@forgezero/agent 0.1.103 → 0.1.108
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 +1 -4
- package/dist/bootstrap.js +75 -44
- 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 +148 -175
- package/dist/metal-bootstrap.js +1 -1
- package/dist/operator-bootstrap.js +75 -44
- package/dist/platform-bootstrap-runtime.d.ts +4 -6
- package/dist/platform-bootstrap-runtime.js +5 -10
- package/dist/platform-fleet-verification.js +134 -37
- package/dist/platform-launch-env.d.ts +17 -0
- package/dist/platform-launch-profile.d.ts +2 -3
- 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,10 +115,7 @@ export interface PlatformBootstrapSecrets {
|
|
|
115
115
|
backupS3Secret?: string;
|
|
116
116
|
cloudflareTunnelToken?: string;
|
|
117
117
|
cloudflareApiToken?: string;
|
|
118
|
-
|
|
119
|
-
/** One-line base64 form accepted by prompts/env; decoded before systemd sealing. */
|
|
120
|
-
githubPrivateKeyBase64?: string;
|
|
121
|
-
githubWebhookSecret?: string;
|
|
118
|
+
githubOAuthClientSecret: string;
|
|
122
119
|
}
|
|
123
120
|
export interface EnrolledComputeBootstrapSecrets {
|
|
124
121
|
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,
|
|
1429
|
+
import { createHash as createHash2, createHmac as createHmac2, 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.108";
|
|
1424
1464
|
|
|
1425
1465
|
// src/software.ts
|
|
1426
1466
|
var PINNED_BUN_VERSION = "1.3.14";
|
|
@@ -3188,10 +3228,8 @@ function validatePlatformSharedEnvironment(input) {
|
|
|
3188
3228
|
} else if (input.email !== undefined) {
|
|
3189
3229
|
throw new Error("Bootstrap email provider must be smtp or jetemail.");
|
|
3190
3230
|
}
|
|
3191
|
-
if (input.
|
|
3192
|
-
|
|
3193
|
-
throw new Error("GitHub App client id, app id or slug is malformed.");
|
|
3194
|
-
}
|
|
3231
|
+
if (!input.githubOAuth || !/^(?:Iv1\.[A-Fa-f0-9]{16}|Ov23li[A-Za-z0-9]{14,})$/.test(input.githubOAuth.clientId)) {
|
|
3232
|
+
throw new Error("GitHub OAuth client id is malformed.");
|
|
3195
3233
|
}
|
|
3196
3234
|
boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
|
|
3197
3235
|
if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
|
|
@@ -3323,9 +3361,7 @@ function renderPlatformSharedEnvironment(input) {
|
|
|
3323
3361
|
FZ_REALTIME_WORKER_SCRIPT: value.realtime?.workerScriptName ?? "",
|
|
3324
3362
|
FZ_REALTIME_ENDPOINT: value.realtime?.endpoint ?? "",
|
|
3325
3363
|
FZ_REALTIME_PRODUCER: value.realtime?.producer ?? "",
|
|
3326
|
-
|
|
3327
|
-
FZ_GITHUB_APP_ID: value.githubApp?.appId ?? "",
|
|
3328
|
-
FZ_GITHUB_APP_SLUG: value.githubApp?.slug ?? "",
|
|
3364
|
+
FZ_GITHUB_OAUTH_CLIENT_ID: value.githubOAuth.clientId,
|
|
3329
3365
|
FZ_PLATFORM_INITIAL_INVENTORY: value.initialInventory ? JSON.stringify(value.initialInventory) : ""
|
|
3330
3366
|
};
|
|
3331
3367
|
return `# Generated by fz bootstrap platform. Non-secret coordinates only.
|
|
@@ -3337,9 +3373,7 @@ function platformApiCredentialSpecs(options) {
|
|
|
3337
3373
|
const optional = [
|
|
3338
3374
|
["fz_smtp.password", options.emailProvider === "smtp"],
|
|
3339
3375
|
["fz_jetemail.apiKey", options.emailProvider === "jetemail"],
|
|
3340
|
-
["
|
|
3341
|
-
["fz_github.privateKey", options.githubApp],
|
|
3342
|
-
["fz_github.webhookSecret", options.githubApp],
|
|
3376
|
+
["fz_oauth.github.clientSecret", options.githubOAuth],
|
|
3343
3377
|
["CF_API_TOKEN", options.cloudflareKv],
|
|
3344
3378
|
["CF_TUNNEL_TOKEN", options.cloudflareKv],
|
|
3345
3379
|
["REALTIME_PUBLISH_SECRET", options.realtime],
|
|
@@ -3347,6 +3381,7 @@ function platformApiCredentialSpecs(options) {
|
|
|
3347
3381
|
];
|
|
3348
3382
|
return [
|
|
3349
3383
|
{ name: "arangodb-jwt", encryptedPath: "/etc/forgezero/creds/arangodb-jwt.cred", required: true },
|
|
3384
|
+
{ name: "arangodb-root-password", encryptedPath: "/etc/forgezero/creds/arangodb-root-password.cred", required: true },
|
|
3350
3385
|
{ name: "seed-sync-root", encryptedPath: "/etc/forgezero/creds/seed-sync-root.cred", required: true },
|
|
3351
3386
|
...optional.filter(([, present]) => present).map(([name]) => ({
|
|
3352
3387
|
name,
|
|
@@ -3742,9 +3777,7 @@ function validatePlatformBootstrapSecrets(config, input) {
|
|
|
3742
3777
|
"backupS3Secret",
|
|
3743
3778
|
"cloudflareTunnelToken",
|
|
3744
3779
|
"cloudflareApiToken",
|
|
3745
|
-
"
|
|
3746
|
-
"githubPrivateKeyBase64",
|
|
3747
|
-
"githubWebhookSecret"
|
|
3780
|
+
"githubOAuthClientSecret"
|
|
3748
3781
|
];
|
|
3749
3782
|
const unknown = Object.keys(source).filter((key) => !allowed.includes(key));
|
|
3750
3783
|
if (unknown.length)
|
|
@@ -3755,9 +3788,7 @@ function validatePlatformBootstrapSecrets(config, input) {
|
|
|
3755
3788
|
const backupS3Secret = typeof source.backupS3Secret === "string" ? source.backupS3Secret.trim() : undefined;
|
|
3756
3789
|
const cloudflareTunnelToken = typeof source.cloudflareTunnelToken === "string" ? source.cloudflareTunnelToken.trim() : undefined;
|
|
3757
3790
|
const cloudflareApiToken = typeof source.cloudflareApiToken === "string" ? source.cloudflareApiToken.trim() : undefined;
|
|
3758
|
-
const
|
|
3759
|
-
const githubPrivateKeyBase64 = typeof source.githubPrivateKeyBase64 === "string" ? source.githubPrivateKeyBase64.trim() : undefined;
|
|
3760
|
-
const githubWebhookSecret = typeof source.githubWebhookSecret === "string" ? source.githubWebhookSecret.trim() : undefined;
|
|
3791
|
+
const githubOAuthClientSecret = typeof source.githubOAuthClientSecret === "string" ? source.githubOAuthClientSecret.trim() : "";
|
|
3761
3792
|
if (!/^[a-f0-9]{64}$/i.test(clusterBootstrapCode))
|
|
3762
3793
|
throw new Error("cluster bootstrap code must contain exactly 64 hexadecimal characters");
|
|
3763
3794
|
if (!emailSecret || emailSecret.length > 16384 || /[\r\n\0]/.test(emailSecret))
|
|
@@ -3777,21 +3808,8 @@ function validatePlatformBootstrapSecrets(config, input) {
|
|
|
3777
3808
|
}
|
|
3778
3809
|
if (cloudflareTunnelToken)
|
|
3779
3810
|
validateCloudflareBootstrapSecretPair({ cloudflareTunnelToken, cloudflareApiToken });
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
throw new Error("GitHub App coordinates require client secret, private key and webhook secret together");
|
|
3783
|
-
}
|
|
3784
|
-
if (githubConfigured) {
|
|
3785
|
-
if (githubClientSecret.length < 20 || githubClientSecret.length > 512 || /[\r\n\0]/.test(githubClientSecret) || githubWebhookSecret.length < 32 || githubWebhookSecret.length > 512 || /[\r\n\0]/.test(githubWebhookSecret)) {
|
|
3786
|
-
throw new Error("GitHub App client or webhook secret is malformed");
|
|
3787
|
-
}
|
|
3788
|
-
try {
|
|
3789
|
-
const pem = Buffer.from(githubPrivateKeyBase64, "base64").toString("utf8");
|
|
3790
|
-
if (createPrivateKey(pem).asymmetricKeyType !== "rsa")
|
|
3791
|
-
throw new Error("not RSA");
|
|
3792
|
-
} catch {
|
|
3793
|
-
throw new Error("GitHub App private key must be a base64-encoded RSA private key");
|
|
3794
|
-
}
|
|
3811
|
+
if (githubOAuthClientSecret.length < 20 || githubOAuthClientSecret.length > 512 || /[\r\n\0]/.test(githubOAuthClientSecret)) {
|
|
3812
|
+
throw new Error("GitHub OAuth client secret is malformed");
|
|
3795
3813
|
}
|
|
3796
3814
|
return {
|
|
3797
3815
|
clusterBootstrapCode,
|
|
@@ -3799,7 +3817,7 @@ function validatePlatformBootstrapSecrets(config, input) {
|
|
|
3799
3817
|
...enrolmentToken ? { enrolmentToken } : {},
|
|
3800
3818
|
...backupS3Secret ? { backupS3Secret } : {},
|
|
3801
3819
|
...cloudflareTunnelToken ? { cloudflareTunnelToken, cloudflareApiToken } : {},
|
|
3802
|
-
|
|
3820
|
+
githubOAuthClientSecret
|
|
3803
3821
|
};
|
|
3804
3822
|
}
|
|
3805
3823
|
var platformBootstrapRunner = (config) => config.kind === "platform" && config.database.role === "master";
|
|
@@ -3814,6 +3832,7 @@ var STATE_PATH = BOOTSTRAP_STATE_PATH;
|
|
|
3814
3832
|
var INTENT_PATH = "/var/lib/forgezero/bootstrap.intent.json";
|
|
3815
3833
|
var CREDS = "/etc/forgezero/creds";
|
|
3816
3834
|
var JWT_CREDENTIAL = `${CREDS}/arangodb-jwt.cred`;
|
|
3835
|
+
var ARANGO_ROOT_CREDENTIAL = `${CREDS}/arangodb-root-password.cred`;
|
|
3817
3836
|
var ENROL_CREDENTIAL = `${CREDS}/enrol-token.cred`;
|
|
3818
3837
|
var TUNNEL_CREDENTIAL = `${CREDS}/CF_TUNNEL_CONNECTOR_TOKEN.cred`;
|
|
3819
3838
|
var CF_API_CREDENTIAL = `${CREDS}/CF_API_TOKEN.cred`;
|
|
@@ -4099,7 +4118,7 @@ WantedBy=multi-user.target
|
|
|
4099
4118
|
function databaseVerifyUnit(config) {
|
|
4100
4119
|
const { address } = config.database;
|
|
4101
4120
|
return `[Unit]
|
|
4102
|
-
Description=
|
|
4121
|
+
Description=Secure and verify ForgeZero ArangoDB Community 3.11.14 writable Coordinator
|
|
4103
4122
|
Requires=forgezero-db.service
|
|
4104
4123
|
After=forgezero-db.service
|
|
4105
4124
|
PartOf=forgezero-db.service
|
|
@@ -4109,7 +4128,9 @@ Type=oneshot
|
|
|
4109
4128
|
User=arangodb
|
|
4110
4129
|
Group=arangodb
|
|
4111
4130
|
LoadCredentialEncrypted=arangodb-jwt:${JWT_CREDENTIAL}
|
|
4112
|
-
|
|
4131
|
+
LoadCredentialEncrypted=arangodb-root-password:${ARANGO_ROOT_CREDENTIAL}
|
|
4132
|
+
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);'
|
|
4133
|
+
ExecStart=/usr/local/bin/fz-agent database-auth-verify --endpoint=http://${unitEscape(address)}:8529
|
|
4113
4134
|
RemainAfterExit=yes
|
|
4114
4135
|
TimeoutStartSec=200
|
|
4115
4136
|
NoNewPrivileges=true
|
|
@@ -4576,6 +4597,11 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
4576
4597
|
if (nginx.exitCode !== 0)
|
|
4577
4598
|
problems.push("nginx configuration is invalid");
|
|
4578
4599
|
if (state.databaseRole !== "none") {
|
|
4600
|
+
for (const credential of [JWT_CREDENTIAL, ARANGO_ROOT_CREDENTIAL]) {
|
|
4601
|
+
services[credential] = host.exists(credential);
|
|
4602
|
+
if (!services[credential])
|
|
4603
|
+
problems.push(`${credential} is missing`);
|
|
4604
|
+
}
|
|
4579
4605
|
const unitPath = "/etc/systemd/system/forgezero-db.service";
|
|
4580
4606
|
const expectsNoAgency = state.databaseAgency === "none";
|
|
4581
4607
|
const unitHasNoAgency = host.exists(unitPath) && host.read(unitPath).includes("--cluster.start-agent=false");
|
|
@@ -4715,9 +4741,7 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
|
|
|
4715
4741
|
backup: checked4.backupS3Secret,
|
|
4716
4742
|
cloudflareTunnelToken: checked4.cloudflareTunnelToken,
|
|
4717
4743
|
cloudflareApiToken: checked4.cloudflareApiToken,
|
|
4718
|
-
|
|
4719
|
-
githubPrivateKey: checked4.githubPrivateKeyBase64 ? Buffer.from(checked4.githubPrivateKeyBase64, "base64").toString("utf8") : undefined,
|
|
4720
|
-
githubWebhookSecret: checked4.githubWebhookSecret
|
|
4744
|
+
githubOAuthClientSecret: checked4.githubOAuthClientSecret
|
|
4721
4745
|
};
|
|
4722
4746
|
})() : undefined;
|
|
4723
4747
|
const enrolledPrivate = config.kind === "enrolled-compute" ? validateEnrolledComputeBootstrapSecrets(config, secrets) : undefined;
|
|
@@ -4810,14 +4834,15 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
|
|
|
4810
4834
|
if (!host.exists(JWT_CREDENTIAL)) {
|
|
4811
4835
|
await seal(host, "arangodb-jwt", JWT_CREDENTIAL, derive(root, "forgezero/cluster/arangodb-jwt/v1"));
|
|
4812
4836
|
}
|
|
4837
|
+
if (!host.exists(ARANGO_ROOT_CREDENTIAL)) {
|
|
4838
|
+
await seal(host, "arangodb-root-password", ARANGO_ROOT_CREDENTIAL, `fzr_${derive(root, "forgezero/cluster/arangodb-root-password/v1")}`);
|
|
4839
|
+
}
|
|
4813
4840
|
await seal(host, "seed-sync-root", SEED_CREDENTIAL, derive(root, "forgezero/cluster/seed-mesh/v1"));
|
|
4814
4841
|
await seal(host, "backup-recovery-root", BACKUP_RECOVERY_CREDENTIAL, derive(root, "forgezero/backup/recovery-root/v1"));
|
|
4815
4842
|
const emailCredentialName = config.runtime.environment.email?.provider === "smtp" ? "fz_smtp.password" : config.runtime.environment.email?.provider === "jetemail" ? "fz_jetemail.apiKey" : undefined;
|
|
4816
4843
|
for (const [name, source] of Object.entries({
|
|
4817
4844
|
...emailCredentialName ? { [emailCredentialName]: platformPrivate.email } : {},
|
|
4818
|
-
"
|
|
4819
|
-
"fz_github.privateKey": platformPrivate.githubPrivateKey,
|
|
4820
|
-
"fz_github.webhookSecret": platformPrivate.githubWebhookSecret,
|
|
4845
|
+
"fz_oauth.github.clientSecret": platformPrivate.githubOAuthClientSecret,
|
|
4821
4846
|
"backup.s3.secretAccessKey": platformPrivate.backup
|
|
4822
4847
|
})) {
|
|
4823
4848
|
if (source) {
|
|
@@ -4834,7 +4859,7 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
|
|
|
4834
4859
|
const credentials = platformApiCredentialSpecs({
|
|
4835
4860
|
emailProvider: runtime.environment.email?.provider,
|
|
4836
4861
|
cloudflareKv: cloudflareConfigured,
|
|
4837
|
-
|
|
4862
|
+
githubOAuth: true,
|
|
4838
4863
|
realtime: Boolean(runtime.environment.realtime)
|
|
4839
4864
|
});
|
|
4840
4865
|
const units = renderPlatformApiUnits({
|
|
@@ -5082,6 +5107,7 @@ function strictBootstrapDocument(value) {
|
|
|
5082
5107
|
"agentOtlpEndpoint",
|
|
5083
5108
|
"custodianEmail",
|
|
5084
5109
|
"email",
|
|
5110
|
+
"githubOAuth",
|
|
5085
5111
|
"deployProfile",
|
|
5086
5112
|
"otlpFlushIntervalMs",
|
|
5087
5113
|
"otlpTraceSampleRatio",
|
|
@@ -5092,8 +5118,13 @@ function strictBootstrapDocument(value) {
|
|
|
5092
5118
|
"databaseReadPreferredCoordinators"
|
|
5093
5119
|
], "runtime environment");
|
|
5094
5120
|
const environment = runtime.environment;
|
|
5121
|
+
exactKeys(environment.githubOAuth, ["clientId"], "GitHub OAuth config");
|
|
5095
5122
|
if (environment.initialInventory !== undefined) {
|
|
5096
|
-
const inventory = exactKeys(environment.initialInventory, ["metalHostname", "region", "computes", "attestation", "deployment"], "initial inventory");
|
|
5123
|
+
const inventory = exactKeys(environment.initialInventory, ["metalHostname", "metalIdentity", "region", "computes", "attestation", "deployment"], "initial inventory");
|
|
5124
|
+
if (inventory.metalIdentity !== undefined) {
|
|
5125
|
+
const identity = exactKeys(inventory.metalIdentity, ["nodeKey", "publicKeys"], "initial Metal identity");
|
|
5126
|
+
exactKeys(identity.publicKeys, ["ed25519", "mlDsa"], "initial Metal public keys");
|
|
5127
|
+
}
|
|
5097
5128
|
exactKeys(inventory.region, ["key", "label", "country", "city", "confidentialCapable"], "initial inventory region");
|
|
5098
5129
|
if (inventory.attestation !== undefined) {
|
|
5099
5130
|
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],
|