@forgezero/agent 0.1.23 → 0.1.25

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.
@@ -56,6 +56,10 @@ export interface InstallOptions {
56
56
  warpOrganization?: string;
57
57
  warpClientIdCredentialPath?: string;
58
58
  warpClientSecretCredentialPath?: string;
59
+ cloudflareAccountId?: string;
60
+ cloudflareTunnelId?: string;
61
+ cloudflareVirtualNetworkId?: string;
62
+ cloudflareWarpPolicyId?: string;
59
63
  binPath?: string;
60
64
  sourceBinPath?: string;
61
65
  user?: string;
@@ -66,6 +70,7 @@ export interface InstallOptions {
66
70
  enrolTokenCredentialPath?: string;
67
71
  enrolStatePath?: string;
68
72
  nodeLabel?: string;
73
+ nodeHostname?: string;
69
74
  }
70
75
  export declare function planInstall(options: InstallOptions): ProvisionPlan;
71
76
  /** Execute the same ordered plan the control plane executes over SSH. */
package/dist/fz-agent.js CHANGED
@@ -1158,6 +1158,26 @@ import {
1158
1158
  writeFileSync as writeFileSync2
1159
1159
  } from "fs";
1160
1160
  import { dirname as dirname2 } from "path";
1161
+ function privateNetworkAttachmentFromEnvironment(env = process.env) {
1162
+ const values = {
1163
+ accountId: env.FZ_CF_ACCOUNT_ID?.trim() ?? "",
1164
+ tunnelId: env.FZ_CF_TUNNEL_ID?.trim() ?? "",
1165
+ virtualNetworkId: env.FZ_CF_VIRTUAL_NETWORK_ID?.trim() ?? "",
1166
+ warpPolicyId: env.FZ_CF_WARP_POLICY_ID?.trim() ?? ""
1167
+ };
1168
+ const supplied = [values.accountId, values.tunnelId, values.virtualNetworkId, values.warpPolicyId].some(Boolean);
1169
+ if (!supplied)
1170
+ return;
1171
+ if (!values.accountId || !values.tunnelId || !values.warpPolicyId) {
1172
+ throw new Error("private-network attachment requires account, Tunnel and WARP policy ids");
1173
+ }
1174
+ return {
1175
+ accountId: values.accountId,
1176
+ tunnelId: values.tunnelId,
1177
+ ...values.virtualNetworkId ? { virtualNetworkId: values.virtualNetworkId } : {},
1178
+ warpPolicyId: values.warpPolicyId
1179
+ };
1180
+ }
1161
1181
  var validBinding = (value, expectedNodeKey) => {
1162
1182
  if (!value || typeof value !== "object")
1163
1183
  return false;
@@ -1204,6 +1224,8 @@ async function enrolGuestIdentity(options) {
1204
1224
  token,
1205
1225
  label: options.label,
1206
1226
  gitDeployPublicKey: options.gitDeployPublicKey,
1227
+ privateNetworkAttachment: options.privateNetworkAttachment,
1228
+ edgeHostname: options.edgeHostname?.trim() || undefined,
1207
1229
  publicKeys: {
1208
1230
  ed25519: options.keys.ed25519.publicKey,
1209
1231
  mlDsa: options.keys.mlDsa.publicKey
@@ -2696,17 +2718,23 @@ function validateLifecycleProfile(profile) {
2696
2718
  if (!Array.isArray(profile.apiUnits) || profile.apiUnits.length < 1 || profile.apiUnits.some((unit) => !unitPattern.test(unit))) {
2697
2719
  throw new Error("lifecycle profile needs one or more valid API service units");
2698
2720
  }
2699
- if (!unitPattern.test(profile.databaseUnit))
2721
+ const databaseValues = [profile.databaseUnit, profile.databaseHealthUrl, profile.databasePorts];
2722
+ if (databaseValues.some((value) => value !== undefined) && databaseValues.some((value) => value === undefined)) {
2723
+ throw new Error("database lifecycle settings must be supplied together");
2724
+ }
2725
+ if (profile.databaseUnit && !unitPattern.test(profile.databaseUnit))
2700
2726
  throw new Error("lifecycle profile database unit is invalid");
2701
2727
  const apiUrl = new URL(profile.apiHealthUrl);
2702
2728
  if (apiUrl.protocol !== "http:" || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(apiUrl.hostname)) {
2703
2729
  throw new Error("API health URL must be loopback HTTP");
2704
2730
  }
2705
- const databaseUrl = new URL(profile.databaseHealthUrl);
2706
- if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
2707
- throw new Error("database health URL must be loopback or private HTTP");
2731
+ if (profile.databaseHealthUrl) {
2732
+ const databaseUrl = new URL(profile.databaseHealthUrl);
2733
+ if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
2734
+ throw new Error("database health URL must be loopback or private HTTP");
2735
+ }
2708
2736
  }
2709
- if (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
2737
+ if (profile.databasePorts && (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535))) {
2710
2738
  throw new Error("lifecycle profile database ports are invalid");
2711
2739
  }
2712
2740
  }
@@ -2765,7 +2793,7 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
2765
2793
  throw new Error("WARP is not connected");
2766
2794
  }
2767
2795
  for (const address of claim.peerPrivateAddresses) {
2768
- for (const port of profile.databasePorts)
2796
+ for (const port of profile.databasePorts ?? [])
2769
2797
  await tcpProbe(address, port);
2770
2798
  }
2771
2799
  return {
@@ -2775,6 +2803,9 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
2775
2803
  };
2776
2804
  }
2777
2805
  case "database-member-ready": {
2806
+ if (!profile.databaseUnit || !profile.databaseHealthUrl) {
2807
+ throw new Error("this workload has no controller-managed database lifecycle");
2808
+ }
2778
2809
  await requireSuccess(exec, ["/usr/bin/systemctl", "is-active", profile.databaseUnit], "database service check");
2779
2810
  const response = await fetcher(profile.databaseHealthUrl, { signal: AbortSignal.timeout(5000) });
2780
2811
  if (!response.ok && response.status !== 401)
@@ -2791,7 +2822,12 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
2791
2822
  await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits], "API drain");
2792
2823
  return { sourceDrained: true };
2793
2824
  case "source-stopped":
2794
- await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits, profile.databaseUnit], "source retirement");
2825
+ await requireSuccess(exec, [
2826
+ "/usr/bin/systemctl",
2827
+ "stop",
2828
+ ...profile.apiUnits,
2829
+ ...profile.databaseUnit ? [profile.databaseUnit] : []
2830
+ ], "source retirement");
2795
2831
  return { sourceStopped: true };
2796
2832
  default:
2797
2833
  throw new Error("unknown lifecycle action");
@@ -2919,7 +2955,7 @@ function materializeWarpMdm(options) {
2919
2955
  }
2920
2956
 
2921
2957
  // src/version.ts
2922
- var VERSION = "0.1.23";
2958
+ var VERSION = "0.1.25";
2923
2959
 
2924
2960
  // src/index.ts
2925
2961
  function loadOrCreateSeed(path) {
@@ -3062,7 +3098,9 @@ if (import.meta.main) {
3062
3098
  nodeKey: nodeKey2,
3063
3099
  keys: keys2,
3064
3100
  label: process.env.FZ_NODE_LABEL,
3065
- gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
3101
+ edgeHostname: process.env.FZ_NODE_HOSTNAME,
3102
+ gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
3103
+ privateNetworkAttachment: privateNetworkAttachmentFromEnvironment()
3066
3104
  });
3067
3105
  console.log(`[agent] enrolled ${binding2.computeReference} in project ${binding2.projectKey}/${binding2.environmentKey}`);
3068
3106
  process.exit(0);
@@ -3280,7 +3318,9 @@ if (import.meta.main) {
3280
3318
  nodeKey,
3281
3319
  keys,
3282
3320
  label: process.env.FZ_NODE_LABEL,
3283
- gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
3321
+ edgeHostname: process.env.FZ_NODE_HOSTNAME,
3322
+ gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
3323
+ privateNetworkAttachment: privateNetworkAttachmentFromEnvironment()
3284
3324
  });
3285
3325
  console.log(`[agent] enrolled ${binding.computeReference} in project ${binding.projectKey}/${binding.environmentKey}`);
3286
3326
  }
package/dist/fz.js CHANGED
@@ -163,6 +163,7 @@ var systemdPath = (value, label) => {
163
163
  throw new Error(`invalid ${label} path`);
164
164
  return value;
165
165
  };
166
+ var validNodeHostname = (value) => !value || value.length <= 253 && value === value.toLowerCase() && value.split(".").length >= 3 && value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
166
167
  function warpConfigUnit(options) {
167
168
  if (!options.warpOrganization || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.warpOrganization)) {
168
169
  throw new Error("WARP organization is invalid");
@@ -252,13 +253,27 @@ function agentEnrolmentUnit(options) {
252
253
  if (!options.apiUrl || !options.enrolTokenCredentialPath || !options.enrolStatePath) {
253
254
  throw new Error("direct enrolment needs API, credential and state paths");
254
255
  }
256
+ if (!validNodeHostname(options.nodeHostname))
257
+ throw new Error("node hostname is invalid");
255
258
  const bin = options.binPath ?? "fz-agent";
256
259
  const user = options.user ?? "forgezero";
257
260
  const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
258
261
  const label = options.nodeLabel ? `Environment=FZ_NODE_LABEL=${options.nodeLabel}
262
+ ` : "";
263
+ const hostname = options.nodeHostname ? `Environment=FZ_NODE_HOSTNAME=${options.nodeHostname}
259
264
  ` : "";
260
265
  const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
261
266
  ` : "";
267
+ const networkAttachment = [
268
+ options.cloudflareAccountId ? `Environment=FZ_CF_ACCOUNT_ID=${options.cloudflareAccountId}
269
+ ` : "",
270
+ options.cloudflareTunnelId ? `Environment=FZ_CF_TUNNEL_ID=${options.cloudflareTunnelId}
271
+ ` : "",
272
+ options.cloudflareVirtualNetworkId ? `Environment=FZ_CF_VIRTUAL_NETWORK_ID=${options.cloudflareVirtualNetworkId}
273
+ ` : "",
274
+ options.cloudflareWarpPolicyId ? `Environment=FZ_CF_WARP_POLICY_ID=${options.cloudflareWarpPolicyId}
275
+ ` : ""
276
+ ].join("");
262
277
  const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
263
278
  return `[Unit]
264
279
  Description=Bind this machine to its ForgeZero compute
@@ -277,7 +292,7 @@ Environment=FZ_SEED_CREDENTIAL=agent-seed
277
292
  Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
278
293
  Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
279
294
  Environment=FZ_API=${options.apiUrl}
280
- ${label}${gitPublicKey}ExecStart=${bin} enrol
295
+ ${label}${hostname}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
281
296
  # A '+' fixed command runs as root solely to remove the host-bound one-time
282
297
  # ciphertext. Tenant code and the agent never receive a privilege boundary.
283
298
  ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
@@ -336,6 +351,8 @@ WantedBy=multi-user.target
336
351
  `;
337
352
  }
338
353
  function agentUnit(options) {
354
+ if (!validNodeHostname(options.nodeHostname))
355
+ throw new Error("node hostname is invalid");
339
356
  const bin = options.binPath ?? "fz-agent";
340
357
  const user = options.user ?? "forgezero";
341
358
  const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
@@ -355,6 +372,21 @@ function agentUnit(options) {
355
372
  const warpEnabled = warpValues.every(Boolean);
356
373
  if (warpValues.some(Boolean) && !warpEnabled)
357
374
  throw new Error("WARP configuration must be supplied together");
375
+ const networkAttachmentValues = [
376
+ options.cloudflareAccountId,
377
+ options.cloudflareTunnelId,
378
+ options.cloudflareVirtualNetworkId,
379
+ options.cloudflareWarpPolicyId
380
+ ];
381
+ if (networkAttachmentValues.some(Boolean)) {
382
+ if (!options.cloudflareAccountId || !options.cloudflareTunnelId || !options.cloudflareWarpPolicyId) {
383
+ throw new Error("private-network attachment requires account, Tunnel and WARP policy ids");
384
+ }
385
+ 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;
386
+ 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)) {
387
+ throw new Error("private-network attachment coordinates are invalid");
388
+ }
389
+ }
358
390
  const enrolmentEnabled = Boolean(options.enrolTokenCredentialPath && options.enrolStatePath);
359
391
  const deploymentEnvironment = options.deploymentEnvironment ?? {};
360
392
  const deploymentCredentials = options.deploymentCredentials ?? {};
@@ -378,6 +410,7 @@ function agentUnit(options) {
378
410
  options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null,
379
411
  options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
380
412
  options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
413
+ options.nodeHostname ? `FZ_NODE_HOSTNAME=${options.nodeHostname}` : null,
381
414
  options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
382
415
  options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
383
416
  options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
@@ -941,7 +974,7 @@ async function resolveIdentity(selector, socketPath) {
941
974
  }
942
975
 
943
976
  // src/version.ts
944
- var VERSION = "0.1.23";
977
+ var VERSION = "0.1.25";
945
978
 
946
979
  // src/cli/index.ts
947
980
  var DEFAULT_MODE = THRESHOLD_MODES[0].id;
@@ -1170,12 +1203,17 @@ async function cmdAgent(options, args) {
1170
1203
  warpOrganization: process.env.FZ_WARP_ORGANIZATION,
1171
1204
  warpClientIdCredentialPath: process.env.FZ_WARP_CLIENT_ID_CREDENTIAL_PATH,
1172
1205
  warpClientSecretCredentialPath: process.env.FZ_WARP_CLIENT_SECRET_CREDENTIAL_PATH,
1206
+ cloudflareAccountId: process.env.FZ_CF_ACCOUNT_ID,
1207
+ cloudflareTunnelId: process.env.FZ_CF_TUNNEL_ID,
1208
+ cloudflareVirtualNetworkId: process.env.FZ_CF_VIRTUAL_NETWORK_ID,
1209
+ cloudflareWarpPolicyId: process.env.FZ_CF_WARP_POLICY_ID,
1173
1210
  ...options.enrol ? {
1174
1211
  pullDeployments: true,
1175
1212
  enrolTokenSourcePath,
1176
1213
  enrolTokenCredentialPath,
1177
1214
  enrolStatePath,
1178
1215
  nodeLabel: process.env.FZ_NODE_LABEL,
1216
+ nodeHostname: process.env.FZ_NODE_HOSTNAME,
1179
1217
  deployRoot: process.env.FZ_DEPLOY_ROOT ?? "/opt/forgezero"
1180
1218
  } : {},
1181
1219
  binPath: process.env.FZ_AGENT_BIN ?? "/usr/local/lib/forgezero/agent/fz-agent",
@@ -7,6 +7,14 @@ export interface GuestBinding {
7
7
  realm: 'platform' | 'tenant';
8
8
  tenantSlug?: string;
9
9
  }
10
+ export interface GuestPrivateNetworkAttachment {
11
+ accountId: string;
12
+ tunnelId: string;
13
+ virtualNetworkId?: string;
14
+ warpPolicyId: string;
15
+ }
16
+ /** Fail closed when setup supplied only part of a cross-network attachment. */
17
+ export declare function privateNetworkAttachmentFromEnvironment(env?: Record<string, string | undefined>): GuestPrivateNetworkAttachment | undefined;
10
18
  export interface GuestEnrolmentOptions {
11
19
  apiUrl: string;
12
20
  /** Already-unsealed systemd credential. Production uses this path. */
@@ -21,6 +29,9 @@ export interface GuestEnrolmentOptions {
21
29
  keys: NodeKeyPair;
22
30
  label?: string;
23
31
  gitDeployPublicKey?: string;
32
+ privateNetworkAttachment?: GuestPrivateNetworkAttachment;
33
+ /** Stable API ingress identity; signed with the rest of the enrolment body. */
34
+ edgeHostname?: string;
24
35
  fetch?: (input: URL, init: RequestInit) => Promise<Response>;
25
36
  requestTimeoutMs?: number;
26
37
  }
@@ -68,6 +68,26 @@ async function postSignedNode(options, path, body) {
68
68
  }
69
69
 
70
70
  // src/guest-enrolment.ts
71
+ function privateNetworkAttachmentFromEnvironment(env = process.env) {
72
+ const values = {
73
+ accountId: env.FZ_CF_ACCOUNT_ID?.trim() ?? "",
74
+ tunnelId: env.FZ_CF_TUNNEL_ID?.trim() ?? "",
75
+ virtualNetworkId: env.FZ_CF_VIRTUAL_NETWORK_ID?.trim() ?? "",
76
+ warpPolicyId: env.FZ_CF_WARP_POLICY_ID?.trim() ?? ""
77
+ };
78
+ const supplied = [values.accountId, values.tunnelId, values.virtualNetworkId, values.warpPolicyId].some(Boolean);
79
+ if (!supplied)
80
+ return;
81
+ if (!values.accountId || !values.tunnelId || !values.warpPolicyId) {
82
+ throw new Error("private-network attachment requires account, Tunnel and WARP policy ids");
83
+ }
84
+ return {
85
+ accountId: values.accountId,
86
+ tunnelId: values.tunnelId,
87
+ ...values.virtualNetworkId ? { virtualNetworkId: values.virtualNetworkId } : {},
88
+ warpPolicyId: values.warpPolicyId
89
+ };
90
+ }
71
91
  var validBinding = (value, expectedNodeKey) => {
72
92
  if (!value || typeof value !== "object")
73
93
  return false;
@@ -114,6 +134,8 @@ async function enrolGuestIdentity(options) {
114
134
  token,
115
135
  label: options.label,
116
136
  gitDeployPublicKey: options.gitDeployPublicKey,
137
+ privateNetworkAttachment: options.privateNetworkAttachment,
138
+ edgeHostname: options.edgeHostname?.trim() || undefined,
117
139
  publicKeys: {
118
140
  ed25519: options.keys.ed25519.publicKey,
119
141
  mlDsa: options.keys.mlDsa.publicKey
@@ -138,6 +160,7 @@ async function enrolGuestIdentity(options) {
138
160
  return binding;
139
161
  }
140
162
  export {
163
+ privateNetworkAttachmentFromEnvironment,
141
164
  loadGuestBinding,
142
165
  enrolGuestIdentity
143
166
  };
@@ -5,13 +5,13 @@ export interface LifecycleProfile {
5
5
  /** Root-owned systemd units whose normal stop path performs application drain. */
6
6
  apiUnits: readonly string[];
7
7
  /** Root-owned database service stopped only at the final retirement stage. */
8
- databaseUnit: string;
8
+ databaseUnit?: string;
9
9
  /** Loopback health endpoint for the API on this compute. */
10
10
  apiHealthUrl: string;
11
11
  /** Loopback health endpoint for the database member on this compute. */
12
- databaseHealthUrl: string;
12
+ databaseHealthUrl?: string;
13
13
  /** Ports that must be reachable on every signed controller-provided private peer. */
14
- databasePorts: readonly number[];
14
+ databasePorts?: readonly number[];
15
15
  }
16
16
  export interface LifecycleCommandResult {
17
17
  exitCode: number;
@@ -22,17 +22,23 @@ function validateLifecycleProfile(profile) {
22
22
  if (!Array.isArray(profile.apiUnits) || profile.apiUnits.length < 1 || profile.apiUnits.some((unit) => !unitPattern.test(unit))) {
23
23
  throw new Error("lifecycle profile needs one or more valid API service units");
24
24
  }
25
- if (!unitPattern.test(profile.databaseUnit))
25
+ const databaseValues = [profile.databaseUnit, profile.databaseHealthUrl, profile.databasePorts];
26
+ if (databaseValues.some((value) => value !== undefined) && databaseValues.some((value) => value === undefined)) {
27
+ throw new Error("database lifecycle settings must be supplied together");
28
+ }
29
+ if (profile.databaseUnit && !unitPattern.test(profile.databaseUnit))
26
30
  throw new Error("lifecycle profile database unit is invalid");
27
31
  const apiUrl = new URL(profile.apiHealthUrl);
28
32
  if (apiUrl.protocol !== "http:" || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(apiUrl.hostname)) {
29
33
  throw new Error("API health URL must be loopback HTTP");
30
34
  }
31
- const databaseUrl = new URL(profile.databaseHealthUrl);
32
- if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
33
- throw new Error("database health URL must be loopback or private HTTP");
35
+ if (profile.databaseHealthUrl) {
36
+ const databaseUrl = new URL(profile.databaseHealthUrl);
37
+ if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
38
+ throw new Error("database health URL must be loopback or private HTTP");
39
+ }
34
40
  }
35
- if (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
41
+ if (profile.databasePorts && (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535))) {
36
42
  throw new Error("lifecycle profile database ports are invalid");
37
43
  }
38
44
  }
@@ -91,7 +97,7 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
91
97
  throw new Error("WARP is not connected");
92
98
  }
93
99
  for (const address of claim.peerPrivateAddresses) {
94
- for (const port of profile.databasePorts)
100
+ for (const port of profile.databasePorts ?? [])
95
101
  await tcpProbe(address, port);
96
102
  }
97
103
  return {
@@ -101,6 +107,9 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
101
107
  };
102
108
  }
103
109
  case "database-member-ready": {
110
+ if (!profile.databaseUnit || !profile.databaseHealthUrl) {
111
+ throw new Error("this workload has no controller-managed database lifecycle");
112
+ }
104
113
  await requireSuccess(exec, ["/usr/bin/systemctl", "is-active", profile.databaseUnit], "database service check");
105
114
  const response = await fetcher(profile.databaseHealthUrl, { signal: AbortSignal.timeout(5000) });
106
115
  if (!response.ok && response.status !== 401)
@@ -117,7 +126,12 @@ async function executeLifecycleAction(profile, claim, exec = spawnLifecycleComma
117
126
  await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits], "API drain");
118
127
  return { sourceDrained: true };
119
128
  case "source-stopped":
120
- await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits, profile.databaseUnit], "source retirement");
129
+ await requireSuccess(exec, [
130
+ "/usr/bin/systemctl",
131
+ "stop",
132
+ ...profile.apiUnits,
133
+ ...profile.databaseUnit ? [profile.databaseUnit] : []
134
+ ], "source retirement");
121
135
  return { sourceStopped: true };
122
136
  default:
123
137
  throw new Error("unknown lifecycle action");
@@ -1,5 +1,6 @@
1
1
  import type { NodeKeyPair } from '@forgezero/runtime/identity';
2
2
  export type MigrationNetwork = 'private-lan' | 'cloudflare-warp';
3
+ export type MigrationEvidenceProfile = 'forgezero-platform' | 'tenant-managed';
3
4
  export type MigrationAction = 'network-ready' | 'database-member-ready' | 'api-ready' | 'source-drained' | 'source-stopped';
4
5
  export interface MigrationEvidence {
5
6
  targetAgentReady?: boolean;
@@ -14,6 +15,7 @@ export interface RemoteMigrationClaim {
14
15
  migrationKey: string;
15
16
  action: MigrationAction;
16
17
  network: MigrationNetwork;
18
+ evidenceProfile?: MigrationEvidenceProfile;
17
19
  claimToken: string;
18
20
  claimExpiresAtTs: number;
19
21
  attempt: number;
@@ -101,6 +101,11 @@ export interface UnitOptions {
101
101
  warpOrganization?: string;
102
102
  warpClientIdCredentialPath?: string;
103
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;
104
109
  /** Where `fz-agent` ended up. `bun add -g` puts it on PATH. */
105
110
  binPath?: string;
106
111
  /** Package-owned binary copied to binPath before hardened units start. */
@@ -114,6 +119,8 @@ export interface UnitOptions {
114
119
  enrolTokenCredentialPath?: string;
115
120
  enrolStatePath?: string;
116
121
  nodeLabel?: string;
122
+ /** Stable API ingress identity bound by the signed one-time enrolment. */
123
+ nodeHostname?: string;
117
124
  }
118
125
  export declare const DEPLOYMENT_RUNNER_USER = "forgezero-runner";
119
126
  export declare const DEPLOYMENT_GROUP = "forgezero-deploy";
package/dist/provision.js CHANGED
@@ -55,6 +55,7 @@ var systemdPath = (value, label) => {
55
55
  throw new Error(`invalid ${label} path`);
56
56
  return value;
57
57
  };
58
+ var validNodeHostname = (value) => !value || value.length <= 253 && value === value.toLowerCase() && value.split(".").length >= 3 && value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
58
59
  function warpConfigUnit(options) {
59
60
  if (!options.warpOrganization || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.warpOrganization)) {
60
61
  throw new Error("WARP organization is invalid");
@@ -144,13 +145,27 @@ function agentEnrolmentUnit(options) {
144
145
  if (!options.apiUrl || !options.enrolTokenCredentialPath || !options.enrolStatePath) {
145
146
  throw new Error("direct enrolment needs API, credential and state paths");
146
147
  }
148
+ if (!validNodeHostname(options.nodeHostname))
149
+ throw new Error("node hostname is invalid");
147
150
  const bin = options.binPath ?? "fz-agent";
148
151
  const user = options.user ?? "forgezero";
149
152
  const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
150
153
  const label = options.nodeLabel ? `Environment=FZ_NODE_LABEL=${options.nodeLabel}
154
+ ` : "";
155
+ const hostname = options.nodeHostname ? `Environment=FZ_NODE_HOSTNAME=${options.nodeHostname}
151
156
  ` : "";
152
157
  const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
153
158
  ` : "";
159
+ const networkAttachment = [
160
+ options.cloudflareAccountId ? `Environment=FZ_CF_ACCOUNT_ID=${options.cloudflareAccountId}
161
+ ` : "",
162
+ options.cloudflareTunnelId ? `Environment=FZ_CF_TUNNEL_ID=${options.cloudflareTunnelId}
163
+ ` : "",
164
+ options.cloudflareVirtualNetworkId ? `Environment=FZ_CF_VIRTUAL_NETWORK_ID=${options.cloudflareVirtualNetworkId}
165
+ ` : "",
166
+ options.cloudflareWarpPolicyId ? `Environment=FZ_CF_WARP_POLICY_ID=${options.cloudflareWarpPolicyId}
167
+ ` : ""
168
+ ].join("");
154
169
  const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
155
170
  return `[Unit]
156
171
  Description=Bind this machine to its ForgeZero compute
@@ -169,7 +184,7 @@ Environment=FZ_SEED_CREDENTIAL=agent-seed
169
184
  Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
170
185
  Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
171
186
  Environment=FZ_API=${options.apiUrl}
172
- ${label}${gitPublicKey}ExecStart=${bin} enrol
187
+ ${label}${hostname}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
173
188
  # A '+' fixed command runs as root solely to remove the host-bound one-time
174
189
  # ciphertext. Tenant code and the agent never receive a privilege boundary.
175
190
  ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
@@ -228,6 +243,8 @@ WantedBy=multi-user.target
228
243
  `;
229
244
  }
230
245
  function agentUnit(options) {
246
+ if (!validNodeHostname(options.nodeHostname))
247
+ throw new Error("node hostname is invalid");
231
248
  const bin = options.binPath ?? "fz-agent";
232
249
  const user = options.user ?? "forgezero";
233
250
  const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
@@ -247,6 +264,21 @@ function agentUnit(options) {
247
264
  const warpEnabled = warpValues.every(Boolean);
248
265
  if (warpValues.some(Boolean) && !warpEnabled)
249
266
  throw new Error("WARP configuration must be supplied together");
267
+ const networkAttachmentValues = [
268
+ options.cloudflareAccountId,
269
+ options.cloudflareTunnelId,
270
+ options.cloudflareVirtualNetworkId,
271
+ options.cloudflareWarpPolicyId
272
+ ];
273
+ if (networkAttachmentValues.some(Boolean)) {
274
+ if (!options.cloudflareAccountId || !options.cloudflareTunnelId || !options.cloudflareWarpPolicyId) {
275
+ throw new Error("private-network attachment requires account, Tunnel and WARP policy ids");
276
+ }
277
+ 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;
278
+ 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)) {
279
+ throw new Error("private-network attachment coordinates are invalid");
280
+ }
281
+ }
250
282
  const enrolmentEnabled = Boolean(options.enrolTokenCredentialPath && options.enrolStatePath);
251
283
  const deploymentEnvironment = options.deploymentEnvironment ?? {};
252
284
  const deploymentCredentials = options.deploymentCredentials ?? {};
@@ -270,6 +302,7 @@ function agentUnit(options) {
270
302
  options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null,
271
303
  options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
272
304
  options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
305
+ options.nodeHostname ? `FZ_NODE_HOSTNAME=${options.nodeHostname}` : null,
273
306
  options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
274
307
  options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
275
308
  options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
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.23";
2
+ export declare const VERSION = "0.1.25";
package/package.json CHANGED
@@ -1,7 +1,7 @@
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.23",
4
+ "version": "0.1.25",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "check": "tsc --noEmit",