@forgezero/agent 0.1.102 → 0.1.107
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/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap-bundle.d.ts +6 -0
- package/dist/bootstrap-bundle.js +41 -0
- package/dist/bootstrap.d.ts +4 -0
- package/dist/bootstrap.js +126 -9
- package/dist/database-auth-verify.d.ts +6 -0
- package/dist/deployment-connectivity.d.ts +21 -0
- package/dist/deployment-connectivity.js +117 -21
- package/dist/deployment-pull.d.ts +1 -1
- package/dist/deployment-topology.d.ts +2 -0
- package/dist/deployment-topology.js +2 -0
- package/dist/deployment.d.ts +18 -1
- package/dist/fz-agent.js +251 -34
- package/dist/fz.js +313 -97
- package/dist/metal-bootstrap.d.ts +8 -0
- package/dist/metal-bootstrap.js +10 -3
- package/dist/operator-bootstrap.d.ts +10 -0
- package/dist/operator-bootstrap.js +181 -12
- package/dist/platform-bootstrap-runtime.d.ts +21 -1
- package/dist/platform-bootstrap-runtime.js +20 -1
- package/dist/platform-fleet-verification.js +149 -28
- package/dist/platform-genesis-config.d.ts +1 -1
- package/dist/platform-launch-env.d.ts +19 -0
- package/dist/platform-launch-profile.d.ts +5 -0
- package/dist/provision.js +120 -24
- package/dist/software-helper.js +117 -21
- package/dist/version.d.ts +1 -1
- package/package.json +2 -2
package/dist/agent-heartbeat.js
CHANGED
|
@@ -30,3 +30,9 @@ export declare const bootstrapBundleBranch: (value: unknown) => string;
|
|
|
30
30
|
export declare function parseBootstrapBundleManifest(value: unknown): BootstrapBundleManifest;
|
|
31
31
|
export declare function readBootstrapBundle(bundlePath: string, manifestPath?: string): Promise<BootstrapBundleBuildResult>;
|
|
32
32
|
export declare function buildBootstrapBundle(input: BootstrapBundleBuildInput, exec?: BootstrapBundleCommand): Promise<BootstrapBundleBuildResult>;
|
|
33
|
+
/**
|
|
34
|
+
* Make the reviewed branch head the canonical launch bundle without silently
|
|
35
|
+
* reusing an older release. The previous verified pair is retained beside the
|
|
36
|
+
* new pair so an attended operator can inspect or restore it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function refreshBootstrapBundle(input: BootstrapBundleBuildInput, exec?: BootstrapBundleCommand): Promise<BootstrapBundleBuildResult>;
|
package/dist/bootstrap-bundle.js
CHANGED
|
@@ -154,8 +154,49 @@ async function buildBootstrapBundle(input, exec = run) {
|
|
|
154
154
|
throw cause;
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
|
+
async function refreshBootstrapBundle(input, exec = run) {
|
|
158
|
+
const outputPath = resolve(input.outputPath);
|
|
159
|
+
const manifestPath = `${outputPath}.json`;
|
|
160
|
+
if (existsSync(outputPath) !== existsSync(manifestPath)) {
|
|
161
|
+
throw new Error("bootstrap bundle and manifest must either both exist or both be absent");
|
|
162
|
+
}
|
|
163
|
+
const candidatePath = `${outputPath}.candidate.${process.pid}.${randomBytes(6).toString("hex")}`;
|
|
164
|
+
const candidate = await buildBootstrapBundle({ ...input, outputPath: candidatePath }, exec);
|
|
165
|
+
try {
|
|
166
|
+
if (!existsSync(outputPath)) {
|
|
167
|
+
renameSync(candidate.bundlePath, outputPath);
|
|
168
|
+
renameSync(candidate.manifestPath, manifestPath);
|
|
169
|
+
return readBootstrapBundle(outputPath);
|
|
170
|
+
}
|
|
171
|
+
const current = await readBootstrapBundle(outputPath);
|
|
172
|
+
if (current.manifest.branch === candidate.manifest.branch && current.manifest.revision === candidate.manifest.revision) {
|
|
173
|
+
rmSync(candidate.bundlePath, { force: true });
|
|
174
|
+
rmSync(candidate.manifestPath, { force: true });
|
|
175
|
+
return current;
|
|
176
|
+
}
|
|
177
|
+
const archivePath = `${outputPath}.before-${current.manifest.revision.slice(0, 7)}-${Date.now()}`;
|
|
178
|
+
const archiveManifestPath = `${archivePath}.json`;
|
|
179
|
+
renameSync(outputPath, archivePath);
|
|
180
|
+
renameSync(manifestPath, archiveManifestPath);
|
|
181
|
+
try {
|
|
182
|
+
renameSync(candidate.bundlePath, outputPath);
|
|
183
|
+
renameSync(candidate.manifestPath, manifestPath);
|
|
184
|
+
return readBootstrapBundle(outputPath);
|
|
185
|
+
} catch (cause) {
|
|
186
|
+
rmSync(outputPath, { force: true });
|
|
187
|
+
rmSync(manifestPath, { force: true });
|
|
188
|
+
renameSync(archivePath, outputPath);
|
|
189
|
+
renameSync(archiveManifestPath, manifestPath);
|
|
190
|
+
throw cause;
|
|
191
|
+
}
|
|
192
|
+
} finally {
|
|
193
|
+
rmSync(candidate.bundlePath, { force: true });
|
|
194
|
+
rmSync(candidate.manifestPath, { force: true });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
157
197
|
export {
|
|
158
198
|
sha256File,
|
|
199
|
+
refreshBootstrapBundle,
|
|
159
200
|
readBootstrapBundle,
|
|
160
201
|
parseBootstrapBundleManifest,
|
|
161
202
|
buildBootstrapBundle,
|
package/dist/bootstrap.d.ts
CHANGED
|
@@ -115,6 +115,10 @@ export interface PlatformBootstrapSecrets {
|
|
|
115
115
|
backupS3Secret?: string;
|
|
116
116
|
cloudflareTunnelToken?: string;
|
|
117
117
|
cloudflareApiToken?: string;
|
|
118
|
+
githubClientSecret?: string;
|
|
119
|
+
/** One-line base64 form accepted by prompts/env; decoded before systemd sealing. */
|
|
120
|
+
githubPrivateKeyBase64?: string;
|
|
121
|
+
githubWebhookSecret?: string;
|
|
118
122
|
}
|
|
119
123
|
export interface EnrolledComputeBootstrapSecrets {
|
|
120
124
|
enrolmentToken: string;
|
package/dist/bootstrap.js
CHANGED
|
@@ -1384,9 +1384,49 @@ async function buildBootstrapBundle(input, exec = run) {
|
|
|
1384
1384
|
throw cause;
|
|
1385
1385
|
}
|
|
1386
1386
|
}
|
|
1387
|
+
async function refreshBootstrapBundle(input, exec = run) {
|
|
1388
|
+
const outputPath = resolve2(input.outputPath);
|
|
1389
|
+
const manifestPath = `${outputPath}.json`;
|
|
1390
|
+
if (existsSync(outputPath) !== existsSync(manifestPath)) {
|
|
1391
|
+
throw new Error("bootstrap bundle and manifest must either both exist or both be absent");
|
|
1392
|
+
}
|
|
1393
|
+
const candidatePath = `${outputPath}.candidate.${process.pid}.${randomBytes(6).toString("hex")}`;
|
|
1394
|
+
const candidate = await buildBootstrapBundle({ ...input, outputPath: candidatePath }, exec);
|
|
1395
|
+
try {
|
|
1396
|
+
if (!existsSync(outputPath)) {
|
|
1397
|
+
renameSync(candidate.bundlePath, outputPath);
|
|
1398
|
+
renameSync(candidate.manifestPath, manifestPath);
|
|
1399
|
+
return readBootstrapBundle(outputPath);
|
|
1400
|
+
}
|
|
1401
|
+
const current = await readBootstrapBundle(outputPath);
|
|
1402
|
+
if (current.manifest.branch === candidate.manifest.branch && current.manifest.revision === candidate.manifest.revision) {
|
|
1403
|
+
rmSync(candidate.bundlePath, { force: true });
|
|
1404
|
+
rmSync(candidate.manifestPath, { force: true });
|
|
1405
|
+
return current;
|
|
1406
|
+
}
|
|
1407
|
+
const archivePath = `${outputPath}.before-${current.manifest.revision.slice(0, 7)}-${Date.now()}`;
|
|
1408
|
+
const archiveManifestPath = `${archivePath}.json`;
|
|
1409
|
+
renameSync(outputPath, archivePath);
|
|
1410
|
+
renameSync(manifestPath, archiveManifestPath);
|
|
1411
|
+
try {
|
|
1412
|
+
renameSync(candidate.bundlePath, outputPath);
|
|
1413
|
+
renameSync(candidate.manifestPath, manifestPath);
|
|
1414
|
+
return readBootstrapBundle(outputPath);
|
|
1415
|
+
} catch (cause) {
|
|
1416
|
+
rmSync(outputPath, { force: true });
|
|
1417
|
+
rmSync(manifestPath, { force: true });
|
|
1418
|
+
renameSync(archivePath, outputPath);
|
|
1419
|
+
renameSync(archiveManifestPath, manifestPath);
|
|
1420
|
+
throw cause;
|
|
1421
|
+
}
|
|
1422
|
+
} finally {
|
|
1423
|
+
rmSync(candidate.bundlePath, { force: true });
|
|
1424
|
+
rmSync(candidate.manifestPath, { force: true });
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1387
1427
|
|
|
1388
1428
|
// src/bootstrap.ts
|
|
1389
|
-
import { createHash as createHash2, createHmac as createHmac2, randomBytes as randomBytes3 } from "crypto";
|
|
1429
|
+
import { createHash as createHash2, createHmac as createHmac2, createPrivateKey, randomBytes as randomBytes3 } from "crypto";
|
|
1390
1430
|
import {
|
|
1391
1431
|
chmodSync as chmodSync3,
|
|
1392
1432
|
existsSync as existsSync5,
|
|
@@ -1420,7 +1460,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
|
|
|
1420
1460
|
var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
|
|
1421
1461
|
|
|
1422
1462
|
// src/version.ts
|
|
1423
|
-
var VERSION = "0.1.
|
|
1463
|
+
var VERSION = "0.1.107";
|
|
1424
1464
|
|
|
1425
1465
|
// src/software.ts
|
|
1426
1466
|
var PINNED_BUN_VERSION = "1.3.14";
|
|
@@ -3090,10 +3130,16 @@ var httpsOrigin = (name, raw) => {
|
|
|
3090
3130
|
};
|
|
3091
3131
|
var agentTelemetryOrigin = (raw) => raw === "http://127.0.0.1:4318" ? raw : httpsOrigin("agentOtlpEndpoint", raw);
|
|
3092
3132
|
function validatePlatformInitialInventory(value) {
|
|
3093
|
-
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !["metalHostname", "region", "computes", "attestation", "deployment"].includes(key)) || !/^[A-Za-z0-9][A-Za-z0-9.-]{1,252}$/.test(value.metalHostname) || !value.region || typeof value.region !== "object" || Array.isArray(value.region) || Object.keys(value.region).some((key) => !["key", "label", "country", "city", "confidentialCapable"].includes(key)) || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(value.region.key) || !/^[A-Z]{2}$/.test(value.region.country) || value.region.confidentialCapable !== true || !Array.isArray(value.computes)) {
|
|
3133
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !["metalHostname", "metalIdentity", "region", "computes", "attestation", "deployment"].includes(key)) || !/^[A-Za-z0-9][A-Za-z0-9.-]{1,252}$/.test(value.metalHostname) || !value.region || typeof value.region !== "object" || Array.isArray(value.region) || Object.keys(value.region).some((key) => !["key", "label", "country", "city", "confidentialCapable"].includes(key)) || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(value.region.key) || !/^[A-Z]{2}$/.test(value.region.country) || value.region.confidentialCapable !== true || !Array.isArray(value.computes)) {
|
|
3094
3134
|
throw new Error("initial platform inventory coordinates are invalid");
|
|
3095
3135
|
}
|
|
3096
3136
|
safeAtom("initialInventory.region.label", value.region.label);
|
|
3137
|
+
if (value.metalIdentity !== undefined) {
|
|
3138
|
+
const identity = value.metalIdentity;
|
|
3139
|
+
if (!identity || typeof identity !== "object" || Array.isArray(identity) || Object.keys(identity).some((key) => !["nodeKey", "publicKeys"].includes(key)) || !/^[A-Za-z0-9_-]{43}$/.test(identity.nodeKey) || !identity.publicKeys || identity.publicKeys.ed25519 !== identity.nodeKey || !/^[A-Za-z0-9_-]{1,8192}$/.test(identity.publicKeys.mlDsa) || Object.keys(identity.publicKeys).some((key) => !["ed25519", "mlDsa"].includes(key))) {
|
|
3140
|
+
throw new Error("initial platform Metal identity is invalid");
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3097
3143
|
if (value.region.city !== undefined)
|
|
3098
3144
|
safeAtom("initialInventory.region.city", value.region.city);
|
|
3099
3145
|
if (value.deployment !== undefined && (!value.deployment || typeof value.deployment !== "object" || Array.isArray(value.deployment) || Object.keys(value.deployment).some((key) => !["source", "branch", "revision", "bundleSha256"].includes(key)) || value.deployment.source !== "bootstrap-bundle" || !["dev", "main"].includes(value.deployment.branch) || !/^[a-f0-9]{40}$/.test(value.deployment.revision) || !/^[a-f0-9]{64}$/.test(value.deployment.bundleSha256)))
|
|
@@ -3128,6 +3174,7 @@ function validatePlatformInitialInventory(value) {
|
|
|
3128
3174
|
}
|
|
3129
3175
|
return {
|
|
3130
3176
|
metalHostname: value.metalHostname,
|
|
3177
|
+
...value.metalIdentity ? { metalIdentity: structuredClone(value.metalIdentity) } : {},
|
|
3131
3178
|
region: { ...value.region },
|
|
3132
3179
|
computes: value.computes.map((compute, index) => ({
|
|
3133
3180
|
...computes[index],
|
|
@@ -3181,6 +3228,11 @@ function validatePlatformSharedEnvironment(input) {
|
|
|
3181
3228
|
} else if (input.email !== undefined) {
|
|
3182
3229
|
throw new Error("Bootstrap email provider must be smtp or jetemail.");
|
|
3183
3230
|
}
|
|
3231
|
+
if (input.githubApp) {
|
|
3232
|
+
if (!/^(?:Iv1\.[A-Fa-f0-9]{16}|Ov23li[A-Za-z0-9]{14,})$/.test(input.githubApp.clientId) || !/^[1-9][0-9]{0,19}$/.test(input.githubApp.appId) || !/^[a-z0-9][a-z0-9-]{0,99}$/.test(input.githubApp.slug)) {
|
|
3233
|
+
throw new Error("GitHub App client id, app id or slug is malformed.");
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3184
3236
|
boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
|
|
3185
3237
|
if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
|
|
3186
3238
|
throw new Error("seedSyncMembers must contain 3 to 64 unique physical host identities.");
|
|
@@ -3311,6 +3363,9 @@ function renderPlatformSharedEnvironment(input) {
|
|
|
3311
3363
|
FZ_REALTIME_WORKER_SCRIPT: value.realtime?.workerScriptName ?? "",
|
|
3312
3364
|
FZ_REALTIME_ENDPOINT: value.realtime?.endpoint ?? "",
|
|
3313
3365
|
FZ_REALTIME_PRODUCER: value.realtime?.producer ?? "",
|
|
3366
|
+
FZ_GITHUB_CLIENT_ID: value.githubApp?.clientId ?? "",
|
|
3367
|
+
FZ_GITHUB_APP_ID: value.githubApp?.appId ?? "",
|
|
3368
|
+
FZ_GITHUB_APP_SLUG: value.githubApp?.slug ?? "",
|
|
3314
3369
|
FZ_PLATFORM_INITIAL_INVENTORY: value.initialInventory ? JSON.stringify(value.initialInventory) : ""
|
|
3315
3370
|
};
|
|
3316
3371
|
return `# Generated by fz bootstrap platform. Non-secret coordinates only.
|
|
@@ -3322,6 +3377,9 @@ function platformApiCredentialSpecs(options) {
|
|
|
3322
3377
|
const optional = [
|
|
3323
3378
|
["fz_smtp.password", options.emailProvider === "smtp"],
|
|
3324
3379
|
["fz_jetemail.apiKey", options.emailProvider === "jetemail"],
|
|
3380
|
+
["fz_github.clientSecret", options.githubApp],
|
|
3381
|
+
["fz_github.privateKey", options.githubApp],
|
|
3382
|
+
["fz_github.webhookSecret", options.githubApp],
|
|
3325
3383
|
["CF_API_TOKEN", options.cloudflareKv],
|
|
3326
3384
|
["CF_TUNNEL_TOKEN", options.cloudflareKv],
|
|
3327
3385
|
["REALTIME_PUBLISH_SECRET", options.realtime],
|
|
@@ -3329,6 +3387,7 @@ function platformApiCredentialSpecs(options) {
|
|
|
3329
3387
|
];
|
|
3330
3388
|
return [
|
|
3331
3389
|
{ name: "arangodb-jwt", encryptedPath: "/etc/forgezero/creds/arangodb-jwt.cred", required: true },
|
|
3390
|
+
{ name: "arangodb-root-password", encryptedPath: "/etc/forgezero/creds/arangodb-root-password.cred", required: true },
|
|
3332
3391
|
{ name: "seed-sync-root", encryptedPath: "/etc/forgezero/creds/seed-sync-root.cred", required: true },
|
|
3333
3392
|
...optional.filter(([, present]) => present).map(([name]) => ({
|
|
3334
3393
|
name,
|
|
@@ -3717,7 +3776,17 @@ function validatePlatformBootstrapSecrets(config, input) {
|
|
|
3717
3776
|
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
3718
3777
|
throw new Error("bootstrap credential input must be an object");
|
|
3719
3778
|
const source = input;
|
|
3720
|
-
const allowed = [
|
|
3779
|
+
const allowed = [
|
|
3780
|
+
"clusterBootstrapCode",
|
|
3781
|
+
"emailSecret",
|
|
3782
|
+
"enrolmentToken",
|
|
3783
|
+
"backupS3Secret",
|
|
3784
|
+
"cloudflareTunnelToken",
|
|
3785
|
+
"cloudflareApiToken",
|
|
3786
|
+
"githubClientSecret",
|
|
3787
|
+
"githubPrivateKeyBase64",
|
|
3788
|
+
"githubWebhookSecret"
|
|
3789
|
+
];
|
|
3721
3790
|
const unknown = Object.keys(source).filter((key) => !allowed.includes(key));
|
|
3722
3791
|
if (unknown.length)
|
|
3723
3792
|
throw new Error(`bootstrap credential input contains unsupported field ${unknown[0]}`);
|
|
@@ -3727,6 +3796,9 @@ function validatePlatformBootstrapSecrets(config, input) {
|
|
|
3727
3796
|
const backupS3Secret = typeof source.backupS3Secret === "string" ? source.backupS3Secret.trim() : undefined;
|
|
3728
3797
|
const cloudflareTunnelToken = typeof source.cloudflareTunnelToken === "string" ? source.cloudflareTunnelToken.trim() : undefined;
|
|
3729
3798
|
const cloudflareApiToken = typeof source.cloudflareApiToken === "string" ? source.cloudflareApiToken.trim() : undefined;
|
|
3799
|
+
const githubClientSecret = typeof source.githubClientSecret === "string" ? source.githubClientSecret.trim() : undefined;
|
|
3800
|
+
const githubPrivateKeyBase64 = typeof source.githubPrivateKeyBase64 === "string" ? source.githubPrivateKeyBase64.trim() : undefined;
|
|
3801
|
+
const githubWebhookSecret = typeof source.githubWebhookSecret === "string" ? source.githubWebhookSecret.trim() : undefined;
|
|
3730
3802
|
if (!/^[a-f0-9]{64}$/i.test(clusterBootstrapCode))
|
|
3731
3803
|
throw new Error("cluster bootstrap code must contain exactly 64 hexadecimal characters");
|
|
3732
3804
|
if (!emailSecret || emailSecret.length > 16384 || /[\r\n\0]/.test(emailSecret))
|
|
@@ -3746,12 +3818,29 @@ function validatePlatformBootstrapSecrets(config, input) {
|
|
|
3746
3818
|
}
|
|
3747
3819
|
if (cloudflareTunnelToken)
|
|
3748
3820
|
validateCloudflareBootstrapSecretPair({ cloudflareTunnelToken, cloudflareApiToken });
|
|
3821
|
+
const githubConfigured = Boolean(config.runtime.environment.githubApp);
|
|
3822
|
+
if (githubConfigured !== Boolean(githubClientSecret && githubPrivateKeyBase64 && githubWebhookSecret)) {
|
|
3823
|
+
throw new Error("GitHub App coordinates require client secret, private key and webhook secret together");
|
|
3824
|
+
}
|
|
3825
|
+
if (githubConfigured) {
|
|
3826
|
+
if (githubClientSecret.length < 20 || githubClientSecret.length > 512 || /[\r\n\0]/.test(githubClientSecret) || githubWebhookSecret.length < 32 || githubWebhookSecret.length > 512 || /[\r\n\0]/.test(githubWebhookSecret)) {
|
|
3827
|
+
throw new Error("GitHub App client or webhook secret is malformed");
|
|
3828
|
+
}
|
|
3829
|
+
try {
|
|
3830
|
+
const pem = Buffer.from(githubPrivateKeyBase64, "base64").toString("utf8");
|
|
3831
|
+
if (createPrivateKey(pem).asymmetricKeyType !== "rsa")
|
|
3832
|
+
throw new Error("not RSA");
|
|
3833
|
+
} catch {
|
|
3834
|
+
throw new Error("GitHub App private key must be a base64-encoded RSA private key");
|
|
3835
|
+
}
|
|
3836
|
+
}
|
|
3749
3837
|
return {
|
|
3750
3838
|
clusterBootstrapCode,
|
|
3751
3839
|
emailSecret,
|
|
3752
3840
|
...enrolmentToken ? { enrolmentToken } : {},
|
|
3753
3841
|
...backupS3Secret ? { backupS3Secret } : {},
|
|
3754
|
-
...cloudflareTunnelToken ? { cloudflareTunnelToken, cloudflareApiToken } : {}
|
|
3842
|
+
...cloudflareTunnelToken ? { cloudflareTunnelToken, cloudflareApiToken } : {},
|
|
3843
|
+
...githubConfigured ? { githubClientSecret, githubPrivateKeyBase64, githubWebhookSecret } : {}
|
|
3755
3844
|
};
|
|
3756
3845
|
}
|
|
3757
3846
|
var platformBootstrapRunner = (config) => config.kind === "platform" && config.database.role === "master";
|
|
@@ -3766,6 +3855,7 @@ var STATE_PATH = BOOTSTRAP_STATE_PATH;
|
|
|
3766
3855
|
var INTENT_PATH = "/var/lib/forgezero/bootstrap.intent.json";
|
|
3767
3856
|
var CREDS = "/etc/forgezero/creds";
|
|
3768
3857
|
var JWT_CREDENTIAL = `${CREDS}/arangodb-jwt.cred`;
|
|
3858
|
+
var ARANGO_ROOT_CREDENTIAL = `${CREDS}/arangodb-root-password.cred`;
|
|
3769
3859
|
var ENROL_CREDENTIAL = `${CREDS}/enrol-token.cred`;
|
|
3770
3860
|
var TUNNEL_CREDENTIAL = `${CREDS}/CF_TUNNEL_CONNECTOR_TOKEN.cred`;
|
|
3771
3861
|
var CF_API_CREDENTIAL = `${CREDS}/CF_API_TOKEN.cred`;
|
|
@@ -4051,7 +4141,7 @@ WantedBy=multi-user.target
|
|
|
4051
4141
|
function databaseVerifyUnit(config) {
|
|
4052
4142
|
const { address } = config.database;
|
|
4053
4143
|
return `[Unit]
|
|
4054
|
-
Description=
|
|
4144
|
+
Description=Secure and verify ForgeZero ArangoDB Community 3.11.14 writable Coordinator
|
|
4055
4145
|
Requires=forgezero-db.service
|
|
4056
4146
|
After=forgezero-db.service
|
|
4057
4147
|
PartOf=forgezero-db.service
|
|
@@ -4061,7 +4151,9 @@ Type=oneshot
|
|
|
4061
4151
|
User=arangodb
|
|
4062
4152
|
Group=arangodb
|
|
4063
4153
|
LoadCredentialEncrypted=arangodb-jwt:${JWT_CREDENTIAL}
|
|
4064
|
-
|
|
4154
|
+
LoadCredentialEncrypted=arangodb-root-password:${ARANGO_ROOT_CREDENTIAL}
|
|
4155
|
+
ExecStart=/usr/bin/arangosh --server.endpoint tcp://${unitEscape(address)}:8529 --server.jwt-secret-keyfile %d/arangodb-jwt --javascript.execute-string 'const c=require("@arangodb").db._connection;let last;let ok=false;for(let i=0;i<90;i++){try{const v=c.GET("/_api/version?details=true");const s=c.GET("/_admin/status");const m=c.GET("/_admin/server/mode");if(!v.error&&v.version==="3.11.14"&&v.details&&v.details.license==="community"&&!s.error&&s.serverInfo&&s.serverInfo.role==="COORDINATOR"&&!m.error&&m.mode==="default"){ok=true;break;}last={v,s,m};}catch(e){last=String(e);}require("internal").wait(2);}if(!ok)throw new Error("writable Community Coordinator verification failed: "+JSON.stringify(last));const password=require("fs").read("%d/arangodb-root-password").trim();if(password.length<48)throw new Error("ArangoDB root credential is malformed");require("@arangodb/users").update("root",password,true);'
|
|
4156
|
+
ExecStart=/usr/local/bin/fz-agent database-auth-verify --endpoint=http://${unitEscape(address)}:8529
|
|
4065
4157
|
RemainAfterExit=yes
|
|
4066
4158
|
TimeoutStartSec=200
|
|
4067
4159
|
NoNewPrivileges=true
|
|
@@ -4303,6 +4395,12 @@ function approvedPlatformRepairIdentityDigests(config) {
|
|
|
4303
4395
|
delete beforeAttestation.runtime.environment.initialInventory.attestation;
|
|
4304
4396
|
prior.push(beforeAttestation);
|
|
4305
4397
|
}
|
|
4398
|
+
const metalIdentity = config.runtime.environment.initialInventory?.metalIdentity;
|
|
4399
|
+
if (metalIdentity && config.enrolment.source === "genesis-derived") {
|
|
4400
|
+
const beforeMetalIdentity = structuredClone(config);
|
|
4401
|
+
delete beforeMetalIdentity.runtime.environment.initialInventory.metalIdentity;
|
|
4402
|
+
prior.push(beforeMetalIdentity);
|
|
4403
|
+
}
|
|
4306
4404
|
if (config.database.role === "joiner" && config.enrolment.source === "genesis-derived" && config.runtime.environment.custodianEmail) {
|
|
4307
4405
|
const beforeReplicaCustodianEmail = structuredClone(config);
|
|
4308
4406
|
delete beforeReplicaCustodianEmail.runtime.environment.custodianEmail;
|
|
@@ -4522,6 +4620,11 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
4522
4620
|
if (nginx.exitCode !== 0)
|
|
4523
4621
|
problems.push("nginx configuration is invalid");
|
|
4524
4622
|
if (state.databaseRole !== "none") {
|
|
4623
|
+
for (const credential of [JWT_CREDENTIAL, ARANGO_ROOT_CREDENTIAL]) {
|
|
4624
|
+
services[credential] = host.exists(credential);
|
|
4625
|
+
if (!services[credential])
|
|
4626
|
+
problems.push(`${credential} is missing`);
|
|
4627
|
+
}
|
|
4525
4628
|
const unitPath = "/etc/systemd/system/forgezero-db.service";
|
|
4526
4629
|
const expectsNoAgency = state.databaseAgency === "none";
|
|
4527
4630
|
const unitHasNoAgency = host.exists(unitPath) && host.read(unitPath).includes("--cluster.start-agent=false");
|
|
@@ -4660,7 +4763,10 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
|
|
|
4660
4763
|
enrolmentToken: checked4.enrolmentToken,
|
|
4661
4764
|
backup: checked4.backupS3Secret,
|
|
4662
4765
|
cloudflareTunnelToken: checked4.cloudflareTunnelToken,
|
|
4663
|
-
cloudflareApiToken: checked4.cloudflareApiToken
|
|
4766
|
+
cloudflareApiToken: checked4.cloudflareApiToken,
|
|
4767
|
+
githubClientSecret: checked4.githubClientSecret,
|
|
4768
|
+
githubPrivateKey: checked4.githubPrivateKeyBase64 ? Buffer.from(checked4.githubPrivateKeyBase64, "base64").toString("utf8") : undefined,
|
|
4769
|
+
githubWebhookSecret: checked4.githubWebhookSecret
|
|
4664
4770
|
};
|
|
4665
4771
|
})() : undefined;
|
|
4666
4772
|
const enrolledPrivate = config.kind === "enrolled-compute" ? validateEnrolledComputeBootstrapSecrets(config, secrets) : undefined;
|
|
@@ -4753,11 +4859,17 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
|
|
|
4753
4859
|
if (!host.exists(JWT_CREDENTIAL)) {
|
|
4754
4860
|
await seal(host, "arangodb-jwt", JWT_CREDENTIAL, derive(root, "forgezero/cluster/arangodb-jwt/v1"));
|
|
4755
4861
|
}
|
|
4862
|
+
if (!host.exists(ARANGO_ROOT_CREDENTIAL)) {
|
|
4863
|
+
await seal(host, "arangodb-root-password", ARANGO_ROOT_CREDENTIAL, `fzr_${derive(root, "forgezero/cluster/arangodb-root-password/v1")}`);
|
|
4864
|
+
}
|
|
4756
4865
|
await seal(host, "seed-sync-root", SEED_CREDENTIAL, derive(root, "forgezero/cluster/seed-mesh/v1"));
|
|
4757
4866
|
await seal(host, "backup-recovery-root", BACKUP_RECOVERY_CREDENTIAL, derive(root, "forgezero/backup/recovery-root/v1"));
|
|
4758
4867
|
const emailCredentialName = config.runtime.environment.email?.provider === "smtp" ? "fz_smtp.password" : config.runtime.environment.email?.provider === "jetemail" ? "fz_jetemail.apiKey" : undefined;
|
|
4759
4868
|
for (const [name, source] of Object.entries({
|
|
4760
4869
|
...emailCredentialName ? { [emailCredentialName]: platformPrivate.email } : {},
|
|
4870
|
+
"fz_github.clientSecret": platformPrivate.githubClientSecret,
|
|
4871
|
+
"fz_github.privateKey": platformPrivate.githubPrivateKey,
|
|
4872
|
+
"fz_github.webhookSecret": platformPrivate.githubWebhookSecret,
|
|
4761
4873
|
"backup.s3.secretAccessKey": platformPrivate.backup
|
|
4762
4874
|
})) {
|
|
4763
4875
|
if (source) {
|
|
@@ -4774,6 +4886,7 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
|
|
|
4774
4886
|
const credentials = platformApiCredentialSpecs({
|
|
4775
4887
|
emailProvider: runtime.environment.email?.provider,
|
|
4776
4888
|
cloudflareKv: cloudflareConfigured,
|
|
4889
|
+
githubApp: Boolean(runtime.environment.githubApp),
|
|
4777
4890
|
realtime: Boolean(runtime.environment.realtime)
|
|
4778
4891
|
});
|
|
4779
4892
|
const units = renderPlatformApiUnits({
|
|
@@ -5032,7 +5145,11 @@ function strictBootstrapDocument(value) {
|
|
|
5032
5145
|
], "runtime environment");
|
|
5033
5146
|
const environment = runtime.environment;
|
|
5034
5147
|
if (environment.initialInventory !== undefined) {
|
|
5035
|
-
const inventory = exactKeys(environment.initialInventory, ["metalHostname", "region", "computes", "attestation", "deployment"], "initial inventory");
|
|
5148
|
+
const inventory = exactKeys(environment.initialInventory, ["metalHostname", "metalIdentity", "region", "computes", "attestation", "deployment"], "initial inventory");
|
|
5149
|
+
if (inventory.metalIdentity !== undefined) {
|
|
5150
|
+
const identity = exactKeys(inventory.metalIdentity, ["nodeKey", "publicKeys"], "initial Metal identity");
|
|
5151
|
+
exactKeys(identity.publicKeys, ["ed25519", "mlDsa"], "initial Metal public keys");
|
|
5152
|
+
}
|
|
5036
5153
|
exactKeys(inventory.region, ["key", "label", "country", "city", "confidentialCapable"], "initial inventory region");
|
|
5037
5154
|
if (inventory.attestation !== undefined) {
|
|
5038
5155
|
const attestation = exactKeys(inventory.attestation, ["measurement", "tcbFloor"], "initial attestation evidence");
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface DatabaseAuthVerifyOptions {
|
|
2
|
+
readCredential(): string;
|
|
3
|
+
fetcher?: typeof fetch;
|
|
4
|
+
}
|
|
5
|
+
/** Proves root is not empty and the sealed break-glass credential is active. */
|
|
6
|
+
export declare function verifyDatabaseAuthentication(rawEndpoint: string, options: DatabaseAuthVerifyOptions): Promise<void>;
|
|
@@ -42,17 +42,33 @@ export interface DeploymentConnectivityTopology {
|
|
|
42
42
|
localPeerIdentities: readonly string[];
|
|
43
43
|
remoteRelayAddresses: readonly string[];
|
|
44
44
|
remoteRelayIdentities: readonly string[];
|
|
45
|
+
remoteMemberAddresses: readonly string[];
|
|
45
46
|
remoteSiteCidrs: readonly string[];
|
|
46
47
|
memberIdentities: readonly string[];
|
|
47
48
|
healthPort: number;
|
|
48
49
|
routedTcpPorts: readonly number[];
|
|
49
50
|
generation: string;
|
|
50
51
|
}
|
|
52
|
+
export interface DeploymentFirewallRule {
|
|
53
|
+
ruleKey: string;
|
|
54
|
+
action: 'allow' | 'deny';
|
|
55
|
+
protocol: 'tcp' | 'udp';
|
|
56
|
+
sourceAddresses: readonly string[];
|
|
57
|
+
portFrom: number;
|
|
58
|
+
portTo: number;
|
|
59
|
+
priority: number;
|
|
60
|
+
}
|
|
61
|
+
/** API-compiled, exact-address policy. Names and selectors never reach the host. */
|
|
62
|
+
export interface DeploymentFirewallPolicy {
|
|
63
|
+
generation: `sha256:${string}`;
|
|
64
|
+
rules: readonly DeploymentFirewallRule[];
|
|
65
|
+
}
|
|
51
66
|
export interface DeploymentConnectivityRequest {
|
|
52
67
|
key: string;
|
|
53
68
|
intent: DeploymentConnectivityIntent;
|
|
54
69
|
capabilities: DeploymentConnectivityCapabilities;
|
|
55
70
|
topology?: DeploymentConnectivityTopology;
|
|
71
|
+
firewallPolicy?: DeploymentFirewallPolicy;
|
|
56
72
|
}
|
|
57
73
|
export interface DeploymentConnectivityEvidence {
|
|
58
74
|
key: string;
|
|
@@ -74,6 +90,11 @@ export interface DeploymentConnectivityEvidence {
|
|
|
74
90
|
role: DeploymentConnectivityTopology['role'];
|
|
75
91
|
probed: string[];
|
|
76
92
|
};
|
|
93
|
+
firewall?: {
|
|
94
|
+
generation: string;
|
|
95
|
+
rules: number;
|
|
96
|
+
active: true;
|
|
97
|
+
};
|
|
77
98
|
}
|
|
78
99
|
export interface DeploymentConnectivityHost {
|
|
79
100
|
seal(name: string, path: string, value: string): Promise<void>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/deployment-connectivity.ts
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { createConnection } from "node:net";
|
|
4
|
+
import { createConnection, isIP } from "node:net";
|
|
5
5
|
import { dirname } from "node:path";
|
|
6
6
|
|
|
7
7
|
// src/process-input.ts
|
|
@@ -88,21 +88,99 @@ function validate(request) {
|
|
|
88
88
|
}
|
|
89
89
|
} else if (request.capabilities.private)
|
|
90
90
|
throw new Error("unexpected private deployment capability");
|
|
91
|
+
if (request.firewallPolicy) {
|
|
92
|
+
const policy = request.firewallPolicy;
|
|
93
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(policy.generation) || !Array.isArray(policy.rules) || policy.rules.length > 256) {
|
|
94
|
+
throw new Error("deployment firewall policy is malformed");
|
|
95
|
+
}
|
|
96
|
+
let expanded = 0;
|
|
97
|
+
for (const rule of policy.rules) {
|
|
98
|
+
expanded += rule.sourceAddresses.length;
|
|
99
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(rule.ruleKey) || !["allow", "deny"].includes(rule.action) || !["tcp", "udp"].includes(rule.protocol) || rule.sourceAddresses.length < 1 || rule.sourceAddresses.length > 1024 || new Set(rule.sourceAddresses).size !== rule.sourceAddresses.length || rule.sourceAddresses.some((address) => isIP(address) !== 4) || !Number.isSafeInteger(rule.portFrom) || rule.portFrom < 1 || rule.portFrom > 65535 || !Number.isSafeInteger(rule.portTo) || rule.portTo < rule.portFrom || rule.portTo > 65535 || !Number.isSafeInteger(rule.priority) || rule.priority < 0 || rule.priority > 1e6) {
|
|
100
|
+
throw new Error("deployment firewall policy is malformed");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (expanded > 2048)
|
|
104
|
+
throw new Error("deployment firewall policy expands beyond its rule limit");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function replaceTaggedUfwRules(host, comment) {
|
|
108
|
+
const status = await checked(host, ["/usr/sbin/ufw", "status", "numbered"], "firewall inventory");
|
|
109
|
+
const numbers = status.split(`
|
|
110
|
+
`).flatMap((line) => {
|
|
111
|
+
if (!line.includes(comment))
|
|
112
|
+
return [];
|
|
113
|
+
const match = line.match(/^\s*\[\s*(\d{1,6})\]/);
|
|
114
|
+
return match ? [Number(match[1])] : [];
|
|
115
|
+
}).filter((value) => Number.isSafeInteger(value) && value > 0).sort((left, right) => right - left);
|
|
116
|
+
for (const number of numbers)
|
|
117
|
+
await checked(host, ["/usr/sbin/ufw", "--force", "delete", String(number)], `stale firewall rule ${number}`);
|
|
91
118
|
}
|
|
92
119
|
async function applyDeploymentConnectivity(request, host = defaultHost) {
|
|
93
120
|
validate(request);
|
|
94
121
|
const id = idFor(request.key);
|
|
95
122
|
const evidence = { key: request.key };
|
|
123
|
+
if (request.firewallPolicy) {
|
|
124
|
+
const policy = request.firewallPolicy;
|
|
125
|
+
const comment = `fz-policy-${id}`;
|
|
126
|
+
await replaceTaggedUfwRules(host, comment);
|
|
127
|
+
const ordered = [...policy.rules].sort((left, right) => left.priority - right.priority || (left.action === right.action ? left.ruleKey.localeCompare(right.ruleKey) : left.action === "deny" ? -1 : 1));
|
|
128
|
+
const commands = [];
|
|
129
|
+
for (const rule of ordered)
|
|
130
|
+
for (const source of rule.sourceAddresses)
|
|
131
|
+
commands.push([
|
|
132
|
+
"/usr/sbin/ufw",
|
|
133
|
+
"insert",
|
|
134
|
+
"1",
|
|
135
|
+
rule.action,
|
|
136
|
+
"from",
|
|
137
|
+
source,
|
|
138
|
+
"to",
|
|
139
|
+
"any",
|
|
140
|
+
"port",
|
|
141
|
+
rule.portFrom === rule.portTo ? String(rule.portFrom) : `${rule.portFrom}:${rule.portTo}`,
|
|
142
|
+
"proto",
|
|
143
|
+
rule.protocol,
|
|
144
|
+
"comment",
|
|
145
|
+
comment
|
|
146
|
+
]);
|
|
147
|
+
const ranges = new Map;
|
|
148
|
+
for (const rule of ordered)
|
|
149
|
+
ranges.set(`${rule.protocol}:${rule.portFrom}:${rule.portTo}`, rule);
|
|
150
|
+
for (const range of ranges.values())
|
|
151
|
+
commands.push([
|
|
152
|
+
"/usr/sbin/ufw",
|
|
153
|
+
"insert",
|
|
154
|
+
"1",
|
|
155
|
+
"deny",
|
|
156
|
+
"to",
|
|
157
|
+
"any",
|
|
158
|
+
"port",
|
|
159
|
+
range.portFrom === range.portTo ? String(range.portFrom) : `${range.portFrom}:${range.portTo}`,
|
|
160
|
+
"proto",
|
|
161
|
+
range.protocol,
|
|
162
|
+
"comment",
|
|
163
|
+
comment
|
|
164
|
+
]);
|
|
165
|
+
for (const command of commands.toReversed())
|
|
166
|
+
await checked(host, command, "deployment firewall rule");
|
|
167
|
+
evidence.firewall = { generation: policy.generation, rules: commands.length, active: true };
|
|
168
|
+
}
|
|
96
169
|
const topology = request.topology;
|
|
97
170
|
if (topology) {
|
|
98
|
-
const values = [
|
|
171
|
+
const values = [
|
|
172
|
+
topology.localRelayAddress,
|
|
173
|
+
...topology.localPeerAddresses,
|
|
174
|
+
...topology.remoteRelayAddresses,
|
|
175
|
+
...topology.remoteMemberAddresses
|
|
176
|
+
];
|
|
99
177
|
const identities = [
|
|
100
178
|
topology.nodeIdentity,
|
|
101
179
|
...topology.localPeerIdentities,
|
|
102
180
|
...topology.remoteRelayIdentities,
|
|
103
181
|
...topology.memberIdentities
|
|
104
182
|
];
|
|
105
|
-
if (!/^[A-Za-z0-9.-]{1,253}$/.test(topology.site) || !/^(?:\d{1,3}\.){3}0\/24$/.test(topology.siteCidr) || values.some((value) =>
|
|
183
|
+
if (!/^[A-Za-z0-9.-]{1,253}$/.test(topology.site) || !/^(?:\d{1,3}\.){3}0\/24$/.test(topology.siteCidr) || values.some((value) => isIP(value) !== 4) || identities.some((value) => !/^[A-Za-z0-9.-]{1,253}$/.test(value)) || topology.localPeerIdentities.length !== topology.localPeerAddresses.length || topology.remoteRelayIdentities.length !== topology.remoteRelayAddresses.length || topology.remoteMemberAddresses.length > 1024 || new Set(topology.remoteMemberAddresses).size !== topology.remoteMemberAddresses.length || topology.memberIdentities.length < 1 || topology.memberIdentities.length > 1024 || new Set(topology.memberIdentities).size !== topology.memberIdentities.length || !topology.memberIdentities.includes(topology.nodeIdentity) || !/^sha256:[a-f0-9]{64}$/.test(topology.generation) || (topology.role === "member" ? topology.remoteRelayAddresses.length !== 0 : topology.remoteSiteCidrs.length !== topology.remoteRelayAddresses.length) || topology.remoteSiteCidrs.some((value) => !/^(?:\d{1,3}\.){3}0\/24$/.test(value)) || new Set(topology.remoteSiteCidrs).size !== topology.remoteSiteCidrs.length || !Number.isSafeInteger(topology.healthPort) || topology.healthPort < 1 || topology.healthPort > 65535 || topology.routedTcpPorts.length < 1 || topology.routedTcpPorts.length > 64 || new Set(topology.routedTcpPorts).size !== topology.routedTcpPorts.length || !topology.routedTcpPorts.includes(topology.healthPort) || topology.routedTcpPorts.some((port) => !Number.isSafeInteger(port) || port < 1 || port > 65535) || topology.role === "member" && topology.transport !== "private-lan") {
|
|
106
184
|
throw new Error("deployment topology is malformed");
|
|
107
185
|
}
|
|
108
186
|
if (request.intent.private?.mode === "cloudflare-warp" !== (topology.transport === "cloudflare-warp")) {
|
|
@@ -229,28 +307,46 @@ ${forwarding}${noOp}${starts}${starts ? `
|
|
|
229
307
|
[Install]
|
|
230
308
|
WantedBy=multi-user.target
|
|
231
309
|
`, 420);
|
|
232
|
-
|
|
310
|
+
const firewallComment = `fz-topology-${id}`;
|
|
311
|
+
await replaceTaggedUfwRules(host, firewallComment);
|
|
312
|
+
for (const source of [...new Set([...topology.localPeerAddresses, ...topology.remoteMemberAddresses])]) {
|
|
233
313
|
for (const port of topology.routedTcpPorts) {
|
|
234
|
-
await checked(host, [
|
|
314
|
+
await checked(host, [
|
|
315
|
+
"/usr/sbin/ufw",
|
|
316
|
+
"allow",
|
|
317
|
+
"from",
|
|
318
|
+
source,
|
|
319
|
+
"to",
|
|
320
|
+
"any",
|
|
321
|
+
"port",
|
|
322
|
+
String(port),
|
|
323
|
+
"proto",
|
|
324
|
+
"tcp",
|
|
325
|
+
"comment",
|
|
326
|
+
firewallComment
|
|
327
|
+
], `private service ${source}:${port}`);
|
|
235
328
|
}
|
|
236
329
|
}
|
|
237
330
|
if (topology.role !== "member")
|
|
238
|
-
for (const
|
|
239
|
-
for (const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
331
|
+
for (const remoteAddress of topology.remoteMemberAddresses) {
|
|
332
|
+
for (const source of topology.localPeerAddresses)
|
|
333
|
+
for (const port of topology.routedTcpPorts) {
|
|
334
|
+
await checked(host, [
|
|
335
|
+
"/usr/sbin/ufw",
|
|
336
|
+
"route",
|
|
337
|
+
"allow",
|
|
338
|
+
"proto",
|
|
339
|
+
"tcp",
|
|
340
|
+
"from",
|
|
341
|
+
source,
|
|
342
|
+
"to",
|
|
343
|
+
remoteAddress,
|
|
344
|
+
"port",
|
|
345
|
+
String(port),
|
|
346
|
+
"comment",
|
|
347
|
+
firewallComment
|
|
348
|
+
], `private routed service ${source}->${remoteAddress}:${port}`);
|
|
349
|
+
}
|
|
254
350
|
}
|
|
255
351
|
await checked(host, ["/usr/bin/systemctl", "daemon-reload"], "topology daemon reload");
|
|
256
352
|
await checked(host, ["/usr/bin/systemctl", "enable", "--now", routeUnit], "topology routes");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { NodeKeyPair } from '@forgezero/runtime/identity';
|
|
2
|
-
import type
|
|
2
|
+
import { type DeploymentManager, type DeploymentResult, type GitSourceAuth } from './deployment';
|
|
3
3
|
import type { AgentOperationTelemetry } from './telemetry-runtime';
|
|
4
4
|
export interface RemoteDeploymentClaim {
|
|
5
5
|
runKey: string;
|
|
@@ -21,6 +21,8 @@ export interface DeploymentTopologyAssignment extends DeploymentTopologyPlacemen
|
|
|
21
21
|
localPeerIdentities: readonly string[];
|
|
22
22
|
remoteRelayAddresses: readonly string[];
|
|
23
23
|
remoteRelayIdentities: readonly string[];
|
|
24
|
+
/** Exact deployment members at other Metal sites; used for host firewall authority. */
|
|
25
|
+
remoteMemberAddresses: readonly string[];
|
|
24
26
|
remoteSiteCidrs: readonly string[];
|
|
25
27
|
memberIdentities: readonly string[];
|
|
26
28
|
healthPort: number;
|
|
@@ -80,6 +80,7 @@ function planDeploymentTopology(args) {
|
|
|
80
80
|
const relay = active.get(site);
|
|
81
81
|
const remoteRelayAddresses = siteNames.filter((other) => other !== site).map((other) => active.get(other).privateAddress);
|
|
82
82
|
const remoteRelayIdentities = siteNames.filter((other) => other !== site).map((other) => active.get(other).nodeIdentity);
|
|
83
|
+
const remoteMemberAddresses = siteNames.filter((other) => other !== site).flatMap((other) => sites.get(other).map(({ privateAddress }) => privateAddress));
|
|
83
84
|
const remoteSiteCidrs = siteNames.filter((other) => other !== site).map((other) => siteCidrs.get(other));
|
|
84
85
|
return members.map((placement, index) => {
|
|
85
86
|
const role = index === 0 ? "relay" : index === 1 && topology.relays.standbyPerMetal === 1 ? "standby" : "member";
|
|
@@ -97,6 +98,7 @@ function planDeploymentTopology(args) {
|
|
|
97
98
|
localPeerIdentities,
|
|
98
99
|
remoteRelayAddresses: crossSite ? remoteRelayAddresses : [],
|
|
99
100
|
remoteRelayIdentities: crossSite ? remoteRelayIdentities : [],
|
|
101
|
+
remoteMemberAddresses: siteNames.length > 1 ? remoteMemberAddresses : [],
|
|
100
102
|
remoteSiteCidrs: siteNames.length > 1 ? remoteSiteCidrs : [],
|
|
101
103
|
healthPort: topology.relays.healthPort,
|
|
102
104
|
routedTcpPorts: [...topology.relays.routedTcpPorts],
|