@forgezero/agent 0.1.103 → 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.js +60 -4
- 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 +164 -97
- package/dist/metal-bootstrap.js +1 -1
- package/dist/operator-bootstrap.js +60 -4
- package/dist/platform-bootstrap-runtime.d.ts +1 -1
- package/dist/platform-bootstrap-runtime.js +1 -0
- package/dist/platform-fleet-verification.js +129 -26
- package/dist/platform-launch-env.d.ts +19 -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.js
CHANGED
|
@@ -1384,6 +1384,46 @@ 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
1429
|
import { createHash as createHash2, createHmac as createHmac2, createPrivateKey, randomBytes as randomBytes3 } from "crypto";
|
|
@@ -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";
|
|
@@ -3347,6 +3387,7 @@ function platformApiCredentialSpecs(options) {
|
|
|
3347
3387
|
];
|
|
3348
3388
|
return [
|
|
3349
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 },
|
|
3350
3391
|
{ name: "seed-sync-root", encryptedPath: "/etc/forgezero/creds/seed-sync-root.cred", required: true },
|
|
3351
3392
|
...optional.filter(([, present]) => present).map(([name]) => ({
|
|
3352
3393
|
name,
|
|
@@ -3814,6 +3855,7 @@ var STATE_PATH = BOOTSTRAP_STATE_PATH;
|
|
|
3814
3855
|
var INTENT_PATH = "/var/lib/forgezero/bootstrap.intent.json";
|
|
3815
3856
|
var CREDS = "/etc/forgezero/creds";
|
|
3816
3857
|
var JWT_CREDENTIAL = `${CREDS}/arangodb-jwt.cred`;
|
|
3858
|
+
var ARANGO_ROOT_CREDENTIAL = `${CREDS}/arangodb-root-password.cred`;
|
|
3817
3859
|
var ENROL_CREDENTIAL = `${CREDS}/enrol-token.cred`;
|
|
3818
3860
|
var TUNNEL_CREDENTIAL = `${CREDS}/CF_TUNNEL_CONNECTOR_TOKEN.cred`;
|
|
3819
3861
|
var CF_API_CREDENTIAL = `${CREDS}/CF_API_TOKEN.cred`;
|
|
@@ -4099,7 +4141,7 @@ WantedBy=multi-user.target
|
|
|
4099
4141
|
function databaseVerifyUnit(config) {
|
|
4100
4142
|
const { address } = config.database;
|
|
4101
4143
|
return `[Unit]
|
|
4102
|
-
Description=
|
|
4144
|
+
Description=Secure and verify ForgeZero ArangoDB Community 3.11.14 writable Coordinator
|
|
4103
4145
|
Requires=forgezero-db.service
|
|
4104
4146
|
After=forgezero-db.service
|
|
4105
4147
|
PartOf=forgezero-db.service
|
|
@@ -4109,7 +4151,9 @@ Type=oneshot
|
|
|
4109
4151
|
User=arangodb
|
|
4110
4152
|
Group=arangodb
|
|
4111
4153
|
LoadCredentialEncrypted=arangodb-jwt:${JWT_CREDENTIAL}
|
|
4112
|
-
|
|
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
|
|
4113
4157
|
RemainAfterExit=yes
|
|
4114
4158
|
TimeoutStartSec=200
|
|
4115
4159
|
NoNewPrivileges=true
|
|
@@ -4576,6 +4620,11 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
4576
4620
|
if (nginx.exitCode !== 0)
|
|
4577
4621
|
problems.push("nginx configuration is invalid");
|
|
4578
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
|
+
}
|
|
4579
4628
|
const unitPath = "/etc/systemd/system/forgezero-db.service";
|
|
4580
4629
|
const expectsNoAgency = state.databaseAgency === "none";
|
|
4581
4630
|
const unitHasNoAgency = host.exists(unitPath) && host.read(unitPath).includes("--cluster.start-agent=false");
|
|
@@ -4810,6 +4859,9 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
|
|
|
4810
4859
|
if (!host.exists(JWT_CREDENTIAL)) {
|
|
4811
4860
|
await seal(host, "arangodb-jwt", JWT_CREDENTIAL, derive(root, "forgezero/cluster/arangodb-jwt/v1"));
|
|
4812
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
|
+
}
|
|
4813
4865
|
await seal(host, "seed-sync-root", SEED_CREDENTIAL, derive(root, "forgezero/cluster/seed-mesh/v1"));
|
|
4814
4866
|
await seal(host, "backup-recovery-root", BACKUP_RECOVERY_CREDENTIAL, derive(root, "forgezero/backup/recovery-root/v1"));
|
|
4815
4867
|
const emailCredentialName = config.runtime.environment.email?.provider === "smtp" ? "fz_smtp.password" : config.runtime.environment.email?.provider === "jetemail" ? "fz_jetemail.apiKey" : undefined;
|
|
@@ -5093,7 +5145,11 @@ function strictBootstrapDocument(value) {
|
|
|
5093
5145
|
], "runtime environment");
|
|
5094
5146
|
const environment = runtime.environment;
|
|
5095
5147
|
if (environment.initialInventory !== undefined) {
|
|
5096
|
-
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
|
+
}
|
|
5097
5153
|
exactKeys(inventory.region, ["key", "label", "country", "city", "confidentialCapable"], "initial inventory region");
|
|
5098
5154
|
if (inventory.attestation !== undefined) {
|
|
5099
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],
|
package/dist/deployment.d.ts
CHANGED
|
@@ -38,6 +38,7 @@ export interface DeploymentRequest {
|
|
|
38
38
|
sharing: ResolvedDeploymentTarget['allocation']['sharing'];
|
|
39
39
|
resources: ResolvedDeploymentTarget['allocation']['resources'];
|
|
40
40
|
connectivity?: DeploymentConnectivityIntent;
|
|
41
|
+
firewallPolicy?: import('./deployment-connectivity').DeploymentFirewallPolicy;
|
|
41
42
|
topology?: {
|
|
42
43
|
site: string;
|
|
43
44
|
nodeIdentity: string;
|
|
@@ -50,6 +51,7 @@ export interface DeploymentRequest {
|
|
|
50
51
|
localPeerIdentities: readonly string[];
|
|
51
52
|
remoteRelayAddresses: readonly string[];
|
|
52
53
|
remoteRelayIdentities: readonly string[];
|
|
54
|
+
remoteMemberAddresses: readonly string[];
|
|
53
55
|
remoteSiteCidrs: readonly string[];
|
|
54
56
|
memberIdentities: readonly string[];
|
|
55
57
|
healthPort: number;
|
|
@@ -75,6 +77,8 @@ export interface DeploymentResult {
|
|
|
75
77
|
release: string;
|
|
76
78
|
ok: boolean;
|
|
77
79
|
phases: readonly RunResult[];
|
|
80
|
+
/** Bounded, credential-redacted execution evidence suitable for control-plane history. */
|
|
81
|
+
execution: DeploymentExecution;
|
|
78
82
|
capacity?: CapacityCalibration & {
|
|
79
83
|
evidencePath: string;
|
|
80
84
|
recommendedCoordinate: {
|
|
@@ -83,6 +87,18 @@ export interface DeploymentResult {
|
|
|
83
87
|
};
|
|
84
88
|
};
|
|
85
89
|
}
|
|
90
|
+
export interface DeploymentExecutionStep {
|
|
91
|
+
phase: string;
|
|
92
|
+
step: string;
|
|
93
|
+
outcome: 'ok' | 'failed' | 'skipped';
|
|
94
|
+
exitCode: number | null;
|
|
95
|
+
durationMs: number;
|
|
96
|
+
/** Present only for a failed step; known injected credentials are redacted first. */
|
|
97
|
+
detail?: string;
|
|
98
|
+
}
|
|
99
|
+
export interface DeploymentExecution {
|
|
100
|
+
steps: readonly DeploymentExecutionStep[];
|
|
101
|
+
}
|
|
86
102
|
export interface ResolvedDeploymentTarget {
|
|
87
103
|
name: string;
|
|
88
104
|
profiles: readonly string[];
|
|
@@ -245,7 +261,8 @@ export interface DeploymentOptions {
|
|
|
245
261
|
}
|
|
246
262
|
export declare class DeploymentError extends Error {
|
|
247
263
|
readonly code: 'BAD_REVISION' | 'SOURCE_FAILED' | 'PIPELINE_FAILED' | 'SECRET_MISSING';
|
|
248
|
-
|
|
264
|
+
readonly execution?: DeploymentExecution | undefined;
|
|
265
|
+
constructor(code: 'BAD_REVISION' | 'SOURCE_FAILED' | 'PIPELINE_FAILED' | 'SECRET_MISSING', message: string, execution?: DeploymentExecution | undefined);
|
|
249
266
|
}
|
|
250
267
|
/**
|
|
251
268
|
* One source and one pipeline owner.
|