@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,185 @@
|
|
|
1
|
+
// src/signed-node-http.ts
|
|
2
|
+
import {
|
|
3
|
+
encodeSignatureHeader,
|
|
4
|
+
generateResponseRecipient,
|
|
5
|
+
openResponse,
|
|
6
|
+
RESPONSE_KEY_HEADER,
|
|
7
|
+
signRequest
|
|
8
|
+
} from "@forgezero/runtime/identity";
|
|
9
|
+
|
|
10
|
+
class SignedNodeHttpError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
constructor(status, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.name = "SignedNodeHttpError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function postSignedNode(options, path, body) {
|
|
19
|
+
const url = new URL(options.apiUrl);
|
|
20
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
|
|
21
|
+
url.search = "";
|
|
22
|
+
url.hash = "";
|
|
23
|
+
const raw = JSON.stringify(body);
|
|
24
|
+
const recipient = generateResponseRecipient();
|
|
25
|
+
const envelope = signRequest(options.keys, options.nodeKey, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
path: url.pathname,
|
|
28
|
+
query: "",
|
|
29
|
+
body: raw,
|
|
30
|
+
responseKey: recipient.publicKey
|
|
31
|
+
});
|
|
32
|
+
const signature = encodeSignatureHeader(envelope);
|
|
33
|
+
const response = await (options.fetch ?? globalThis.fetch)(url, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: {
|
|
36
|
+
"content-type": "application/json",
|
|
37
|
+
"x-fz-node": options.nodeKey,
|
|
38
|
+
"x-fz-signature": signature,
|
|
39
|
+
[RESPONSE_KEY_HEADER]: recipient.publicKey
|
|
40
|
+
},
|
|
41
|
+
body: raw,
|
|
42
|
+
signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
|
|
43
|
+
});
|
|
44
|
+
const payload = await response.json().catch(() => null);
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
const failure = payload;
|
|
47
|
+
const reason = failure ? failure.error?.message ?? failure.message : undefined;
|
|
48
|
+
throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return await openResponse(recipient.secretKey, signature, payload);
|
|
52
|
+
} catch {
|
|
53
|
+
throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/migration-pull.ts
|
|
58
|
+
class MigrationClaimLostError extends Error {
|
|
59
|
+
constructor(message) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = "MigrationClaimLostError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
var post = (options, operation, body) => postSignedNode(options, `v1/node/migrations/${operation}`, body);
|
|
65
|
+
async function complete(options, body) {
|
|
66
|
+
const attempts = Math.max(1, Math.min(options.completionAttempts ?? 5, 10));
|
|
67
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
68
|
+
let last;
|
|
69
|
+
for (let attempt = 1;attempt <= attempts; attempt += 1) {
|
|
70
|
+
try {
|
|
71
|
+
await post(options, "complete", body);
|
|
72
|
+
return;
|
|
73
|
+
} catch (cause) {
|
|
74
|
+
last = cause;
|
|
75
|
+
if (cause instanceof SignedNodeHttpError && cause.status < 500)
|
|
76
|
+
throw cause;
|
|
77
|
+
if (attempt < attempts)
|
|
78
|
+
await sleep(Math.min(2000, 250 * 2 ** (attempt - 1)));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
throw last;
|
|
82
|
+
}
|
|
83
|
+
async function pullMigrationOnce(options) {
|
|
84
|
+
const response = await post(options, "claim", {});
|
|
85
|
+
if (!response.claim)
|
|
86
|
+
return { status: "idle" };
|
|
87
|
+
const claim = response.claim;
|
|
88
|
+
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
89
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
90
|
+
const now = options.now ?? Date.now;
|
|
91
|
+
let expires = claim.claimExpiresAtTs;
|
|
92
|
+
let stopped = false;
|
|
93
|
+
let timer;
|
|
94
|
+
let renewal = null;
|
|
95
|
+
let lost = null;
|
|
96
|
+
const schedule = (override) => {
|
|
97
|
+
if (stopped)
|
|
98
|
+
return;
|
|
99
|
+
const delay = override ?? Math.max(1000, Math.min(5 * 60000, Math.floor(Math.max(0, expires - now()) / 3)));
|
|
100
|
+
timer = setTimer(() => {
|
|
101
|
+
if (stopped || renewal)
|
|
102
|
+
return;
|
|
103
|
+
let retry;
|
|
104
|
+
renewal = post(options, "renew", {
|
|
105
|
+
migrationKey: claim.migrationKey,
|
|
106
|
+
claimToken: claim.claimToken
|
|
107
|
+
}).then((value) => {
|
|
108
|
+
expires = value.claimExpiresAtTs;
|
|
109
|
+
}).catch((cause) => {
|
|
110
|
+
if (cause instanceof SignedNodeHttpError && cause.status < 500) {
|
|
111
|
+
lost = new MigrationClaimLostError(cause.message);
|
|
112
|
+
stopped = true;
|
|
113
|
+
} else
|
|
114
|
+
retry = Math.max(1000, options.renewRetryMs ?? 5000);
|
|
115
|
+
}).finally(() => {
|
|
116
|
+
renewal = null;
|
|
117
|
+
if (!stopped)
|
|
118
|
+
schedule(retry);
|
|
119
|
+
});
|
|
120
|
+
}, delay);
|
|
121
|
+
};
|
|
122
|
+
schedule();
|
|
123
|
+
let evidence;
|
|
124
|
+
try {
|
|
125
|
+
evidence = await options.run(claim);
|
|
126
|
+
} catch (cause) {
|
|
127
|
+
stopped = true;
|
|
128
|
+
clearTimer(timer);
|
|
129
|
+
await renewal;
|
|
130
|
+
if (lost)
|
|
131
|
+
throw lost;
|
|
132
|
+
const reason = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2000);
|
|
133
|
+
await complete(options, {
|
|
134
|
+
migrationKey: claim.migrationKey,
|
|
135
|
+
claimToken: claim.claimToken,
|
|
136
|
+
ok: false,
|
|
137
|
+
detail: reason
|
|
138
|
+
});
|
|
139
|
+
return { status: "failed", claim, reason };
|
|
140
|
+
}
|
|
141
|
+
stopped = true;
|
|
142
|
+
clearTimer(timer);
|
|
143
|
+
await renewal;
|
|
144
|
+
if (lost)
|
|
145
|
+
throw lost;
|
|
146
|
+
await complete(options, {
|
|
147
|
+
migrationKey: claim.migrationKey,
|
|
148
|
+
claimToken: claim.claimToken,
|
|
149
|
+
ok: true,
|
|
150
|
+
evidence
|
|
151
|
+
});
|
|
152
|
+
return { status: "completed", claim, evidence };
|
|
153
|
+
}
|
|
154
|
+
function startMigrationPull(options) {
|
|
155
|
+
const interval = Math.max(1000, options.intervalMs ?? 5000);
|
|
156
|
+
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
157
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
158
|
+
let stopped = false;
|
|
159
|
+
let timer;
|
|
160
|
+
let active = null;
|
|
161
|
+
const tick = () => {
|
|
162
|
+
if (stopped || active)
|
|
163
|
+
return;
|
|
164
|
+
active = pullMigrationOnce(options).then((result) => options.onEvent?.(result.status, result)).catch((cause) => options.onEvent?.("poll-failed", cause)).finally(() => {
|
|
165
|
+
active = null;
|
|
166
|
+
if (!stopped)
|
|
167
|
+
timer = setTimer(tick, interval);
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
tick();
|
|
171
|
+
return {
|
|
172
|
+
async stop() {
|
|
173
|
+
stopped = true;
|
|
174
|
+
clearTimer(timer);
|
|
175
|
+
await active;
|
|
176
|
+
},
|
|
177
|
+
get active() {
|
|
178
|
+
return !stopped;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
export {
|
|
183
|
+
startMigrationPull,
|
|
184
|
+
pullMigrationOnce
|
|
185
|
+
};
|
package/dist/provision.d.ts
CHANGED
|
@@ -92,6 +92,20 @@ export interface UnitOptions {
|
|
|
92
92
|
deploymentCredentials?: Record<string, string>;
|
|
93
93
|
/** Enable authenticated outbound deployment claims after enrolment. */
|
|
94
94
|
pullDeployments?: boolean;
|
|
95
|
+
/** Enable PQ-authenticated lifecycle claims through a constrained root helper. */
|
|
96
|
+
pullMigrations?: boolean;
|
|
97
|
+
/** Root-owned declarative lifecycle profile; never supplied by a migration claim. */
|
|
98
|
+
lifecycleProfilePath?: string;
|
|
99
|
+
lifecycleHelperSocketPath?: string;
|
|
100
|
+
/** Optional cross-network overlay. All three values are required together. */
|
|
101
|
+
warpOrganization?: string;
|
|
102
|
+
warpClientIdCredentialPath?: string;
|
|
103
|
+
warpClientSecretCredentialPath?: string;
|
|
104
|
+
/** Non-secret coordinates bound through the PQ-signed enrolment request. */
|
|
105
|
+
cloudflareAccountId?: string;
|
|
106
|
+
cloudflareTunnelId?: string;
|
|
107
|
+
cloudflareVirtualNetworkId?: string;
|
|
108
|
+
cloudflareWarpPolicyId?: string;
|
|
95
109
|
/** Where `fz-agent` ended up. `bun add -g` puts it on PATH. */
|
|
96
110
|
binPath?: string;
|
|
97
111
|
/** Package-owned binary copied to binPath before hardened units start. */
|
|
@@ -109,9 +123,17 @@ export interface UnitOptions {
|
|
|
109
123
|
export declare const DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
110
124
|
export declare const DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
111
125
|
export declare const VAULT_GROUP = "forgezero-vault";
|
|
126
|
+
export declare const LIFECYCLE_GROUP = "forgezero-lifecycle";
|
|
112
127
|
export declare const DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
|
|
113
128
|
export declare const DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
114
129
|
export declare const ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
|
|
130
|
+
export declare const LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper.service";
|
|
131
|
+
export declare const LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
|
|
132
|
+
export declare const WARP_CONFIG_UNIT_PATH = "/etc/systemd/system/forgezero-warp-config.service";
|
|
133
|
+
export declare const WARP_SERVICE_DROP_IN_PATH = "/etc/systemd/system/warp-svc.service.d/forgezero.conf";
|
|
134
|
+
export declare function warpConfigUnit(options: Pick<UnitOptions, 'binPath' | 'warpOrganization' | 'warpClientIdCredentialPath' | 'warpClientSecretCredentialPath'>): string;
|
|
135
|
+
export declare function warpServiceDropIn(): string;
|
|
136
|
+
export declare function lifecycleHelperUnit(options: Pick<UnitOptions, 'binPath' | 'lifecycleProfilePath' | 'lifecycleHelperSocketPath'>): string;
|
|
115
137
|
/**
|
|
116
138
|
* Exchange a tenant-issued one-time capability before the durable agent starts.
|
|
117
139
|
*
|
package/dist/provision.js
CHANGED
|
@@ -42,9 +42,104 @@ var reasonFor = (mode) => mode === "attested" ? "SEV-SNP guest device present, s
|
|
|
42
42
|
var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
43
43
|
var DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
44
44
|
var VAULT_GROUP = "forgezero-vault";
|
|
45
|
+
var LIFECYCLE_GROUP = "forgezero-lifecycle";
|
|
45
46
|
var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
|
|
46
47
|
var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
47
48
|
var ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
|
|
49
|
+
var LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper.service";
|
|
50
|
+
var LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
|
|
51
|
+
var WARP_CONFIG_UNIT_PATH = "/etc/systemd/system/forgezero-warp-config.service";
|
|
52
|
+
var WARP_SERVICE_DROP_IN_PATH = "/etc/systemd/system/warp-svc.service.d/forgezero.conf";
|
|
53
|
+
var systemdPath = (value, label) => {
|
|
54
|
+
if (!value || !/^\/[A-Za-z0-9._@/-]+$/.test(value))
|
|
55
|
+
throw new Error(`invalid ${label} path`);
|
|
56
|
+
return value;
|
|
57
|
+
};
|
|
58
|
+
function warpConfigUnit(options) {
|
|
59
|
+
if (!options.warpOrganization || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.warpOrganization)) {
|
|
60
|
+
throw new Error("WARP organization is invalid");
|
|
61
|
+
}
|
|
62
|
+
if (!options.warpClientIdCredentialPath || !options.warpClientSecretCredentialPath) {
|
|
63
|
+
throw new Error("WARP service-token credential paths are required");
|
|
64
|
+
}
|
|
65
|
+
const clientIdPath = systemdPath(options.warpClientIdCredentialPath, "WARP client-id credential");
|
|
66
|
+
const clientSecretPath = systemdPath(options.warpClientSecretCredentialPath, "WARP client-secret credential");
|
|
67
|
+
const bin = options.binPath ?? "fz-agent";
|
|
68
|
+
return `[Unit]
|
|
69
|
+
Description=Materialize Cloudflare One enrollment in tmpfs
|
|
70
|
+
Documentation=https://www.forgezero.net/docs/agent
|
|
71
|
+
Before=warp-svc.service
|
|
72
|
+
|
|
73
|
+
[Service]
|
|
74
|
+
Type=oneshot
|
|
75
|
+
RemainAfterExit=yes
|
|
76
|
+
LoadCredentialEncrypted=warp-auth-client-id:${clientIdPath}
|
|
77
|
+
LoadCredentialEncrypted=warp-auth-client-secret:${clientSecretPath}
|
|
78
|
+
Environment=FZ_WARP_CLIENT_ID_CREDENTIAL=warp-auth-client-id
|
|
79
|
+
Environment=FZ_WARP_CLIENT_SECRET_CREDENTIAL=warp-auth-client-secret
|
|
80
|
+
ExecStart=${bin} warp-config --organization=${options.warpOrganization}
|
|
81
|
+
RuntimeDirectory=forgezero-warp
|
|
82
|
+
RuntimeDirectoryMode=0700
|
|
83
|
+
RuntimeDirectoryPreserve=yes
|
|
84
|
+
UMask=0077
|
|
85
|
+
LimitCORE=0
|
|
86
|
+
NoNewPrivileges=true
|
|
87
|
+
PrivateTmp=true
|
|
88
|
+
ProtectSystem=strict
|
|
89
|
+
ProtectHome=true
|
|
90
|
+
ReadWritePaths=/var/lib/cloudflare-warp
|
|
91
|
+
|
|
92
|
+
[Install]
|
|
93
|
+
WantedBy=multi-user.target
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
function warpServiceDropIn() {
|
|
97
|
+
return `[Unit]
|
|
98
|
+
Requires=forgezero-warp-config.service
|
|
99
|
+
After=forgezero-warp-config.service
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
102
|
+
function lifecycleHelperUnit(options) {
|
|
103
|
+
if (!options.lifecycleProfilePath)
|
|
104
|
+
throw new Error("lifecycle helper needs a root-owned profile");
|
|
105
|
+
const bin = options.binPath ?? "fz-agent";
|
|
106
|
+
const profile = systemdPath(options.lifecycleProfilePath, "lifecycle profile");
|
|
107
|
+
const socket = systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket");
|
|
108
|
+
return `[Unit]
|
|
109
|
+
Description=ForgeZero fixed-operation compute lifecycle helper
|
|
110
|
+
Documentation=https://www.forgezero.net/docs/agent
|
|
111
|
+
After=network-online.target
|
|
112
|
+
Wants=network-online.target
|
|
113
|
+
|
|
114
|
+
[Service]
|
|
115
|
+
Type=simple
|
|
116
|
+
User=root
|
|
117
|
+
Group=${LIFECYCLE_GROUP}
|
|
118
|
+
Environment=FZ_LIFECYCLE_HELPER_SOCKET=${socket}
|
|
119
|
+
ExecStart=${bin} lifecycle-helper --profile=${profile}
|
|
120
|
+
Restart=always
|
|
121
|
+
RestartSec=2
|
|
122
|
+
RuntimeDirectory=forgezero-lifecycle
|
|
123
|
+
RuntimeDirectoryMode=0750
|
|
124
|
+
UMask=0007
|
|
125
|
+
LimitCORE=0
|
|
126
|
+
NoNewPrivileges=true
|
|
127
|
+
PrivateTmp=true
|
|
128
|
+
ProtectSystem=strict
|
|
129
|
+
ProtectHome=true
|
|
130
|
+
ProtectKernelTunables=true
|
|
131
|
+
ProtectKernelModules=true
|
|
132
|
+
ProtectControlGroups=true
|
|
133
|
+
RestrictSUIDSGID=true
|
|
134
|
+
RestrictRealtime=true
|
|
135
|
+
MemoryDenyWriteExecute=true
|
|
136
|
+
LockPersonality=true
|
|
137
|
+
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
|
138
|
+
|
|
139
|
+
[Install]
|
|
140
|
+
WantedBy=multi-user.target
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
48
143
|
function agentEnrolmentUnit(options) {
|
|
49
144
|
if (!options.apiUrl || !options.enrolTokenCredentialPath || !options.enrolStatePath) {
|
|
50
145
|
throw new Error("direct enrolment needs API, credential and state paths");
|
|
@@ -56,6 +151,16 @@ function agentEnrolmentUnit(options) {
|
|
|
56
151
|
` : "";
|
|
57
152
|
const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
|
|
58
153
|
` : "";
|
|
154
|
+
const networkAttachment = [
|
|
155
|
+
options.cloudflareAccountId ? `Environment=FZ_CF_ACCOUNT_ID=${options.cloudflareAccountId}
|
|
156
|
+
` : "",
|
|
157
|
+
options.cloudflareTunnelId ? `Environment=FZ_CF_TUNNEL_ID=${options.cloudflareTunnelId}
|
|
158
|
+
` : "",
|
|
159
|
+
options.cloudflareVirtualNetworkId ? `Environment=FZ_CF_VIRTUAL_NETWORK_ID=${options.cloudflareVirtualNetworkId}
|
|
160
|
+
` : "",
|
|
161
|
+
options.cloudflareWarpPolicyId ? `Environment=FZ_CF_WARP_POLICY_ID=${options.cloudflareWarpPolicyId}
|
|
162
|
+
` : ""
|
|
163
|
+
].join("");
|
|
59
164
|
const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
|
|
60
165
|
return `[Unit]
|
|
61
166
|
Description=Bind this machine to its ForgeZero compute
|
|
@@ -74,7 +179,7 @@ Environment=FZ_SEED_CREDENTIAL=agent-seed
|
|
|
74
179
|
Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
|
|
75
180
|
Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
|
|
76
181
|
Environment=FZ_API=${options.apiUrl}
|
|
77
|
-
${label}${gitPublicKey}ExecStart=${bin} enrol
|
|
182
|
+
${label}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
|
|
78
183
|
# A '+' fixed command runs as root solely to remove the host-bound one-time
|
|
79
184
|
# ciphertext. Tenant code and the agent never receive a privilege boundary.
|
|
80
185
|
ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
|
|
@@ -139,6 +244,34 @@ function agentUnit(options) {
|
|
|
139
244
|
const controlSocketPath = options.controlSocketPath ?? "/run/forgezero/control.sock";
|
|
140
245
|
const deployRoot = options.deployRoot ?? "/opt/forgezero";
|
|
141
246
|
const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
|
|
247
|
+
const lifecycleEnabled = Boolean(options.pullMigrations && options.lifecycleProfilePath);
|
|
248
|
+
if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
|
|
249
|
+
throw new Error("migration pull and lifecycle profile must be supplied together");
|
|
250
|
+
}
|
|
251
|
+
const lifecycleHelperSocketPath = lifecycleEnabled ? systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket") : undefined;
|
|
252
|
+
const warpValues = [
|
|
253
|
+
options.warpOrganization,
|
|
254
|
+
options.warpClientIdCredentialPath,
|
|
255
|
+
options.warpClientSecretCredentialPath
|
|
256
|
+
];
|
|
257
|
+
const warpEnabled = warpValues.every(Boolean);
|
|
258
|
+
if (warpValues.some(Boolean) && !warpEnabled)
|
|
259
|
+
throw new Error("WARP configuration must be supplied together");
|
|
260
|
+
const networkAttachmentValues = [
|
|
261
|
+
options.cloudflareAccountId,
|
|
262
|
+
options.cloudflareTunnelId,
|
|
263
|
+
options.cloudflareVirtualNetworkId,
|
|
264
|
+
options.cloudflareWarpPolicyId
|
|
265
|
+
];
|
|
266
|
+
if (networkAttachmentValues.some(Boolean)) {
|
|
267
|
+
if (!options.cloudflareAccountId || !options.cloudflareTunnelId || !options.cloudflareWarpPolicyId) {
|
|
268
|
+
throw new Error("private-network attachment requires account, Tunnel and WARP policy ids");
|
|
269
|
+
}
|
|
270
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
271
|
+
if (!/^[a-f0-9]{32}$/i.test(options.cloudflareAccountId) || !uuid.test(options.cloudflareTunnelId) || options.cloudflareVirtualNetworkId && !uuid.test(options.cloudflareVirtualNetworkId) || !/^[A-Za-z0-9-]{1,64}$/.test(options.cloudflareWarpPolicyId)) {
|
|
272
|
+
throw new Error("private-network attachment coordinates are invalid");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
142
275
|
const enrolmentEnabled = Boolean(options.enrolTokenCredentialPath && options.enrolStatePath);
|
|
143
276
|
const deploymentEnvironment = options.deploymentEnvironment ?? {};
|
|
144
277
|
const deploymentCredentials = options.deploymentCredentials ?? {};
|
|
@@ -173,7 +306,9 @@ function agentUnit(options) {
|
|
|
173
306
|
Object.keys(deploymentEnvironment).length > 0 ? `FZ_DEPLOY_ENV_NAMES=${Object.keys(deploymentEnvironment).join(",")}` : null,
|
|
174
307
|
...Object.entries(deploymentEnvironment).map(([name, value]) => `${name}=${value}`),
|
|
175
308
|
options.publicApiUrl ? `FZ_PUBLIC_API_URL=${options.publicApiUrl}` : null,
|
|
176
|
-
options.pullDeployments ? "FZ_DEPLOY_PULL=true" : null
|
|
309
|
+
options.pullDeployments ? "FZ_DEPLOY_PULL=true" : null,
|
|
310
|
+
options.pullMigrations ? "FZ_MIGRATION_PULL=true" : null,
|
|
311
|
+
options.pullMigrations ? `FZ_LIFECYCLE_HELPER_SOCKET=${lifecycleHelperSocketPath}` : null
|
|
177
312
|
].filter((line) => line !== null);
|
|
178
313
|
if (deploymentEnabled) {
|
|
179
314
|
environment.push(`HOME=${deployRoot}/agent-home`, `XDG_CACHE_HOME=${deployRoot}/cache`);
|
|
@@ -183,14 +318,22 @@ function agentUnit(options) {
|
|
|
183
318
|
const projectCredentials = Object.entries(deploymentCredentials).map(([name, path]) => `LoadCredentialEncrypted=${name}:${path}`).join(`
|
|
184
319
|
`);
|
|
185
320
|
const deploymentWrites = deploymentEnabled ? `ReadWritePaths=${deployRoot}/releases ${deployRoot}/agent-home ${deployRoot}/cache` : "";
|
|
186
|
-
const
|
|
321
|
+
const supplementaryGroups = [
|
|
322
|
+
deploymentEnabled ? DEPLOYMENT_GROUP : null,
|
|
323
|
+
lifecycleEnabled ? LIFECYCLE_GROUP : null
|
|
324
|
+
].filter((value) => value !== null);
|
|
325
|
+
const deploymentGroup = supplementaryGroups.length > 0 ? `SupplementaryGroups=${supplementaryGroups.join(" ")}` : "";
|
|
187
326
|
const after = [
|
|
188
327
|
"network-online.target",
|
|
189
328
|
deploymentEnabled ? "forgezero-deploy-runner.service" : null,
|
|
329
|
+
lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
|
|
330
|
+
warpEnabled ? "warp-svc.service" : null,
|
|
190
331
|
enrolmentEnabled ? "forgezero-agent-enrol.service" : null
|
|
191
332
|
].filter((value) => value !== null);
|
|
192
333
|
const requires = [
|
|
193
334
|
deploymentEnabled ? "forgezero-deploy-runner.service" : null,
|
|
335
|
+
lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
|
|
336
|
+
warpEnabled ? "warp-svc.service" : null,
|
|
194
337
|
enrolmentEnabled ? "forgezero-agent-enrol.service" : null
|
|
195
338
|
].filter((value) => value !== null);
|
|
196
339
|
const deploymentDependency = [
|
|
@@ -262,37 +405,63 @@ function planProvision(options) {
|
|
|
262
405
|
const credentialDir = seedCredentialPath.replace(/\/[^/]+$/, "");
|
|
263
406
|
const deployRoot = options.deployRoot ?? "/opt/forgezero";
|
|
264
407
|
const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
|
|
408
|
+
const lifecycleEnabled = Boolean(options.pullMigrations && options.lifecycleProfilePath);
|
|
409
|
+
if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
|
|
410
|
+
throw new Error("migration pull and lifecycle profile must be supplied together");
|
|
411
|
+
}
|
|
412
|
+
const warpValues = [
|
|
413
|
+
options.warpOrganization,
|
|
414
|
+
options.warpClientIdCredentialPath,
|
|
415
|
+
options.warpClientSecretCredentialPath
|
|
416
|
+
];
|
|
417
|
+
const warpEnabled = warpValues.every(Boolean);
|
|
418
|
+
if (warpValues.some(Boolean) && !warpEnabled)
|
|
419
|
+
throw new Error("WARP configuration must be supplied together");
|
|
265
420
|
const enrolmentEnabled = Boolean(options.enrolTokenSourcePath && options.enrolTokenCredentialPath && options.enrolStatePath);
|
|
266
421
|
if (Boolean(options.enrolTokenSourcePath) !== Boolean(options.enrolTokenCredentialPath) || Boolean(options.enrolTokenCredentialPath) !== Boolean(options.enrolStatePath))
|
|
267
422
|
throw new Error("direct enrolment paths must be supplied together");
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
return value;
|
|
272
|
-
};
|
|
273
|
-
const enrolTokenSourcePath = enrolmentEnabled ? safePath(options.enrolTokenSourcePath, "enrolment source") : undefined;
|
|
274
|
-
const enrolTokenCredentialPath = enrolmentEnabled ? safePath(options.enrolTokenCredentialPath, "enrolment credential") : undefined;
|
|
275
|
-
const enrolStatePath = enrolmentEnabled ? safePath(options.enrolStatePath, "enrolment state") : undefined;
|
|
423
|
+
const enrolTokenSourcePath = enrolmentEnabled ? systemdPath(options.enrolTokenSourcePath, "enrolment source") : undefined;
|
|
424
|
+
const enrolTokenCredentialPath = enrolmentEnabled ? systemdPath(options.enrolTokenCredentialPath, "enrolment credential") : undefined;
|
|
425
|
+
const enrolStatePath = enrolmentEnabled ? systemdPath(options.enrolStatePath, "enrolment state") : undefined;
|
|
276
426
|
const enrolStateDir = enrolStatePath?.replace(/\/[^/]+$/, "");
|
|
277
|
-
const sourceBinPath = options.sourceBinPath ?
|
|
278
|
-
const binPath = options.binPath ?
|
|
279
|
-
const gitCredentialPath = options.gitCredentialPath ?
|
|
280
|
-
const gitPublicKeyPath = options.gitPublicKeyPath ?
|
|
427
|
+
const sourceBinPath = options.sourceBinPath ? systemdPath(options.sourceBinPath, "agent source binary") : undefined;
|
|
428
|
+
const binPath = options.binPath ? systemdPath(options.binPath, "agent binary") : undefined;
|
|
429
|
+
const gitCredentialPath = options.gitCredentialPath ? systemdPath(options.gitCredentialPath, "Git credential") : undefined;
|
|
430
|
+
const gitPublicKeyPath = options.gitPublicKeyPath ? systemdPath(options.gitPublicKeyPath, "Git public key") : undefined;
|
|
281
431
|
if (options.generateGitIdentity && (!gitCredentialPath || !gitPublicKeyPath)) {
|
|
282
432
|
throw new Error("generated Git identity needs credential and public-key paths");
|
|
283
433
|
}
|
|
284
434
|
const gitPublicKeyDir = gitPublicKeyPath?.replace(/\/[^/]+$/, "");
|
|
435
|
+
const lifecycleProfilePath = lifecycleEnabled ? systemdPath(options.lifecycleProfilePath, "lifecycle profile") : undefined;
|
|
436
|
+
const lifecycleHelperSocketPath = lifecycleEnabled ? systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket") : undefined;
|
|
437
|
+
const warpClientIdCredentialPath = warpEnabled ? systemdPath(options.warpClientIdCredentialPath, "WARP client-id credential") : undefined;
|
|
438
|
+
const warpClientSecretCredentialPath = warpEnabled ? systemdPath(options.warpClientSecretCredentialPath, "WARP client-secret credential") : undefined;
|
|
285
439
|
return {
|
|
286
440
|
mode,
|
|
287
441
|
reason: reasonFor(mode),
|
|
288
442
|
unitPath: UNIT_PATH,
|
|
289
|
-
unit: agentUnit({ ...options, mode }),
|
|
443
|
+
unit: agentUnit({ ...options, mode, lifecycleProfilePath, lifecycleHelperSocketPath }),
|
|
290
444
|
auxiliaryUnits: [
|
|
291
445
|
...deploymentEnabled ? [
|
|
292
446
|
{ path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) }
|
|
293
447
|
] : [],
|
|
294
448
|
...enrolmentEnabled ? [
|
|
295
449
|
{ path: ENROLMENT_UNIT_PATH, unit: agentEnrolmentUnit(options) }
|
|
450
|
+
] : [],
|
|
451
|
+
...lifecycleEnabled ? [
|
|
452
|
+
{ path: LIFECYCLE_HELPER_UNIT_PATH, unit: lifecycleHelperUnit({
|
|
453
|
+
...options,
|
|
454
|
+
lifecycleProfilePath,
|
|
455
|
+
lifecycleHelperSocketPath
|
|
456
|
+
}) }
|
|
457
|
+
] : [],
|
|
458
|
+
...warpEnabled ? [
|
|
459
|
+
{ path: WARP_CONFIG_UNIT_PATH, unit: warpConfigUnit({
|
|
460
|
+
...options,
|
|
461
|
+
warpClientIdCredentialPath,
|
|
462
|
+
warpClientSecretCredentialPath
|
|
463
|
+
}) },
|
|
464
|
+
{ path: WARP_SERVICE_DROP_IN_PATH, unit: warpServiceDropIn() }
|
|
296
465
|
] : []
|
|
297
466
|
],
|
|
298
467
|
socketPath: options.socketPath,
|
|
@@ -306,10 +475,18 @@ function planProvision(options) {
|
|
|
306
475
|
label: "root-owned agent runtime",
|
|
307
476
|
command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
|
|
308
477
|
}] : [],
|
|
478
|
+
...warpEnabled ? [{
|
|
479
|
+
label: "Cloudflare One client for Ubuntu 26.04",
|
|
480
|
+
command: `. /etc/os-release; test "$ID" = ubuntu && test "$VERSION_ID" = 26.04; ` + `install -d -m 0755 /usr/share/keyrings /etc/apt/sources.list.d /etc/systemd/system/warp-svc.service.d; ` + `curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg -o /run/cloudflare-warp-key.gpg; ` + `gpg --batch --yes --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg /run/cloudflare-warp-key.gpg; ` + `rm -f /run/cloudflare-warp-key.gpg; ` + `printf 'deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ %s main\\n' "$VERSION_CODENAME" > /etc/apt/sources.list.d/cloudflare-client.list; ` + `apt-get update -qq; DEBIAN_FRONTEND=noninteractive apt-get install -y cloudflare-warp`
|
|
481
|
+
}] : [],
|
|
309
482
|
...deploymentEnabled ? [{
|
|
310
483
|
label: "deployment isolation group",
|
|
311
484
|
command: `groupadd --system ${DEPLOYMENT_GROUP} || true`
|
|
312
485
|
}] : [],
|
|
486
|
+
...lifecycleEnabled ? [{
|
|
487
|
+
label: "lifecycle helper access group",
|
|
488
|
+
command: `groupadd --system ${LIFECYCLE_GROUP} || true`
|
|
489
|
+
}] : [],
|
|
313
490
|
{
|
|
314
491
|
label: "service account",
|
|
315
492
|
command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
|
|
@@ -318,6 +495,10 @@ function planProvision(options) {
|
|
|
318
495
|
label: "bind service account to vault group",
|
|
319
496
|
command: `usermod -g ${VAULT_GROUP} ${user}`
|
|
320
497
|
},
|
|
498
|
+
...lifecycleEnabled ? [{
|
|
499
|
+
label: "grant lifecycle helper socket access",
|
|
500
|
+
command: `usermod -a -G ${LIFECYCLE_GROUP} ${user}`
|
|
501
|
+
}] : [],
|
|
321
502
|
...deploymentEnabled ? [{
|
|
322
503
|
label: "credential-free deployment account",
|
|
323
504
|
command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
|
|
@@ -363,6 +544,8 @@ function planProvision(options) {
|
|
|
363
544
|
label: "enable and start",
|
|
364
545
|
command: `systemctl enable --now ${[
|
|
365
546
|
...deploymentEnabled ? ["forgezero-deploy-runner.service"] : [],
|
|
547
|
+
...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
|
|
548
|
+
...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
|
|
366
549
|
...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : [],
|
|
367
550
|
"forgezero-agent.service"
|
|
368
551
|
].join(" ")}`
|
|
@@ -377,6 +560,14 @@ function planProvision(options) {
|
|
|
377
560
|
label: "prove the deployment runner socket exists",
|
|
378
561
|
command: `test -S ${DEPLOYMENT_RUNNER_SOCKET}`
|
|
379
562
|
}] : [],
|
|
563
|
+
...lifecycleEnabled ? [{
|
|
564
|
+
label: "prove the lifecycle helper socket exists",
|
|
565
|
+
command: `test -S ${lifecycleHelperSocketPath}`
|
|
566
|
+
}] : [],
|
|
567
|
+
...warpEnabled ? [{
|
|
568
|
+
label: "prove Cloudflare WARP is connected",
|
|
569
|
+
command: `warp-cli --accept-tos status | grep -Eiq '(^|[[:space:]])Connected([[:space:]]|$)'`
|
|
570
|
+
}] : [],
|
|
380
571
|
...options.repository ? [{
|
|
381
572
|
label: "prove the deployment control socket exists",
|
|
382
573
|
command: `test -S ${options.controlSocketPath ?? "/run/forgezero/control.sock"}`
|
|
@@ -385,15 +576,23 @@ function planProvision(options) {
|
|
|
385
576
|
};
|
|
386
577
|
}
|
|
387
578
|
export {
|
|
579
|
+
warpServiceDropIn,
|
|
580
|
+
warpConfigUnit,
|
|
388
581
|
reasonFor,
|
|
389
582
|
planProvision,
|
|
390
583
|
modeFor,
|
|
584
|
+
lifecycleHelperUnit,
|
|
391
585
|
deploymentRunnerUnit,
|
|
392
586
|
atLeast,
|
|
393
587
|
agentUnit,
|
|
394
588
|
agentEnrolmentUnit,
|
|
589
|
+
WARP_SERVICE_DROP_IN_PATH,
|
|
590
|
+
WARP_CONFIG_UNIT_PATH,
|
|
395
591
|
VAULT_GROUP,
|
|
396
592
|
UNIT_PATH,
|
|
593
|
+
LIFECYCLE_HELPER_UNIT_PATH,
|
|
594
|
+
LIFECYCLE_HELPER_SOCKET,
|
|
595
|
+
LIFECYCLE_GROUP,
|
|
397
596
|
ENROLMENT_UNIT_PATH,
|
|
398
597
|
DEPLOYMENT_RUNNER_USER,
|
|
399
598
|
DEPLOYMENT_RUNNER_UNIT_PATH,
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** One package version shared by both public binaries. Pinned to package.json by tests. */
|
|
2
|
-
export declare const VERSION = "0.1.
|
|
2
|
+
export declare const VERSION = "0.1.24";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Cloudflare One MDM document; callers keep both token values out of argv/env. */
|
|
2
|
+
export declare function renderWarpMdm(options: {
|
|
3
|
+
organization: string;
|
|
4
|
+
clientId: string;
|
|
5
|
+
clientSecret: string;
|
|
6
|
+
}): string;
|
|
7
|
+
/**
|
|
8
|
+
* Materialize enrollment only in /run. The durable /var/lib path is a symlink,
|
|
9
|
+
* so neither service-token half is left as plaintext on the filesystem.
|
|
10
|
+
*/
|
|
11
|
+
export declare function materializeWarpMdm(options: {
|
|
12
|
+
organization: string;
|
|
13
|
+
clientId: string;
|
|
14
|
+
clientSecret: string;
|
|
15
|
+
runtimePath?: string;
|
|
16
|
+
servicePath?: string;
|
|
17
|
+
}): {
|
|
18
|
+
runtimePath: string;
|
|
19
|
+
servicePath: string;
|
|
20
|
+
};
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
|
|
3
3
|
"name": "@forgezero/agent",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.24",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"check": "tsc --noEmit",
|
|
8
8
|
"prebuild": "rm -rf dist",
|
|
9
|
-
"build": "bun build src/index.ts --outfile dist/fz-agent.js --target bun --format esm --packages external && bun build src/cli/index.ts --outfile dist/fz.js --target bun --format esm --packages external && bun build src/compute.ts src/provision.ts src/subscribe.ts src/pipeline.ts src/definition.ts src/ssh-server.ts src/ssh-listen.ts src/provisioning-pull.ts src/guest-enrolment.ts src/node-vault.ts src/metal-provision.ts src/metal-helper-socket.ts src/deployment-runner.ts src/ubuntu.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
|
|
9
|
+
"build": "bun build src/index.ts --outfile dist/fz-agent.js --target bun --format esm --packages external && bun build src/cli/index.ts --outfile dist/fz.js --target bun --format esm --packages external && bun build src/compute.ts src/provision.ts src/subscribe.ts src/pipeline.ts src/definition.ts src/ssh-server.ts src/ssh-listen.ts src/provisioning-pull.ts src/migration-pull.ts src/guest-enrolment.ts src/node-vault.ts src/metal-provision.ts src/metal-helper-socket.ts src/lifecycle-helper.ts src/deployment-runner.ts src/ubuntu.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
|
|
10
10
|
"prepublishOnly": "bun run check && bun run build"
|
|
11
11
|
},
|
|
12
12
|
"devDependencies": {
|
|
@@ -88,6 +88,14 @@
|
|
|
88
88
|
"types": "./dist/provisioning-pull.d.ts",
|
|
89
89
|
"default": "./dist/provisioning-pull.js"
|
|
90
90
|
},
|
|
91
|
+
"./migration-pull": {
|
|
92
|
+
"types": "./dist/migration-pull.d.ts",
|
|
93
|
+
"default": "./dist/migration-pull.js"
|
|
94
|
+
},
|
|
95
|
+
"./lifecycle-helper": {
|
|
96
|
+
"types": "./dist/lifecycle-helper.d.ts",
|
|
97
|
+
"default": "./dist/lifecycle-helper.js"
|
|
98
|
+
},
|
|
91
99
|
"./metal-provision": {
|
|
92
100
|
"types": "./dist/metal-provision.d.ts",
|
|
93
101
|
"default": "./dist/metal-provision.js"
|