@forgezero/agent 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/cli/agent-install.d.ts +6 -0
- package/dist/definition.d.ts +0 -1
- package/dist/definition.js +1 -6
- package/dist/deployment.d.ts +1 -0
- package/dist/fz-agent.js +472 -224
- package/dist/fz.js +191 -17
- package/dist/index.d.ts +5 -0
- package/dist/lifecycle-helper.d.ts +36 -0
- package/dist/lifecycle-helper.js +216 -0
- package/dist/metal-helper-socket.js +8 -532
- package/dist/metal-provision.d.ts +1 -6
- package/dist/metal-provision.js +8 -536
- package/dist/migration-pull.d.ts +55 -0
- package/dist/migration-pull.js +185 -0
- package/dist/provision.d.ts +17 -0
- package/dist/provision.js +189 -15
- package/dist/version.d.ts +1 -1
- package/dist/warp-config.d.ts +20 -0
- package/package.json +11 -3
package/dist/fz-agent.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
// src/index.ts
|
|
5
5
|
import { randomBytes } from "crypto";
|
|
6
|
-
import { readFileSync as
|
|
6
|
+
import { readFileSync as readFileSync5, writeFileSync as writeFileSync6, existsSync as existsSync10, mkdirSync as mkdirSync6, chmodSync as chmodSync9 } from "fs";
|
|
7
7
|
import { dirname as dirname4, join as join4 } from "path";
|
|
8
8
|
import { deriveKeysFromSeed } from "@forgezero/runtime/identity";
|
|
9
9
|
import { DEFAULT_SOCKET } from "@forgezero/vault";
|
|
@@ -473,17 +473,12 @@ function parseDeployDefinition(value) {
|
|
|
473
473
|
}
|
|
474
474
|
const roles = root.roles.map((raw, index) => {
|
|
475
475
|
const role = record(raw, `roles[${index}]`);
|
|
476
|
-
exactKeys(role, ["name", "
|
|
477
|
-
const count = Number(role.count);
|
|
478
|
-
if (!Number.isSafeInteger(count) || count < 1) {
|
|
479
|
-
throw new DefinitionError(`roles[${index}].count must be a positive integer.`);
|
|
480
|
-
}
|
|
476
|
+
exactKeys(role, ["name", "software"], `roles[${index}]`);
|
|
481
477
|
if (!Array.isArray(role.software)) {
|
|
482
478
|
throw new DefinitionError(`roles[${index}].software must be an array.`);
|
|
483
479
|
}
|
|
484
480
|
return {
|
|
485
481
|
name: text(role.name, `roles[${index}].name`),
|
|
486
|
-
count,
|
|
487
482
|
software: role.software.map((rawSoftware, softwareIndex) => {
|
|
488
483
|
const software = record(rawSoftware, `roles[${index}].software[${softwareIndex}]`);
|
|
489
484
|
exactKeys(software, ["name", "check", "install"], `roles[${index}].software[${softwareIndex}]`);
|
|
@@ -1378,6 +1373,132 @@ function startProvisioningPull(options) {
|
|
|
1378
1373
|
};
|
|
1379
1374
|
}
|
|
1380
1375
|
|
|
1376
|
+
// src/migration-pull.ts
|
|
1377
|
+
class MigrationClaimLostError extends Error {
|
|
1378
|
+
constructor(message) {
|
|
1379
|
+
super(message);
|
|
1380
|
+
this.name = "MigrationClaimLostError";
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
var post2 = (options, operation, body) => postSignedNode(options, `v1/node/migrations/${operation}`, body);
|
|
1384
|
+
async function complete2(options, body) {
|
|
1385
|
+
const attempts = Math.max(1, Math.min(options.completionAttempts ?? 5, 10));
|
|
1386
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1387
|
+
let last;
|
|
1388
|
+
for (let attempt = 1;attempt <= attempts; attempt += 1) {
|
|
1389
|
+
try {
|
|
1390
|
+
await post2(options, "complete", body);
|
|
1391
|
+
return;
|
|
1392
|
+
} catch (cause) {
|
|
1393
|
+
last = cause;
|
|
1394
|
+
if (cause instanceof SignedNodeHttpError && cause.status < 500)
|
|
1395
|
+
throw cause;
|
|
1396
|
+
if (attempt < attempts)
|
|
1397
|
+
await sleep(Math.min(2000, 250 * 2 ** (attempt - 1)));
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
throw last;
|
|
1401
|
+
}
|
|
1402
|
+
async function pullMigrationOnce(options) {
|
|
1403
|
+
const response = await post2(options, "claim", {});
|
|
1404
|
+
if (!response.claim)
|
|
1405
|
+
return { status: "idle" };
|
|
1406
|
+
const claim = response.claim;
|
|
1407
|
+
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
1408
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
1409
|
+
const now = options.now ?? Date.now;
|
|
1410
|
+
let expires = claim.claimExpiresAtTs;
|
|
1411
|
+
let stopped = false;
|
|
1412
|
+
let timer;
|
|
1413
|
+
let renewal = null;
|
|
1414
|
+
let lost = null;
|
|
1415
|
+
const schedule = (override) => {
|
|
1416
|
+
if (stopped)
|
|
1417
|
+
return;
|
|
1418
|
+
const delay = override ?? Math.max(1000, Math.min(5 * 60000, Math.floor(Math.max(0, expires - now()) / 3)));
|
|
1419
|
+
timer = setTimer(() => {
|
|
1420
|
+
if (stopped || renewal)
|
|
1421
|
+
return;
|
|
1422
|
+
let retry;
|
|
1423
|
+
renewal = post2(options, "renew", {
|
|
1424
|
+
migrationKey: claim.migrationKey,
|
|
1425
|
+
claimToken: claim.claimToken
|
|
1426
|
+
}).then((value) => {
|
|
1427
|
+
expires = value.claimExpiresAtTs;
|
|
1428
|
+
}).catch((cause) => {
|
|
1429
|
+
if (cause instanceof SignedNodeHttpError && cause.status < 500) {
|
|
1430
|
+
lost = new MigrationClaimLostError(cause.message);
|
|
1431
|
+
stopped = true;
|
|
1432
|
+
} else
|
|
1433
|
+
retry = Math.max(1000, options.renewRetryMs ?? 5000);
|
|
1434
|
+
}).finally(() => {
|
|
1435
|
+
renewal = null;
|
|
1436
|
+
if (!stopped)
|
|
1437
|
+
schedule(retry);
|
|
1438
|
+
});
|
|
1439
|
+
}, delay);
|
|
1440
|
+
};
|
|
1441
|
+
schedule();
|
|
1442
|
+
let evidence;
|
|
1443
|
+
try {
|
|
1444
|
+
evidence = await options.run(claim);
|
|
1445
|
+
} catch (cause) {
|
|
1446
|
+
stopped = true;
|
|
1447
|
+
clearTimer(timer);
|
|
1448
|
+
await renewal;
|
|
1449
|
+
if (lost)
|
|
1450
|
+
throw lost;
|
|
1451
|
+
const reason = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2000);
|
|
1452
|
+
await complete2(options, {
|
|
1453
|
+
migrationKey: claim.migrationKey,
|
|
1454
|
+
claimToken: claim.claimToken,
|
|
1455
|
+
ok: false,
|
|
1456
|
+
detail: reason
|
|
1457
|
+
});
|
|
1458
|
+
return { status: "failed", claim, reason };
|
|
1459
|
+
}
|
|
1460
|
+
stopped = true;
|
|
1461
|
+
clearTimer(timer);
|
|
1462
|
+
await renewal;
|
|
1463
|
+
if (lost)
|
|
1464
|
+
throw lost;
|
|
1465
|
+
await complete2(options, {
|
|
1466
|
+
migrationKey: claim.migrationKey,
|
|
1467
|
+
claimToken: claim.claimToken,
|
|
1468
|
+
ok: true,
|
|
1469
|
+
evidence
|
|
1470
|
+
});
|
|
1471
|
+
return { status: "completed", claim, evidence };
|
|
1472
|
+
}
|
|
1473
|
+
function startMigrationPull(options) {
|
|
1474
|
+
const interval = Math.max(1000, options.intervalMs ?? 5000);
|
|
1475
|
+
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
1476
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
1477
|
+
let stopped = false;
|
|
1478
|
+
let timer;
|
|
1479
|
+
let active = null;
|
|
1480
|
+
const tick = () => {
|
|
1481
|
+
if (stopped || active)
|
|
1482
|
+
return;
|
|
1483
|
+
active = pullMigrationOnce(options).then((result) => options.onEvent?.(result.status, result)).catch((cause) => options.onEvent?.("poll-failed", cause)).finally(() => {
|
|
1484
|
+
active = null;
|
|
1485
|
+
if (!stopped)
|
|
1486
|
+
timer = setTimer(tick, interval);
|
|
1487
|
+
});
|
|
1488
|
+
};
|
|
1489
|
+
tick();
|
|
1490
|
+
return {
|
|
1491
|
+
async stop() {
|
|
1492
|
+
stopped = true;
|
|
1493
|
+
clearTimer(timer);
|
|
1494
|
+
await active;
|
|
1495
|
+
},
|
|
1496
|
+
get active() {
|
|
1497
|
+
return !stopped;
|
|
1498
|
+
}
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1381
1502
|
// src/metal-helper-socket.ts
|
|
1382
1503
|
import { chmodSync as chmodSync5, existsSync as existsSync6, unlinkSync as unlinkSync5 } from "fs";
|
|
1383
1504
|
import { connect as connect2, createServer as createServer3 } from "net";
|
|
@@ -1494,55 +1615,6 @@ function shapeEgressUnitDirectives(tap, guaranteedMbps, burstMbps) {
|
|
|
1494
1615
|
];
|
|
1495
1616
|
}
|
|
1496
1617
|
|
|
1497
|
-
// src/provision.ts
|
|
1498
|
-
var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
1499
|
-
var DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
1500
|
-
var VAULT_GROUP = "forgezero-vault";
|
|
1501
|
-
var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
1502
|
-
function deploymentRunnerUnit(options) {
|
|
1503
|
-
const bin = options.binPath ?? "fz-agent";
|
|
1504
|
-
const root = options.deployRoot ?? "/opt/forgezero";
|
|
1505
|
-
const agentUser = options.user ?? "forgezero-agent";
|
|
1506
|
-
return `[Unit]
|
|
1507
|
-
Description=ForgeZero credential-free project command runner
|
|
1508
|
-
Documentation=https://www.forgezero.net/docs/agent
|
|
1509
|
-
|
|
1510
|
-
[Service]
|
|
1511
|
-
Type=notify
|
|
1512
|
-
NotifyAccess=all
|
|
1513
|
-
User=${DEPLOYMENT_RUNNER_USER}
|
|
1514
|
-
Group=${DEPLOYMENT_GROUP}
|
|
1515
|
-
Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
|
|
1516
|
-
RuntimeDirectory=forgezero-deploy
|
|
1517
|
-
RuntimeDirectoryMode=0710
|
|
1518
|
-
ExecStartPre=+/usr/bin/install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0710 /run/forgezero-deploy
|
|
1519
|
-
ExecStartPre=+/usr/bin/rm -f ${DEPLOYMENT_RUNNER_SOCKET}
|
|
1520
|
-
ExecStart=${bin} deploy-runner --root=${root} --home=${root}/runner-home
|
|
1521
|
-
ExecStartPost=+/usr/bin/chown ${agentUser}:${agentUser} ${DEPLOYMENT_RUNNER_SOCKET}
|
|
1522
|
-
ExecStartPost=+/usr/bin/chmod 0600 ${DEPLOYMENT_RUNNER_SOCKET}
|
|
1523
|
-
ExecStartPost=+/usr/bin/chown root:${VAULT_GROUP} /run/forgezero-deploy
|
|
1524
|
-
ExecStopPost=+/usr/bin/rm -f ${DEPLOYMENT_RUNNER_SOCKET}
|
|
1525
|
-
Restart=always
|
|
1526
|
-
RestartSec=2
|
|
1527
|
-
UMask=0007
|
|
1528
|
-
LimitCORE=0
|
|
1529
|
-
NoNewPrivileges=false
|
|
1530
|
-
PrivateTmp=true
|
|
1531
|
-
ProtectSystem=strict
|
|
1532
|
-
ProtectHome=true
|
|
1533
|
-
ProtectKernelTunables=true
|
|
1534
|
-
ProtectKernelModules=true
|
|
1535
|
-
ProtectControlGroups=true
|
|
1536
|
-
RestrictRealtime=true
|
|
1537
|
-
MemoryDenyWriteExecute=true
|
|
1538
|
-
LockPersonality=true
|
|
1539
|
-
ReadWritePaths=${root}/releases ${root}/runner-home
|
|
1540
|
-
|
|
1541
|
-
[Install]
|
|
1542
|
-
WantedBy=multi-user.target
|
|
1543
|
-
`;
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
1618
|
// src/ubuntu.ts
|
|
1547
1619
|
var SUPPORTED_GUEST_IMAGE = Object.freeze({
|
|
1548
1620
|
key: "ubuntu-resolute-20260731",
|
|
@@ -1723,7 +1795,7 @@ var yamlFile = (path, content, permissions) => ` - path: ${JSON.stringify(path)
|
|
|
1723
1795
|
encoding: b64
|
|
1724
1796
|
content: ${base64(content)}
|
|
1725
1797
|
`;
|
|
1726
|
-
function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true) {
|
|
1798
|
+
function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true, nodeLabel = "compute") {
|
|
1727
1799
|
const agentBun = "/usr/local/lib/forgezero/bun";
|
|
1728
1800
|
const attestationSetup = attested ? `# The report device is not part of the encryption path, so a guest can appear
|
|
1729
1801
|
# healthy and encrypted while attestation is silently impossible. Install and
|
|
@@ -1737,26 +1809,7 @@ printf 'sev-guest
|
|
|
1737
1809
|
` : "";
|
|
1738
1810
|
return `#!/usr/bin/env bash
|
|
1739
1811
|
set -Eeuo pipefail
|
|
1740
|
-
${attestationSetup}
|
|
1741
|
-
useradd --system --no-create-home --shell /usr/sbin/nologin forgezero-agent 2>/dev/null || true
|
|
1742
|
-
usermod -g ${VAULT_GROUP} forgezero-agent
|
|
1743
|
-
groupadd --system ${DEPLOYMENT_GROUP} 2>/dev/null || true
|
|
1744
|
-
useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} 2>/dev/null || true
|
|
1745
|
-
usermod -a -G ${DEPLOYMENT_GROUP} forgezero-agent
|
|
1746
|
-
install -d -o root -g root -m 0700 /etc/forgezero/creds
|
|
1747
|
-
install -d -o root -g root -m 0755 /etc/forgezero/git
|
|
1748
|
-
install -d -o forgezero-agent -g forgezero-agent -m 0700 /var/lib/forgezero
|
|
1749
|
-
install -d -o root -g root -m 0755 /opt/forgezero
|
|
1750
|
-
install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 /opt/forgezero/releases
|
|
1751
|
-
install -d -o forgezero-agent -g forgezero-agent -m 0700 /opt/forgezero/cache /opt/forgezero/home
|
|
1752
|
-
install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 /opt/forgezero/runner-home /opt/forgezero/runner-home/cache
|
|
1753
|
-
${hasEnrolment ? `if [[ -s /run/forgezero-enrol-token ]]; then
|
|
1754
|
-
systemd-creds encrypt --name=enrol-token /run/forgezero-enrol-token /var/lib/forgezero/enrol-token.cred
|
|
1755
|
-
rm -f /run/forgezero-enrol-token
|
|
1756
|
-
fi
|
|
1757
|
-
chown root:root /var/lib/forgezero/enrol-token.cred
|
|
1758
|
-
chmod 0400 /var/lib/forgezero/enrol-token.cred
|
|
1759
|
-
` : ""}if [[ ! -x /usr/local/bin/bun ]]; then
|
|
1812
|
+
${attestationSetup}if [[ ! -x /usr/local/bin/bun ]]; then
|
|
1760
1813
|
curl -fsSL https://bun.sh/install -o /run/fz-bun-install
|
|
1761
1814
|
printf '%s %s
|
|
1762
1815
|
' '${profile.bunInstallerSha256}' /run/fz-bun-install | sha256sum -c -
|
|
@@ -1764,132 +1817,16 @@ chmod 0400 /var/lib/forgezero/enrol-token.cred
|
|
|
1764
1817
|
install -m 0755 ${agentBun}/bin/bun /usr/local/bin/bun
|
|
1765
1818
|
rm -f /run/fz-bun-install
|
|
1766
1819
|
fi
|
|
1767
|
-
if [[ ! -x /usr/local/bin/fz-agent ]]; then
|
|
1820
|
+
if [[ ! -x /usr/local/bin/fz-agent ]] || [[ "$(/usr/local/bin/fz-agent --version 2>/dev/null || true)" != "${profile.agentVersion}" ]]; then
|
|
1768
1821
|
env BUN_INSTALL=${agentBun} /usr/local/bin/bun add -g --no-cache --force @forgezero/agent@${profile.agentVersion}
|
|
1769
1822
|
ln -sfn ${agentBun}/bin/fz-agent /usr/local/bin/fz-agent
|
|
1770
1823
|
fi
|
|
1771
|
-
|
|
1772
|
-
umask 077
|
|
1773
|
-
openssl rand -base64 32 | tr '+/' '-_' | tr -d '=
|
|
1774
|
-
' >/run/fz-agent-seed
|
|
1775
|
-
systemd-creds encrypt --name=agent-seed /run/fz-agent-seed /etc/forgezero/creds/agent-seed.cred
|
|
1776
|
-
rm -f /run/fz-agent-seed
|
|
1777
|
-
fi
|
|
1778
|
-
chmod 0400 /etc/forgezero/creds/agent-seed.cred
|
|
1779
|
-
if [[ ! -s /etc/forgezero/creds/git-deploy-key.cred ]]; then
|
|
1780
|
-
umask 077
|
|
1781
|
-
rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
|
|
1782
|
-
ssh-keygen -q -t ed25519 -N '' -C forgezero-compute -f /run/fz-git-deploy-key
|
|
1783
|
-
systemd-creds encrypt --name=git-deploy-key /run/fz-git-deploy-key /etc/forgezero/creds/git-deploy-key.cred
|
|
1784
|
-
install -o root -g root -m 0444 /run/fz-git-deploy-key.pub /etc/forgezero/git/deploy.pub
|
|
1785
|
-
rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
|
|
1786
|
-
fi
|
|
1787
|
-
if [[ ! -s /etc/forgezero/git/deploy.pub ]]; then
|
|
1788
|
-
systemd-creds decrypt --name=git-deploy-key /etc/forgezero/creds/git-deploy-key.cred /run/fz-git-deploy-key
|
|
1789
|
-
ssh-keygen -y -f /run/fz-git-deploy-key | sed 's/$/ forgezero-compute/' >/run/fz-git-deploy-key.pub
|
|
1790
|
-
install -o root -g root -m 0444 /run/fz-git-deploy-key.pub /etc/forgezero/git/deploy.pub
|
|
1791
|
-
rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
|
|
1792
|
-
fi
|
|
1793
|
-
chmod 0400 /etc/forgezero/creds/git-deploy-key.cred
|
|
1794
|
-
systemctl daemon-reload
|
|
1795
|
-
systemctl disable --now forgezero-deploy-runner.socket 2>/dev/null || true
|
|
1796
|
-
rm -f /etc/systemd/system/forgezero-deploy-runner.socket
|
|
1797
|
-
systemctl daemon-reload
|
|
1798
|
-
systemctl enable --now forgezero-deploy-runner.service forgezero-agent.service
|
|
1799
|
-
`;
|
|
1800
|
-
}
|
|
1801
|
-
function guestAgentUnit(profile, name, attested = Boolean(profile.confidential), pull = true) {
|
|
1802
|
-
const attestationPrepare = attested ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
|
|
1803
|
-
ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
|
|
1804
|
-
` : "";
|
|
1805
|
-
const attestationDevice = attested ? `DevicePolicy=closed
|
|
1806
|
-
DeviceAllow=/dev/sev-guest rw
|
|
1807
|
-
` : "";
|
|
1808
|
-
return `[Unit]
|
|
1809
|
-
Description=ForgeZero compute agent (${name})
|
|
1810
|
-
After=network-online.target forgezero-deploy-runner.service
|
|
1811
|
-
Wants=network-online.target
|
|
1812
|
-
Requires=forgezero-deploy-runner.service
|
|
1813
|
-
|
|
1814
|
-
[Service]
|
|
1815
|
-
Type=simple
|
|
1816
|
-
User=forgezero-agent
|
|
1817
|
-
Group=${VAULT_GROUP}
|
|
1818
|
-
SupplementaryGroups=${DEPLOYMENT_GROUP}
|
|
1819
|
-
LoadCredentialEncrypted=agent-seed:/etc/forgezero/creds/agent-seed.cred
|
|
1820
|
-
LoadCredentialEncrypted=git-deploy-key:/etc/forgezero/creds/git-deploy-key.cred
|
|
1821
|
-
Environment=FZ_SEED_CREDENTIAL=agent-seed
|
|
1822
|
-
Environment=FZ_GIT_PUBLIC_KEY_FILE=/etc/forgezero/git/deploy.pub
|
|
1823
|
-
Environment=FZ_API=${profile.apiUrl}
|
|
1824
|
-
Environment=FZ_ENROL_STATE_FILE=/var/lib/forgezero/enrolment.json
|
|
1825
|
-
Environment=FZ_NODE_LABEL=${name}
|
|
1826
|
-
Environment=FZ_SOCKET_PATH=/run/forgezero/vault.sock
|
|
1827
|
-
Environment=FZ_DEPLOY_ROOT=/opt/forgezero
|
|
1828
|
-
Environment=FZ_DEPLOY_PULL=${pull ? "true" : "false"}
|
|
1829
|
-
Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
|
|
1830
|
-
Environment=HOME=/opt/forgezero/home
|
|
1831
|
-
${attestationPrepare}ExecStart=/usr/local/bin/fz-agent
|
|
1832
|
-
Restart=on-failure
|
|
1833
|
-
RestartSec=5
|
|
1834
|
-
RuntimeDirectory=forgezero
|
|
1835
|
-
RuntimeDirectoryMode=0750
|
|
1836
|
-
UMask=0007
|
|
1837
|
-
LimitCORE=0
|
|
1838
|
-
NoNewPrivileges=true
|
|
1839
|
-
PrivateTmp=true
|
|
1840
|
-
ProtectSystem=strict
|
|
1841
|
-
ProtectHome=true
|
|
1842
|
-
ReadWritePaths=/var/lib/forgezero /opt/forgezero
|
|
1843
|
-
${attestationDevice}
|
|
1844
|
-
|
|
1845
|
-
[Install]
|
|
1846
|
-
WantedBy=multi-user.target
|
|
1847
|
-
`;
|
|
1848
|
-
}
|
|
1849
|
-
function guestEnrolmentDropIn() {
|
|
1850
|
-
return `[Service]
|
|
1851
|
-
LoadCredentialEncrypted=enrol-token:/var/lib/forgezero/enrol-token.cred
|
|
1852
|
-
Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
|
|
1853
|
-
`;
|
|
1854
|
-
}
|
|
1855
|
-
function guestEnrolmentCleanupScript() {
|
|
1856
|
-
return `#!/usr/bin/env bash
|
|
1857
|
-
set -Eeuo pipefail
|
|
1858
|
-
for _ in $(seq 1 180); do
|
|
1859
|
-
[[ -s /var/lib/forgezero/enrolment.json ]] && break
|
|
1860
|
-
sleep 1
|
|
1861
|
-
done
|
|
1862
|
-
[[ -s /var/lib/forgezero/enrolment.json ]] || { echo 'guest enrolment did not become durable' >&2; exit 1; }
|
|
1863
|
-
rm -f /var/lib/forgezero/enrol-token.cred
|
|
1864
|
-
rm -f /etc/systemd/system/forgezero-agent.service.d/enrolment.conf
|
|
1865
|
-
systemctl daemon-reload
|
|
1866
|
-
systemctl disable forgezero-enrolment-cleanup.service
|
|
1867
|
-
`;
|
|
1868
|
-
}
|
|
1869
|
-
function guestEnrolmentCleanupUnit() {
|
|
1870
|
-
return `[Unit]
|
|
1871
|
-
Description=Remove the consumed ForgeZero guest enrolment credential
|
|
1872
|
-
After=forgezero-agent.service
|
|
1873
|
-
Requires=forgezero-agent.service
|
|
1874
|
-
ConditionPathExists=/var/lib/forgezero/enrol-token.cred
|
|
1875
|
-
|
|
1876
|
-
[Service]
|
|
1877
|
-
Type=oneshot
|
|
1878
|
-
ExecStart=/usr/local/sbin/forgezero-enrolment-cleanup
|
|
1879
|
-
TimeoutStartSec=4min
|
|
1880
|
-
|
|
1881
|
-
[Install]
|
|
1882
|
-
WantedBy=multi-user.target
|
|
1824
|
+
env FZ_API=${profile.apiUrl} FZ_AGENT_BIN=/usr/local/bin/fz-agent FZ_AGENT_USER=forgezero-agent FZ_SOCKET_PATH=/run/forgezero/vault.sock FZ_SEED_CREDENTIAL_PATH=/etc/forgezero/creds/agent-seed.cred FZ_GIT_CREDENTIAL_PATH=/etc/forgezero/creds/git-deploy-key.cred FZ_GIT_PUBLIC_KEY_PATH=/etc/forgezero/git/deploy.pub FZ_DEPLOY_ROOT=/opt/forgezero FZ_DEPLOY_PULL=${hasEnrolment ? "true" : "false"} FZ_NODE_LABEL=${nodeLabel} ${agentBun}/bin/fz agent install --apply${hasEnrolment ? " --enrol" : ""}
|
|
1883
1825
|
`;
|
|
1884
1826
|
}
|
|
1885
1827
|
function cloudInit(profile, claim, manifest) {
|
|
1886
1828
|
const enrolled = Boolean(claim.enrolment);
|
|
1887
|
-
const bootstrap = guestBootstrapScript(profile, claim.spec.confidential, enrolled);
|
|
1888
|
-
const agentUnit = guestAgentUnit(profile, manifest.name, claim.spec.confidential, enrolled);
|
|
1889
|
-
const enrolmentDropIn = guestEnrolmentDropIn();
|
|
1890
|
-
const cleanupScript = guestEnrolmentCleanupScript();
|
|
1891
|
-
const cleanupUnit = guestEnrolmentCleanupUnit();
|
|
1892
|
-
const runnerUnit = deploymentRunnerUnit({ binPath: "/usr/local/bin/fz-agent", deployRoot: "/opt/forgezero", user: "forgezero-agent" });
|
|
1829
|
+
const bootstrap = guestBootstrapScript(profile, claim.spec.confidential, enrolled, manifest.name);
|
|
1893
1830
|
const access = claim.access;
|
|
1894
1831
|
const users = access ? `users:
|
|
1895
1832
|
- default
|
|
@@ -1901,8 +1838,8 @@ function cloudInit(profile, claim, manifest) {
|
|
|
1901
1838
|
${access.sshPublicKeys.map((key) => ` - ${JSON.stringify(key)}`).join(`
|
|
1902
1839
|
`)}
|
|
1903
1840
|
` : "";
|
|
1904
|
-
const enrolmentFiles = enrolled ?
|
|
1905
|
-
`, "0600")
|
|
1841
|
+
const enrolmentFiles = enrolled ? yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
|
|
1842
|
+
`, "0600") : "";
|
|
1906
1843
|
return {
|
|
1907
1844
|
userData: `#cloud-config
|
|
1908
1845
|
package_update: true
|
|
@@ -1911,10 +1848,8 @@ ${users}disable_root: true
|
|
|
1911
1848
|
# every dynamically claimed repository must be cloneable on a clean image.
|
|
1912
1849
|
packages: [curl, ca-certificates, openssl, openssh-client, git, unzip${claim.spec.confidential ? ", python3" : ""}]
|
|
1913
1850
|
write_files:
|
|
1914
|
-
${enrolmentFiles}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}
|
|
1851
|
+
${enrolmentFiles}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}runcmd:
|
|
1915
1852
|
- [ bash, /usr/local/sbin/forgezero-guest-bootstrap ]
|
|
1916
|
-
${enrolled ? ` - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
|
|
1917
|
-
` : ""}
|
|
1918
1853
|
`,
|
|
1919
1854
|
metaData: `instance-id: ${manifest.name}-${claim.attempt}
|
|
1920
1855
|
local-hostname: ${manifest.name}
|
|
@@ -2737,22 +2672,268 @@ async function closeServerWithin(server, timeoutMs) {
|
|
|
2737
2672
|
return closed;
|
|
2738
2673
|
}
|
|
2739
2674
|
|
|
2675
|
+
// src/lifecycle-helper.ts
|
|
2676
|
+
import { chmodSync as chmodSync7, existsSync as existsSync9, readFileSync as readFileSync4, unlinkSync as unlinkSync7 } from "fs";
|
|
2677
|
+
import { connect as connect4, createConnection, createServer as createServer5, isIP } from "net";
|
|
2678
|
+
var DEFAULT_LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
|
|
2679
|
+
var MAX_REQUEST_BYTES4 = 16 * 1024;
|
|
2680
|
+
var REQUEST_TIMEOUT_MS = 5000;
|
|
2681
|
+
var ACTION_TIMEOUT_MS = 10 * 60000;
|
|
2682
|
+
var unitPattern = /^[A-Za-z0-9_.@-]+\.service$/;
|
|
2683
|
+
var privateIp = (value) => {
|
|
2684
|
+
const address = value.replace(/^\[|\]$/g, "").toLowerCase();
|
|
2685
|
+
if (isIP(address) === 4) {
|
|
2686
|
+
const [a, b] = address.split(".").map(Number);
|
|
2687
|
+
return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
|
|
2688
|
+
}
|
|
2689
|
+
if (isIP(address) === 6) {
|
|
2690
|
+
const first = Number.parseInt(address.split(":", 1)[0], 16);
|
|
2691
|
+
return Number.isFinite(first) && (first & 65024) === 64512;
|
|
2692
|
+
}
|
|
2693
|
+
return false;
|
|
2694
|
+
};
|
|
2695
|
+
function validateLifecycleProfile(profile) {
|
|
2696
|
+
if (!Array.isArray(profile.apiUnits) || profile.apiUnits.length < 1 || profile.apiUnits.some((unit) => !unitPattern.test(unit))) {
|
|
2697
|
+
throw new Error("lifecycle profile needs one or more valid API service units");
|
|
2698
|
+
}
|
|
2699
|
+
if (!unitPattern.test(profile.databaseUnit))
|
|
2700
|
+
throw new Error("lifecycle profile database unit is invalid");
|
|
2701
|
+
const apiUrl = new URL(profile.apiHealthUrl);
|
|
2702
|
+
if (apiUrl.protocol !== "http:" || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(apiUrl.hostname)) {
|
|
2703
|
+
throw new Error("API health URL must be loopback HTTP");
|
|
2704
|
+
}
|
|
2705
|
+
const databaseUrl = new URL(profile.databaseHealthUrl);
|
|
2706
|
+
if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
|
|
2707
|
+
throw new Error("database health URL must be loopback or private HTTP");
|
|
2708
|
+
}
|
|
2709
|
+
if (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
|
|
2710
|
+
throw new Error("lifecycle profile database ports are invalid");
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
function loadLifecycleProfile(path) {
|
|
2714
|
+
const profile = JSON.parse(readFileSync4(path, "utf8"));
|
|
2715
|
+
validateLifecycleProfile(profile);
|
|
2716
|
+
return profile;
|
|
2717
|
+
}
|
|
2718
|
+
var spawnLifecycleCommand = async (argv) => {
|
|
2719
|
+
const child = Bun.spawn([...argv], {
|
|
2720
|
+
stdout: "pipe",
|
|
2721
|
+
stderr: "pipe",
|
|
2722
|
+
env: { PATH: "/usr/sbin:/usr/bin:/sbin:/bin" }
|
|
2723
|
+
});
|
|
2724
|
+
let timedOut = false;
|
|
2725
|
+
const timer = setTimeout(() => {
|
|
2726
|
+
timedOut = true;
|
|
2727
|
+
child.kill("SIGTERM");
|
|
2728
|
+
}, ACTION_TIMEOUT_MS);
|
|
2729
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
2730
|
+
new Response(child.stdout).text(),
|
|
2731
|
+
new Response(child.stderr).text(),
|
|
2732
|
+
child.exited
|
|
2733
|
+
]);
|
|
2734
|
+
clearTimeout(timer);
|
|
2735
|
+
return { exitCode: timedOut ? 124 : exitCode, stdout, stderr };
|
|
2736
|
+
};
|
|
2737
|
+
var requireSuccess = async (exec, argv, label) => {
|
|
2738
|
+
const result = await exec(argv);
|
|
2739
|
+
if (result.exitCode !== 0)
|
|
2740
|
+
throw new Error(`${label} failed: ${(result.stderr || result.stdout).trim() || `exit ${result.exitCode}`}`);
|
|
2741
|
+
return result.stdout;
|
|
2742
|
+
};
|
|
2743
|
+
var probeTcp = (host, port, timeoutMs = 5000) => new Promise((resolve2, reject) => {
|
|
2744
|
+
const socket = createConnection({ host, port });
|
|
2745
|
+
socket.setTimeout(timeoutMs);
|
|
2746
|
+
socket.once("connect", () => {
|
|
2747
|
+
socket.destroy();
|
|
2748
|
+
resolve2();
|
|
2749
|
+
});
|
|
2750
|
+
socket.once("timeout", () => {
|
|
2751
|
+
socket.destroy();
|
|
2752
|
+
reject(new Error(`private peer ${host}:${port} timed out`));
|
|
2753
|
+
});
|
|
2754
|
+
socket.once("error", reject);
|
|
2755
|
+
});
|
|
2756
|
+
async function executeLifecycleAction(profile, claim, exec = spawnLifecycleCommand, tcpProbe = probeTcp, fetcher = fetch) {
|
|
2757
|
+
validateLifecycleProfile(profile);
|
|
2758
|
+
switch (claim.action) {
|
|
2759
|
+
case "network-ready": {
|
|
2760
|
+
if (!claim.peerPrivateAddresses?.length)
|
|
2761
|
+
throw new Error("network claim has no private peers");
|
|
2762
|
+
if (claim.network === "cloudflare-warp") {
|
|
2763
|
+
const status = await requireSuccess(exec, ["/usr/bin/warp-cli", "--accept-tos", "status"], "WARP status");
|
|
2764
|
+
if (!/\bconnected\b/i.test(status) || /\bdisconnected\b/i.test(status))
|
|
2765
|
+
throw new Error("WARP is not connected");
|
|
2766
|
+
}
|
|
2767
|
+
for (const address of claim.peerPrivateAddresses) {
|
|
2768
|
+
for (const port of profile.databasePorts)
|
|
2769
|
+
await tcpProbe(address, port);
|
|
2770
|
+
}
|
|
2771
|
+
return {
|
|
2772
|
+
targetAgentReady: true,
|
|
2773
|
+
privateNetworkReady: true,
|
|
2774
|
+
...claim.network === "cloudflare-warp" ? { warpConnected: true } : {}
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
case "database-member-ready": {
|
|
2778
|
+
await requireSuccess(exec, ["/usr/bin/systemctl", "is-active", profile.databaseUnit], "database service check");
|
|
2779
|
+
const response = await fetcher(profile.databaseHealthUrl, { signal: AbortSignal.timeout(5000) });
|
|
2780
|
+
if (!response.ok && response.status !== 401)
|
|
2781
|
+
throw new Error(`database health returned HTTP ${response.status}`);
|
|
2782
|
+
return { databaseMemberHealthy: true };
|
|
2783
|
+
}
|
|
2784
|
+
case "api-ready": {
|
|
2785
|
+
const response = await fetcher(profile.apiHealthUrl, { signal: AbortSignal.timeout(5000) });
|
|
2786
|
+
if (!response.ok)
|
|
2787
|
+
throw new Error(`API health returned HTTP ${response.status}`);
|
|
2788
|
+
return { apiHealthy: true };
|
|
2789
|
+
}
|
|
2790
|
+
case "source-drained":
|
|
2791
|
+
await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits], "API drain");
|
|
2792
|
+
return { sourceDrained: true };
|
|
2793
|
+
case "source-stopped":
|
|
2794
|
+
await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits, profile.databaseUnit], "source retirement");
|
|
2795
|
+
return { sourceStopped: true };
|
|
2796
|
+
default:
|
|
2797
|
+
throw new Error("unknown lifecycle action");
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
function startLifecycleHelper(options) {
|
|
2801
|
+
validateLifecycleProfile(options.profile);
|
|
2802
|
+
const socketPath = options.socketPath ?? DEFAULT_LIFECYCLE_HELPER_SOCKET;
|
|
2803
|
+
if (existsSync9(socketPath))
|
|
2804
|
+
unlinkSync7(socketPath);
|
|
2805
|
+
let tail = Promise.resolve();
|
|
2806
|
+
const server = createServer5((socket) => {
|
|
2807
|
+
let buffer = "";
|
|
2808
|
+
socket.setTimeout(REQUEST_TIMEOUT_MS, () => socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request timed out" } })}
|
|
2809
|
+
`));
|
|
2810
|
+
socket.on("data", (chunk) => {
|
|
2811
|
+
buffer += chunk.toString("utf8");
|
|
2812
|
+
if (buffer.length > MAX_REQUEST_BYTES4)
|
|
2813
|
+
return void socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request too large" } })}
|
|
2814
|
+
`);
|
|
2815
|
+
const newline = buffer.indexOf(`
|
|
2816
|
+
`);
|
|
2817
|
+
if (newline < 0)
|
|
2818
|
+
return;
|
|
2819
|
+
socket.setTimeout(0);
|
|
2820
|
+
const line = buffer.slice(0, newline);
|
|
2821
|
+
buffer = "";
|
|
2822
|
+
const work = async () => {
|
|
2823
|
+
let request;
|
|
2824
|
+
try {
|
|
2825
|
+
request = JSON.parse(line);
|
|
2826
|
+
} catch {
|
|
2827
|
+
return { ok: false, error: { code: "REFUSED", message: "invalid request" } };
|
|
2828
|
+
}
|
|
2829
|
+
if (request?.op !== "apply" || !request.claim)
|
|
2830
|
+
return { ok: false, error: { code: "REFUSED", message: "unknown operation" } };
|
|
2831
|
+
try {
|
|
2832
|
+
return { ok: true, evidence: await executeLifecycleAction(options.profile, request.claim, options.exec, options.tcpProbe, options.fetch) };
|
|
2833
|
+
} catch (cause) {
|
|
2834
|
+
return { ok: false, error: { code: "FAILED", message: cause instanceof Error ? cause.message : "lifecycle action failed" } };
|
|
2835
|
+
}
|
|
2836
|
+
};
|
|
2837
|
+
const response = tail.then(work, work);
|
|
2838
|
+
tail = response;
|
|
2839
|
+
response.then((value) => socket.end(`${JSON.stringify(value)}
|
|
2840
|
+
`));
|
|
2841
|
+
});
|
|
2842
|
+
socket.on("error", () => socket.destroy());
|
|
2843
|
+
});
|
|
2844
|
+
server.listen(socketPath, () => chmodSync7(socketPath, 432));
|
|
2845
|
+
return {
|
|
2846
|
+
server,
|
|
2847
|
+
async stop() {
|
|
2848
|
+
await new Promise((resolve2) => server.close(() => resolve2()));
|
|
2849
|
+
await tail;
|
|
2850
|
+
}
|
|
2851
|
+
};
|
|
2852
|
+
}
|
|
2853
|
+
function requestLifecycleAction(claim, socketPath = DEFAULT_LIFECYCLE_HELPER_SOCKET) {
|
|
2854
|
+
return new Promise((resolve2, reject) => {
|
|
2855
|
+
const socket = connect4(socketPath, () => socket.write(`${JSON.stringify({ op: "apply", claim })}
|
|
2856
|
+
`));
|
|
2857
|
+
socket.setTimeout(ACTION_TIMEOUT_MS + 1e4, () => {
|
|
2858
|
+
socket.destroy();
|
|
2859
|
+
reject(new Error("lifecycle helper response timed out"));
|
|
2860
|
+
});
|
|
2861
|
+
let buffer = "";
|
|
2862
|
+
socket.on("data", (chunk) => {
|
|
2863
|
+
buffer += chunk.toString("utf8");
|
|
2864
|
+
const newline = buffer.indexOf(`
|
|
2865
|
+
`);
|
|
2866
|
+
if (newline < 0)
|
|
2867
|
+
return;
|
|
2868
|
+
socket.end();
|
|
2869
|
+
try {
|
|
2870
|
+
const response = JSON.parse(buffer.slice(0, newline));
|
|
2871
|
+
if (response.ok)
|
|
2872
|
+
resolve2(response.evidence);
|
|
2873
|
+
else
|
|
2874
|
+
reject(new Error(response.error.message));
|
|
2875
|
+
} catch (cause) {
|
|
2876
|
+
reject(cause);
|
|
2877
|
+
}
|
|
2878
|
+
});
|
|
2879
|
+
socket.on("error", reject);
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
// src/warp-config.ts
|
|
2884
|
+
import { chmodSync as chmodSync8, mkdirSync as mkdirSync5, renameSync as renameSync3, symlinkSync, unlinkSync as unlinkSync8, writeFileSync as writeFileSync5 } from "fs";
|
|
2885
|
+
var xml = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
2886
|
+
function renderWarpMdm(options) {
|
|
2887
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.organization))
|
|
2888
|
+
throw new Error("WARP organization is invalid");
|
|
2889
|
+
if (!options.clientId.trim() || !options.clientSecret.trim())
|
|
2890
|
+
throw new Error("WARP service-token credentials are empty");
|
|
2891
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2892
|
+
<dict>
|
|
2893
|
+
<key>auth_client_id</key><string>${xml(options.clientId.trim())}</string>
|
|
2894
|
+
<key>auth_client_secret</key><string>${xml(options.clientSecret.trim())}</string>
|
|
2895
|
+
<key>auto_connect</key><integer>1</integer>
|
|
2896
|
+
<key>onboarding</key><false/>
|
|
2897
|
+
<key>organization</key><string>${xml(options.organization)}</string>
|
|
2898
|
+
<key>service_mode</key><string>warp</string>
|
|
2899
|
+
</dict>
|
|
2900
|
+
`;
|
|
2901
|
+
}
|
|
2902
|
+
function materializeWarpMdm(options) {
|
|
2903
|
+
const runtimePath = options.runtimePath ?? "/run/forgezero-warp/mdm.xml";
|
|
2904
|
+
const servicePath = options.servicePath ?? "/var/lib/cloudflare-warp/mdm.xml";
|
|
2905
|
+
mkdirSync5(runtimePath.replace(/\/[^/]+$/, ""), { recursive: true, mode: 448 });
|
|
2906
|
+
mkdirSync5(servicePath.replace(/\/[^/]+$/, ""), { recursive: true, mode: 448 });
|
|
2907
|
+
const next = `${runtimePath}.next`;
|
|
2908
|
+
writeFileSync5(next, renderWarpMdm(options), { mode: 384 });
|
|
2909
|
+
chmodSync8(next, 384);
|
|
2910
|
+
renameSync3(next, runtimePath);
|
|
2911
|
+
try {
|
|
2912
|
+
unlinkSync8(servicePath);
|
|
2913
|
+
} catch (cause) {
|
|
2914
|
+
if (cause.code !== "ENOENT")
|
|
2915
|
+
throw cause;
|
|
2916
|
+
}
|
|
2917
|
+
symlinkSync(runtimePath, servicePath);
|
|
2918
|
+
return { runtimePath, servicePath };
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2740
2921
|
// src/version.ts
|
|
2741
|
-
var VERSION = "0.1.
|
|
2922
|
+
var VERSION = "0.1.23";
|
|
2742
2923
|
|
|
2743
2924
|
// src/index.ts
|
|
2744
2925
|
function loadOrCreateSeed(path) {
|
|
2745
|
-
if (
|
|
2746
|
-
const seed2 = new Uint8Array(Buffer.from(
|
|
2926
|
+
if (existsSync10(path)) {
|
|
2927
|
+
const seed2 = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
|
|
2747
2928
|
if (seed2.length < 32) {
|
|
2748
2929
|
throw new Error(`agent: the seed at ${path} is too short to derive a key from.`);
|
|
2749
2930
|
}
|
|
2750
2931
|
return seed2;
|
|
2751
2932
|
}
|
|
2752
|
-
|
|
2933
|
+
mkdirSync6(dirname4(path), { recursive: true });
|
|
2753
2934
|
const seed = new Uint8Array(randomBytes(32));
|
|
2754
|
-
|
|
2755
|
-
|
|
2935
|
+
writeFileSync6(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
|
|
2936
|
+
chmodSync9(path, 384);
|
|
2756
2937
|
return seed;
|
|
2757
2938
|
}
|
|
2758
2939
|
var DEFAULT_SOCKET_PATH = DEFAULT_SOCKET;
|
|
@@ -2763,9 +2944,9 @@ function loadSeedCredential(name = DEFAULT_SEED_CREDENTIAL, directory = process.
|
|
|
2763
2944
|
if (!directory)
|
|
2764
2945
|
throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the node identity.");
|
|
2765
2946
|
const path = `${directory}/${name}`;
|
|
2766
|
-
if (!
|
|
2947
|
+
if (!existsSync10(path))
|
|
2767
2948
|
throw new Error(`agent: the systemd credential ${name} is missing at ${path}.`);
|
|
2768
|
-
const seed = new Uint8Array(Buffer.from(
|
|
2949
|
+
const seed = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
|
|
2769
2950
|
if (seed.length < 32)
|
|
2770
2951
|
throw new Error(`agent: the systemd credential ${name} is too short to derive a key from.`);
|
|
2771
2952
|
return seed;
|
|
@@ -2775,7 +2956,7 @@ function loadTextCredential(name, directory = process.env.CREDENTIALS_DIRECTORY)
|
|
|
2775
2956
|
throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the credential.");
|
|
2776
2957
|
if (!/^[A-Za-z0-9_.-]+$/.test(name))
|
|
2777
2958
|
throw new Error("agent: invalid systemd credential name.");
|
|
2778
|
-
const value =
|
|
2959
|
+
const value = readFileSync5(`${directory}/${name}`, "utf8").trim();
|
|
2779
2960
|
if (!value)
|
|
2780
2961
|
throw new Error(`agent: systemd credential ${name} is empty.`);
|
|
2781
2962
|
return value;
|
|
@@ -2881,7 +3062,7 @@ if (import.meta.main) {
|
|
|
2881
3062
|
nodeKey: nodeKey2,
|
|
2882
3063
|
keys: keys2,
|
|
2883
3064
|
label: process.env.FZ_NODE_LABEL,
|
|
2884
|
-
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ?
|
|
3065
|
+
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
|
|
2885
3066
|
});
|
|
2886
3067
|
console.log(`[agent] enrolled ${binding2.computeReference} in project ${binding2.projectKey}/${binding2.environmentKey}`);
|
|
2887
3068
|
process.exit(0);
|
|
@@ -2890,7 +3071,7 @@ if (import.meta.main) {
|
|
|
2890
3071
|
const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
|
|
2891
3072
|
if (!profilePath)
|
|
2892
3073
|
throw new Error("metal-helper requires --profile=/absolute/path.json");
|
|
2893
|
-
const profile = JSON.parse(
|
|
3074
|
+
const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
|
|
2894
3075
|
const helper = startMetalHelper({
|
|
2895
3076
|
profile,
|
|
2896
3077
|
socketPath: process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET
|
|
@@ -2908,11 +3089,53 @@ if (import.meta.main) {
|
|
|
2908
3089
|
process.on("SIGINT", () => void stop());
|
|
2909
3090
|
await new Promise(() => {});
|
|
2910
3091
|
}
|
|
3092
|
+
if (command === "lifecycle-helper") {
|
|
3093
|
+
const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
|
|
3094
|
+
if (!profilePath?.startsWith("/"))
|
|
3095
|
+
throw new Error("lifecycle-helper requires --profile=/absolute/path.json");
|
|
3096
|
+
if (typeof process.getuid !== "function" || process.getuid() !== 0) {
|
|
3097
|
+
throw new Error("lifecycle-helper must run as root");
|
|
3098
|
+
}
|
|
3099
|
+
const helper = startLifecycleHelper({
|
|
3100
|
+
profile: loadLifecycleProfile(profilePath),
|
|
3101
|
+
socketPath: process.env.FZ_LIFECYCLE_HELPER_SOCKET ?? DEFAULT_LIFECYCLE_HELPER_SOCKET
|
|
3102
|
+
});
|
|
3103
|
+
console.log(`[lifecycle-helper] listening on ${process.env.FZ_LIFECYCLE_HELPER_SOCKET ?? DEFAULT_LIFECYCLE_HELPER_SOCKET}`);
|
|
3104
|
+
let stopping = false;
|
|
3105
|
+
const stop = async () => {
|
|
3106
|
+
if (stopping)
|
|
3107
|
+
return;
|
|
3108
|
+
stopping = true;
|
|
3109
|
+
await helper.stop();
|
|
3110
|
+
process.exit(0);
|
|
3111
|
+
};
|
|
3112
|
+
process.on("SIGTERM", () => void stop());
|
|
3113
|
+
process.on("SIGINT", () => void stop());
|
|
3114
|
+
await new Promise(() => {});
|
|
3115
|
+
}
|
|
3116
|
+
if (command === "warp-config") {
|
|
3117
|
+
const organization = args.find((arg) => arg.startsWith("--organization="))?.slice("--organization=".length);
|
|
3118
|
+
if (!organization)
|
|
3119
|
+
throw new Error("warp-config requires --organization=<team-name>");
|
|
3120
|
+
if (typeof process.getuid !== "function" || process.getuid() !== 0)
|
|
3121
|
+
throw new Error("warp-config must run as root");
|
|
3122
|
+
const clientIdCredential = process.env.FZ_WARP_CLIENT_ID_CREDENTIAL;
|
|
3123
|
+
const clientSecretCredential = process.env.FZ_WARP_CLIENT_SECRET_CREDENTIAL;
|
|
3124
|
+
if (!clientIdCredential || !clientSecretCredential)
|
|
3125
|
+
throw new Error("warp-config requires systemd service-token credentials");
|
|
3126
|
+
materializeWarpMdm({
|
|
3127
|
+
organization,
|
|
3128
|
+
clientId: loadTextCredential(clientIdCredential),
|
|
3129
|
+
clientSecret: loadTextCredential(clientSecretCredential)
|
|
3130
|
+
});
|
|
3131
|
+
console.log("[warp-config] enrollment materialized in tmpfs");
|
|
3132
|
+
process.exit(0);
|
|
3133
|
+
}
|
|
2911
3134
|
if (command === "metal-isolation") {
|
|
2912
3135
|
const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
|
|
2913
3136
|
if (!profilePath)
|
|
2914
3137
|
throw new Error("metal-isolation requires --profile=/absolute/path.json");
|
|
2915
|
-
const profile = JSON.parse(
|
|
3138
|
+
const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
|
|
2916
3139
|
await applyMetalIsolation(profile);
|
|
2917
3140
|
console.log("[metal-isolation] host and guest cgroup boundaries active");
|
|
2918
3141
|
process.exit(0);
|
|
@@ -2957,7 +3180,7 @@ if (import.meta.main) {
|
|
|
2957
3180
|
if (claimArg !== "-" && !claimArg.startsWith("/")) {
|
|
2958
3181
|
throw new Error("metal-apply claim path must be absolute");
|
|
2959
3182
|
}
|
|
2960
|
-
const raw =
|
|
3183
|
+
const raw = readFileSync5(claimArg === "-" ? "/dev/stdin" : claimArg, "utf8");
|
|
2961
3184
|
if (Buffer.byteLength(raw) > 32 * 1024)
|
|
2962
3185
|
throw new Error("metal-apply claim exceeds 32 KiB");
|
|
2963
3186
|
const claim = JSON.parse(raw);
|
|
@@ -2983,9 +3206,9 @@ if (import.meta.main) {
|
|
|
2983
3206
|
metalHostname: process.env.FZ_METAL_HOSTNAME,
|
|
2984
3207
|
run: (claim) => requestMetalProvision(claim, process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET),
|
|
2985
3208
|
metalPreflight: () => ({
|
|
2986
|
-
snpHost:
|
|
2987
|
-
kvm:
|
|
2988
|
-
helper:
|
|
3209
|
+
snpHost: existsSync10("/dev/sev"),
|
|
3210
|
+
kvm: existsSync10("/dev/kvm"),
|
|
3211
|
+
helper: existsSync10(process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET)
|
|
2989
3212
|
}),
|
|
2990
3213
|
onEvent: (event, detail) => console.log(`[metal-agent] ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
|
|
2991
3214
|
});
|
|
@@ -3032,7 +3255,7 @@ if (import.meta.main) {
|
|
|
3032
3255
|
process.exit(1);
|
|
3033
3256
|
}
|
|
3034
3257
|
}
|
|
3035
|
-
const attestationSource =
|
|
3258
|
+
const attestationSource = existsSync10("/dev/sev-guest") ? createSnpAttestationSource() : undefined;
|
|
3036
3259
|
const running = runAgent({
|
|
3037
3260
|
socketPath: process.env.FZ_SOCKET_PATH ?? DEFAULT_SOCKET_PATH,
|
|
3038
3261
|
seedCredential: process.env.FZ_SEED_CREDENTIAL,
|
|
@@ -3046,8 +3269,8 @@ if (import.meta.main) {
|
|
|
3046
3269
|
const enrolmentStatePath = process.env.FZ_ENROL_STATE_FILE ?? DEFAULT_ENROLMENT_STATE_PATH;
|
|
3047
3270
|
let binding = loadGuestBinding(enrolmentStatePath, nodeKey);
|
|
3048
3271
|
const enrolmentCredential = process.env.FZ_ENROL_TOKEN_CREDENTIAL;
|
|
3049
|
-
const enrolmentCredentialAvailable = Boolean(enrolmentCredential && process.env.CREDENTIALS_DIRECTORY &&
|
|
3050
|
-
const legacyEnrolmentFileAvailable = Boolean(process.env.FZ_ENROL_TOKEN_FILE &&
|
|
3272
|
+
const enrolmentCredentialAvailable = Boolean(enrolmentCredential && process.env.CREDENTIALS_DIRECTORY && existsSync10(`${process.env.CREDENTIALS_DIRECTORY}/${enrolmentCredential}`));
|
|
3273
|
+
const legacyEnrolmentFileAvailable = Boolean(process.env.FZ_ENROL_TOKEN_FILE && existsSync10(process.env.FZ_ENROL_TOKEN_FILE));
|
|
3051
3274
|
if (!binding && (enrolmentCredentialAvailable || legacyEnrolmentFileAvailable) && process.env.FZ_API) {
|
|
3052
3275
|
binding = await enrolGuestIdentity({
|
|
3053
3276
|
apiUrl: process.env.FZ_API,
|
|
@@ -3057,7 +3280,7 @@ if (import.meta.main) {
|
|
|
3057
3280
|
nodeKey,
|
|
3058
3281
|
keys,
|
|
3059
3282
|
label: process.env.FZ_NODE_LABEL,
|
|
3060
|
-
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ?
|
|
3283
|
+
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined
|
|
3061
3284
|
});
|
|
3062
3285
|
console.log(`[agent] enrolled ${binding.computeReference} in project ${binding.projectKey}/${binding.environmentKey}`);
|
|
3063
3286
|
}
|
|
@@ -3095,6 +3318,19 @@ if (import.meta.main) {
|
|
|
3095
3318
|
}) : undefined;
|
|
3096
3319
|
if (attestationLoop)
|
|
3097
3320
|
console.log("[agent] periodic SEV-SNP attestation enabled");
|
|
3321
|
+
const migrationEnabled = process.env.FZ_MIGRATION_PULL === "true";
|
|
3322
|
+
if (migrationEnabled && (!binding || !nodeApiUrl)) {
|
|
3323
|
+
throw new Error("agent: outbound lifecycle work requires a persisted compute enrolment binding");
|
|
3324
|
+
}
|
|
3325
|
+
const migrationPull = migrationEnabled ? startMigrationPull({
|
|
3326
|
+
apiUrl: nodeApiUrl,
|
|
3327
|
+
nodeKey,
|
|
3328
|
+
keys,
|
|
3329
|
+
run: (claim) => requestLifecycleAction(claim, process.env.FZ_LIFECYCLE_HELPER_SOCKET ?? DEFAULT_LIFECYCLE_HELPER_SOCKET),
|
|
3330
|
+
onEvent: (event, detail) => console.log(`[agent] migration ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
|
|
3331
|
+
}) : undefined;
|
|
3332
|
+
if (migrationPull)
|
|
3333
|
+
console.log("[agent] PQ-authenticated outbound lifecycle claims enabled");
|
|
3098
3334
|
const systemdDeploymentSecrets = createSystemdDeploymentSecrets(process.env.FZ_DEPLOY_SYSTEMD_SECRETS);
|
|
3099
3335
|
const deploymentSecrets = secretCache ? {
|
|
3100
3336
|
async get(name) {
|
|
@@ -3180,8 +3416,9 @@ if (import.meta.main) {
|
|
|
3180
3416
|
const pullDrain = pull?.stop() ?? Promise.resolve();
|
|
3181
3417
|
const vaultDrain = vaultSync?.stop() ?? Promise.resolve();
|
|
3182
3418
|
const attestationDrain = attestationLoop?.stop() ?? Promise.resolve();
|
|
3419
|
+
const migrationDrain = migrationPull?.stop() ?? Promise.resolve();
|
|
3183
3420
|
const pullDrained = pull ? await settleWithin(pullDrain, remaining()) : true;
|
|
3184
|
-
const backgroundDrained = await settleWithin(Promise.all([vaultDrain, attestationDrain]), remaining());
|
|
3421
|
+
const backgroundDrained = await settleWithin(Promise.all([vaultDrain, attestationDrain, migrationDrain]), remaining());
|
|
3185
3422
|
const managerReports = await Promise.all([...new Set(managers.values())].map((manager) => manager.stop(remaining())));
|
|
3186
3423
|
const socketClosed = await closeServerWithin(server, remaining());
|
|
3187
3424
|
const timedOut = managerReports.some((report) => report.timedOut);
|
|
@@ -3198,7 +3435,7 @@ if (import.meta.main) {
|
|
|
3198
3435
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
3199
3436
|
} else {
|
|
3200
3437
|
console.log("[agent] signing/vault mode only; deployment root and pull are not configured");
|
|
3201
|
-
if (vaultSync || attestationLoop) {
|
|
3438
|
+
if (vaultSync || attestationLoop || migrationPull) {
|
|
3202
3439
|
let stopping = false;
|
|
3203
3440
|
const stop = async (signal) => {
|
|
3204
3441
|
if (stopping)
|
|
@@ -3210,7 +3447,8 @@ if (import.meta.main) {
|
|
|
3210
3447
|
console.log(`[agent] ${signal}: stopping background intake and draining`);
|
|
3211
3448
|
const backgroundDrained = await settleWithin(Promise.all([
|
|
3212
3449
|
vaultSync?.stop() ?? Promise.resolve(),
|
|
3213
|
-
attestationLoop?.stop() ?? Promise.resolve()
|
|
3450
|
+
attestationLoop?.stop() ?? Promise.resolve(),
|
|
3451
|
+
migrationPull?.stop() ?? Promise.resolve()
|
|
3214
3452
|
]), remaining());
|
|
3215
3453
|
const socketClosed = await closeServerWithin(server, remaining());
|
|
3216
3454
|
console.log(`[agent] drain ${JSON.stringify({ backgroundDrained, socketClosed })}`);
|
|
@@ -3223,34 +3461,43 @@ if (import.meta.main) {
|
|
|
3223
3461
|
}
|
|
3224
3462
|
export {
|
|
3225
3463
|
validateMetalProfile,
|
|
3464
|
+
validateLifecycleProfile,
|
|
3226
3465
|
tenantNodeApiUrl,
|
|
3227
3466
|
startProvisioningPull,
|
|
3228
3467
|
startNodeVaultSync,
|
|
3229
3468
|
startNodeAttestation,
|
|
3469
|
+
startMigrationPull,
|
|
3230
3470
|
startMetalHelper,
|
|
3471
|
+
startLifecycleHelper,
|
|
3231
3472
|
startDeploymentRunner,
|
|
3232
3473
|
startDeploymentPull,
|
|
3233
3474
|
startControlServer,
|
|
3234
3475
|
startAgent,
|
|
3235
3476
|
runAgent,
|
|
3236
3477
|
requestMetalProvision,
|
|
3478
|
+
requestLifecycleAction,
|
|
3237
3479
|
requestDeploymentCommand,
|
|
3238
3480
|
requestControl,
|
|
3481
|
+
renderWarpMdm,
|
|
3239
3482
|
removeMetalGuest,
|
|
3240
3483
|
pullProvisioningOnce,
|
|
3484
|
+
pullMigrationOnce,
|
|
3241
3485
|
pullDeploymentOnce,
|
|
3242
3486
|
provisionMetalGuest,
|
|
3243
3487
|
projectVaultCoordinate,
|
|
3244
3488
|
projectVaultCacheKey,
|
|
3245
3489
|
metalHousekeepingDropIn,
|
|
3246
3490
|
metalGuestSliceUnit,
|
|
3491
|
+
materializeWarpMdm,
|
|
3247
3492
|
loadTextCredential,
|
|
3248
3493
|
loadSeedCredential,
|
|
3249
3494
|
loadOrCreateSeed,
|
|
3495
|
+
loadLifecycleProfile,
|
|
3250
3496
|
loadGuestBinding,
|
|
3251
3497
|
handleRequest,
|
|
3252
3498
|
handleApplicationRequest,
|
|
3253
3499
|
guestNameFor,
|
|
3500
|
+
executeLifecycleAction,
|
|
3254
3501
|
enrolGuestIdentity,
|
|
3255
3502
|
createSystemdDeploymentSecrets,
|
|
3256
3503
|
createSnpAttestationSource,
|
|
@@ -3270,6 +3517,7 @@ export {
|
|
|
3270
3517
|
DEFAULT_SEED_PATH,
|
|
3271
3518
|
DEFAULT_SEED_CREDENTIAL,
|
|
3272
3519
|
DEFAULT_METAL_HELPER_SOCKET,
|
|
3520
|
+
DEFAULT_LIFECYCLE_HELPER_SOCKET,
|
|
3273
3521
|
DEFAULT_ENROLMENT_STATE_PATH,
|
|
3274
3522
|
DEFAULT_DEPLOYMENT_RUNNER_SOCKET,
|
|
3275
3523
|
DEFAULT_CONTROL_SOCKET,
|