@forgezero/agent 0.1.22 → 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/fz-agent.js +471 -218
- 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 +10 -2
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";
|
|
@@ -1373,6 +1373,132 @@ function startProvisioningPull(options) {
|
|
|
1373
1373
|
};
|
|
1374
1374
|
}
|
|
1375
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
|
+
|
|
1376
1502
|
// src/metal-helper-socket.ts
|
|
1377
1503
|
import { chmodSync as chmodSync5, existsSync as existsSync6, unlinkSync as unlinkSync5 } from "fs";
|
|
1378
1504
|
import { connect as connect2, createServer as createServer3 } from "net";
|
|
@@ -1489,55 +1615,6 @@ function shapeEgressUnitDirectives(tap, guaranteedMbps, burstMbps) {
|
|
|
1489
1615
|
];
|
|
1490
1616
|
}
|
|
1491
1617
|
|
|
1492
|
-
// src/provision.ts
|
|
1493
|
-
var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
1494
|
-
var DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
1495
|
-
var VAULT_GROUP = "forgezero-vault";
|
|
1496
|
-
var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
1497
|
-
function deploymentRunnerUnit(options) {
|
|
1498
|
-
const bin = options.binPath ?? "fz-agent";
|
|
1499
|
-
const root = options.deployRoot ?? "/opt/forgezero";
|
|
1500
|
-
const agentUser = options.user ?? "forgezero-agent";
|
|
1501
|
-
return `[Unit]
|
|
1502
|
-
Description=ForgeZero credential-free project command runner
|
|
1503
|
-
Documentation=https://www.forgezero.net/docs/agent
|
|
1504
|
-
|
|
1505
|
-
[Service]
|
|
1506
|
-
Type=notify
|
|
1507
|
-
NotifyAccess=all
|
|
1508
|
-
User=${DEPLOYMENT_RUNNER_USER}
|
|
1509
|
-
Group=${DEPLOYMENT_GROUP}
|
|
1510
|
-
Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
|
|
1511
|
-
RuntimeDirectory=forgezero-deploy
|
|
1512
|
-
RuntimeDirectoryMode=0710
|
|
1513
|
-
ExecStartPre=+/usr/bin/install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0710 /run/forgezero-deploy
|
|
1514
|
-
ExecStartPre=+/usr/bin/rm -f ${DEPLOYMENT_RUNNER_SOCKET}
|
|
1515
|
-
ExecStart=${bin} deploy-runner --root=${root} --home=${root}/runner-home
|
|
1516
|
-
ExecStartPost=+/usr/bin/chown ${agentUser}:${agentUser} ${DEPLOYMENT_RUNNER_SOCKET}
|
|
1517
|
-
ExecStartPost=+/usr/bin/chmod 0600 ${DEPLOYMENT_RUNNER_SOCKET}
|
|
1518
|
-
ExecStartPost=+/usr/bin/chown root:${VAULT_GROUP} /run/forgezero-deploy
|
|
1519
|
-
ExecStopPost=+/usr/bin/rm -f ${DEPLOYMENT_RUNNER_SOCKET}
|
|
1520
|
-
Restart=always
|
|
1521
|
-
RestartSec=2
|
|
1522
|
-
UMask=0007
|
|
1523
|
-
LimitCORE=0
|
|
1524
|
-
NoNewPrivileges=false
|
|
1525
|
-
PrivateTmp=true
|
|
1526
|
-
ProtectSystem=strict
|
|
1527
|
-
ProtectHome=true
|
|
1528
|
-
ProtectKernelTunables=true
|
|
1529
|
-
ProtectKernelModules=true
|
|
1530
|
-
ProtectControlGroups=true
|
|
1531
|
-
RestrictRealtime=true
|
|
1532
|
-
MemoryDenyWriteExecute=true
|
|
1533
|
-
LockPersonality=true
|
|
1534
|
-
ReadWritePaths=${root}/releases ${root}/runner-home
|
|
1535
|
-
|
|
1536
|
-
[Install]
|
|
1537
|
-
WantedBy=multi-user.target
|
|
1538
|
-
`;
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
1618
|
// src/ubuntu.ts
|
|
1542
1619
|
var SUPPORTED_GUEST_IMAGE = Object.freeze({
|
|
1543
1620
|
key: "ubuntu-resolute-20260731",
|
|
@@ -1718,7 +1795,7 @@ var yamlFile = (path, content, permissions) => ` - path: ${JSON.stringify(path)
|
|
|
1718
1795
|
encoding: b64
|
|
1719
1796
|
content: ${base64(content)}
|
|
1720
1797
|
`;
|
|
1721
|
-
function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true) {
|
|
1798
|
+
function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true, nodeLabel = "compute") {
|
|
1722
1799
|
const agentBun = "/usr/local/lib/forgezero/bun";
|
|
1723
1800
|
const attestationSetup = attested ? `# The report device is not part of the encryption path, so a guest can appear
|
|
1724
1801
|
# healthy and encrypted while attestation is silently impossible. Install and
|
|
@@ -1732,26 +1809,7 @@ printf 'sev-guest
|
|
|
1732
1809
|
` : "";
|
|
1733
1810
|
return `#!/usr/bin/env bash
|
|
1734
1811
|
set -Eeuo pipefail
|
|
1735
|
-
${attestationSetup}
|
|
1736
|
-
useradd --system --no-create-home --shell /usr/sbin/nologin forgezero-agent 2>/dev/null || true
|
|
1737
|
-
usermod -g ${VAULT_GROUP} forgezero-agent
|
|
1738
|
-
groupadd --system ${DEPLOYMENT_GROUP} 2>/dev/null || true
|
|
1739
|
-
useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} 2>/dev/null || true
|
|
1740
|
-
usermod -a -G ${DEPLOYMENT_GROUP} forgezero-agent
|
|
1741
|
-
install -d -o root -g root -m 0700 /etc/forgezero/creds
|
|
1742
|
-
install -d -o root -g root -m 0755 /etc/forgezero/git
|
|
1743
|
-
install -d -o forgezero-agent -g forgezero-agent -m 0700 /var/lib/forgezero
|
|
1744
|
-
install -d -o root -g root -m 0755 /opt/forgezero
|
|
1745
|
-
install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 /opt/forgezero/releases
|
|
1746
|
-
install -d -o forgezero-agent -g forgezero-agent -m 0700 /opt/forgezero/cache /opt/forgezero/home
|
|
1747
|
-
install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 /opt/forgezero/runner-home /opt/forgezero/runner-home/cache
|
|
1748
|
-
${hasEnrolment ? `if [[ -s /run/forgezero-enrol-token ]]; then
|
|
1749
|
-
systemd-creds encrypt --name=enrol-token /run/forgezero-enrol-token /var/lib/forgezero/enrol-token.cred
|
|
1750
|
-
rm -f /run/forgezero-enrol-token
|
|
1751
|
-
fi
|
|
1752
|
-
chown root:root /var/lib/forgezero/enrol-token.cred
|
|
1753
|
-
chmod 0400 /var/lib/forgezero/enrol-token.cred
|
|
1754
|
-
` : ""}if [[ ! -x /usr/local/bin/bun ]]; then
|
|
1812
|
+
${attestationSetup}if [[ ! -x /usr/local/bin/bun ]]; then
|
|
1755
1813
|
curl -fsSL https://bun.sh/install -o /run/fz-bun-install
|
|
1756
1814
|
printf '%s %s
|
|
1757
1815
|
' '${profile.bunInstallerSha256}' /run/fz-bun-install | sha256sum -c -
|
|
@@ -1759,132 +1817,16 @@ chmod 0400 /var/lib/forgezero/enrol-token.cred
|
|
|
1759
1817
|
install -m 0755 ${agentBun}/bin/bun /usr/local/bin/bun
|
|
1760
1818
|
rm -f /run/fz-bun-install
|
|
1761
1819
|
fi
|
|
1762
|
-
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
|
|
1763
1821
|
env BUN_INSTALL=${agentBun} /usr/local/bin/bun add -g --no-cache --force @forgezero/agent@${profile.agentVersion}
|
|
1764
1822
|
ln -sfn ${agentBun}/bin/fz-agent /usr/local/bin/fz-agent
|
|
1765
1823
|
fi
|
|
1766
|
-
|
|
1767
|
-
umask 077
|
|
1768
|
-
openssl rand -base64 32 | tr '+/' '-_' | tr -d '=
|
|
1769
|
-
' >/run/fz-agent-seed
|
|
1770
|
-
systemd-creds encrypt --name=agent-seed /run/fz-agent-seed /etc/forgezero/creds/agent-seed.cred
|
|
1771
|
-
rm -f /run/fz-agent-seed
|
|
1772
|
-
fi
|
|
1773
|
-
chmod 0400 /etc/forgezero/creds/agent-seed.cred
|
|
1774
|
-
if [[ ! -s /etc/forgezero/creds/git-deploy-key.cred ]]; then
|
|
1775
|
-
umask 077
|
|
1776
|
-
rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
|
|
1777
|
-
ssh-keygen -q -t ed25519 -N '' -C forgezero-compute -f /run/fz-git-deploy-key
|
|
1778
|
-
systemd-creds encrypt --name=git-deploy-key /run/fz-git-deploy-key /etc/forgezero/creds/git-deploy-key.cred
|
|
1779
|
-
install -o root -g root -m 0444 /run/fz-git-deploy-key.pub /etc/forgezero/git/deploy.pub
|
|
1780
|
-
rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
|
|
1781
|
-
fi
|
|
1782
|
-
if [[ ! -s /etc/forgezero/git/deploy.pub ]]; then
|
|
1783
|
-
systemd-creds decrypt --name=git-deploy-key /etc/forgezero/creds/git-deploy-key.cred /run/fz-git-deploy-key
|
|
1784
|
-
ssh-keygen -y -f /run/fz-git-deploy-key | sed 's/$/ forgezero-compute/' >/run/fz-git-deploy-key.pub
|
|
1785
|
-
install -o root -g root -m 0444 /run/fz-git-deploy-key.pub /etc/forgezero/git/deploy.pub
|
|
1786
|
-
rm -f /run/fz-git-deploy-key /run/fz-git-deploy-key.pub
|
|
1787
|
-
fi
|
|
1788
|
-
chmod 0400 /etc/forgezero/creds/git-deploy-key.cred
|
|
1789
|
-
systemctl daemon-reload
|
|
1790
|
-
systemctl disable --now forgezero-deploy-runner.socket 2>/dev/null || true
|
|
1791
|
-
rm -f /etc/systemd/system/forgezero-deploy-runner.socket
|
|
1792
|
-
systemctl daemon-reload
|
|
1793
|
-
systemctl enable --now forgezero-deploy-runner.service forgezero-agent.service
|
|
1794
|
-
`;
|
|
1795
|
-
}
|
|
1796
|
-
function guestAgentUnit(profile, name, attested = Boolean(profile.confidential), pull = true) {
|
|
1797
|
-
const attestationPrepare = attested ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
|
|
1798
|
-
ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
|
|
1799
|
-
` : "";
|
|
1800
|
-
const attestationDevice = attested ? `DevicePolicy=closed
|
|
1801
|
-
DeviceAllow=/dev/sev-guest rw
|
|
1802
|
-
` : "";
|
|
1803
|
-
return `[Unit]
|
|
1804
|
-
Description=ForgeZero compute agent (${name})
|
|
1805
|
-
After=network-online.target forgezero-deploy-runner.service
|
|
1806
|
-
Wants=network-online.target
|
|
1807
|
-
Requires=forgezero-deploy-runner.service
|
|
1808
|
-
|
|
1809
|
-
[Service]
|
|
1810
|
-
Type=simple
|
|
1811
|
-
User=forgezero-agent
|
|
1812
|
-
Group=${VAULT_GROUP}
|
|
1813
|
-
SupplementaryGroups=${DEPLOYMENT_GROUP}
|
|
1814
|
-
LoadCredentialEncrypted=agent-seed:/etc/forgezero/creds/agent-seed.cred
|
|
1815
|
-
LoadCredentialEncrypted=git-deploy-key:/etc/forgezero/creds/git-deploy-key.cred
|
|
1816
|
-
Environment=FZ_SEED_CREDENTIAL=agent-seed
|
|
1817
|
-
Environment=FZ_GIT_PUBLIC_KEY_FILE=/etc/forgezero/git/deploy.pub
|
|
1818
|
-
Environment=FZ_API=${profile.apiUrl}
|
|
1819
|
-
Environment=FZ_ENROL_STATE_FILE=/var/lib/forgezero/enrolment.json
|
|
1820
|
-
Environment=FZ_NODE_LABEL=${name}
|
|
1821
|
-
Environment=FZ_SOCKET_PATH=/run/forgezero/vault.sock
|
|
1822
|
-
Environment=FZ_DEPLOY_ROOT=/opt/forgezero
|
|
1823
|
-
Environment=FZ_DEPLOY_PULL=${pull ? "true" : "false"}
|
|
1824
|
-
Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
|
|
1825
|
-
Environment=HOME=/opt/forgezero/home
|
|
1826
|
-
${attestationPrepare}ExecStart=/usr/local/bin/fz-agent
|
|
1827
|
-
Restart=on-failure
|
|
1828
|
-
RestartSec=5
|
|
1829
|
-
RuntimeDirectory=forgezero
|
|
1830
|
-
RuntimeDirectoryMode=0750
|
|
1831
|
-
UMask=0007
|
|
1832
|
-
LimitCORE=0
|
|
1833
|
-
NoNewPrivileges=true
|
|
1834
|
-
PrivateTmp=true
|
|
1835
|
-
ProtectSystem=strict
|
|
1836
|
-
ProtectHome=true
|
|
1837
|
-
ReadWritePaths=/var/lib/forgezero /opt/forgezero
|
|
1838
|
-
${attestationDevice}
|
|
1839
|
-
|
|
1840
|
-
[Install]
|
|
1841
|
-
WantedBy=multi-user.target
|
|
1842
|
-
`;
|
|
1843
|
-
}
|
|
1844
|
-
function guestEnrolmentDropIn() {
|
|
1845
|
-
return `[Service]
|
|
1846
|
-
LoadCredentialEncrypted=enrol-token:/var/lib/forgezero/enrol-token.cred
|
|
1847
|
-
Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
|
|
1848
|
-
`;
|
|
1849
|
-
}
|
|
1850
|
-
function guestEnrolmentCleanupScript() {
|
|
1851
|
-
return `#!/usr/bin/env bash
|
|
1852
|
-
set -Eeuo pipefail
|
|
1853
|
-
for _ in $(seq 1 180); do
|
|
1854
|
-
[[ -s /var/lib/forgezero/enrolment.json ]] && break
|
|
1855
|
-
sleep 1
|
|
1856
|
-
done
|
|
1857
|
-
[[ -s /var/lib/forgezero/enrolment.json ]] || { echo 'guest enrolment did not become durable' >&2; exit 1; }
|
|
1858
|
-
rm -f /var/lib/forgezero/enrol-token.cred
|
|
1859
|
-
rm -f /etc/systemd/system/forgezero-agent.service.d/enrolment.conf
|
|
1860
|
-
systemctl daemon-reload
|
|
1861
|
-
systemctl disable forgezero-enrolment-cleanup.service
|
|
1862
|
-
`;
|
|
1863
|
-
}
|
|
1864
|
-
function guestEnrolmentCleanupUnit() {
|
|
1865
|
-
return `[Unit]
|
|
1866
|
-
Description=Remove the consumed ForgeZero guest enrolment credential
|
|
1867
|
-
After=forgezero-agent.service
|
|
1868
|
-
Requires=forgezero-agent.service
|
|
1869
|
-
ConditionPathExists=/var/lib/forgezero/enrol-token.cred
|
|
1870
|
-
|
|
1871
|
-
[Service]
|
|
1872
|
-
Type=oneshot
|
|
1873
|
-
ExecStart=/usr/local/sbin/forgezero-enrolment-cleanup
|
|
1874
|
-
TimeoutStartSec=4min
|
|
1875
|
-
|
|
1876
|
-
[Install]
|
|
1877
|
-
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" : ""}
|
|
1878
1825
|
`;
|
|
1879
1826
|
}
|
|
1880
1827
|
function cloudInit(profile, claim, manifest) {
|
|
1881
1828
|
const enrolled = Boolean(claim.enrolment);
|
|
1882
|
-
const bootstrap = guestBootstrapScript(profile, claim.spec.confidential, enrolled);
|
|
1883
|
-
const agentUnit = guestAgentUnit(profile, manifest.name, claim.spec.confidential, enrolled);
|
|
1884
|
-
const enrolmentDropIn = guestEnrolmentDropIn();
|
|
1885
|
-
const cleanupScript = guestEnrolmentCleanupScript();
|
|
1886
|
-
const cleanupUnit = guestEnrolmentCleanupUnit();
|
|
1887
|
-
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);
|
|
1888
1830
|
const access = claim.access;
|
|
1889
1831
|
const users = access ? `users:
|
|
1890
1832
|
- default
|
|
@@ -1896,8 +1838,8 @@ function cloudInit(profile, claim, manifest) {
|
|
|
1896
1838
|
${access.sshPublicKeys.map((key) => ` - ${JSON.stringify(key)}`).join(`
|
|
1897
1839
|
`)}
|
|
1898
1840
|
` : "";
|
|
1899
|
-
const enrolmentFiles = enrolled ?
|
|
1900
|
-
`, "0600")
|
|
1841
|
+
const enrolmentFiles = enrolled ? yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
|
|
1842
|
+
`, "0600") : "";
|
|
1901
1843
|
return {
|
|
1902
1844
|
userData: `#cloud-config
|
|
1903
1845
|
package_update: true
|
|
@@ -1906,10 +1848,8 @@ ${users}disable_root: true
|
|
|
1906
1848
|
# every dynamically claimed repository must be cloneable on a clean image.
|
|
1907
1849
|
packages: [curl, ca-certificates, openssl, openssh-client, git, unzip${claim.spec.confidential ? ", python3" : ""}]
|
|
1908
1850
|
write_files:
|
|
1909
|
-
${enrolmentFiles}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}
|
|
1851
|
+
${enrolmentFiles}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}runcmd:
|
|
1910
1852
|
- [ bash, /usr/local/sbin/forgezero-guest-bootstrap ]
|
|
1911
|
-
${enrolled ? ` - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
|
|
1912
|
-
` : ""}
|
|
1913
1853
|
`,
|
|
1914
1854
|
metaData: `instance-id: ${manifest.name}-${claim.attempt}
|
|
1915
1855
|
local-hostname: ${manifest.name}
|
|
@@ -2732,22 +2672,268 @@ async function closeServerWithin(server, timeoutMs) {
|
|
|
2732
2672
|
return closed;
|
|
2733
2673
|
}
|
|
2734
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
|
+
|
|
2735
2921
|
// src/version.ts
|
|
2736
|
-
var VERSION = "0.1.
|
|
2922
|
+
var VERSION = "0.1.23";
|
|
2737
2923
|
|
|
2738
2924
|
// src/index.ts
|
|
2739
2925
|
function loadOrCreateSeed(path) {
|
|
2740
|
-
if (
|
|
2741
|
-
const seed2 = new Uint8Array(Buffer.from(
|
|
2926
|
+
if (existsSync10(path)) {
|
|
2927
|
+
const seed2 = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
|
|
2742
2928
|
if (seed2.length < 32) {
|
|
2743
2929
|
throw new Error(`agent: the seed at ${path} is too short to derive a key from.`);
|
|
2744
2930
|
}
|
|
2745
2931
|
return seed2;
|
|
2746
2932
|
}
|
|
2747
|
-
|
|
2933
|
+
mkdirSync6(dirname4(path), { recursive: true });
|
|
2748
2934
|
const seed = new Uint8Array(randomBytes(32));
|
|
2749
|
-
|
|
2750
|
-
|
|
2935
|
+
writeFileSync6(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
|
|
2936
|
+
chmodSync9(path, 384);
|
|
2751
2937
|
return seed;
|
|
2752
2938
|
}
|
|
2753
2939
|
var DEFAULT_SOCKET_PATH = DEFAULT_SOCKET;
|
|
@@ -2758,9 +2944,9 @@ function loadSeedCredential(name = DEFAULT_SEED_CREDENTIAL, directory = process.
|
|
|
2758
2944
|
if (!directory)
|
|
2759
2945
|
throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the node identity.");
|
|
2760
2946
|
const path = `${directory}/${name}`;
|
|
2761
|
-
if (!
|
|
2947
|
+
if (!existsSync10(path))
|
|
2762
2948
|
throw new Error(`agent: the systemd credential ${name} is missing at ${path}.`);
|
|
2763
|
-
const seed = new Uint8Array(Buffer.from(
|
|
2949
|
+
const seed = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
|
|
2764
2950
|
if (seed.length < 32)
|
|
2765
2951
|
throw new Error(`agent: the systemd credential ${name} is too short to derive a key from.`);
|
|
2766
2952
|
return seed;
|
|
@@ -2770,7 +2956,7 @@ function loadTextCredential(name, directory = process.env.CREDENTIALS_DIRECTORY)
|
|
|
2770
2956
|
throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the credential.");
|
|
2771
2957
|
if (!/^[A-Za-z0-9_.-]+$/.test(name))
|
|
2772
2958
|
throw new Error("agent: invalid systemd credential name.");
|
|
2773
|
-
const value =
|
|
2959
|
+
const value = readFileSync5(`${directory}/${name}`, "utf8").trim();
|
|
2774
2960
|
if (!value)
|
|
2775
2961
|
throw new Error(`agent: systemd credential ${name} is empty.`);
|
|
2776
2962
|
return value;
|
|
@@ -2876,7 +3062,7 @@ if (import.meta.main) {
|
|
|
2876
3062
|
nodeKey: nodeKey2,
|
|
2877
3063
|
keys: keys2,
|
|
2878
3064
|
label: process.env.FZ_NODE_LABEL,
|
|
2879
|
-
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
|
|
2880
3066
|
});
|
|
2881
3067
|
console.log(`[agent] enrolled ${binding2.computeReference} in project ${binding2.projectKey}/${binding2.environmentKey}`);
|
|
2882
3068
|
process.exit(0);
|
|
@@ -2885,7 +3071,7 @@ if (import.meta.main) {
|
|
|
2885
3071
|
const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
|
|
2886
3072
|
if (!profilePath)
|
|
2887
3073
|
throw new Error("metal-helper requires --profile=/absolute/path.json");
|
|
2888
|
-
const profile = JSON.parse(
|
|
3074
|
+
const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
|
|
2889
3075
|
const helper = startMetalHelper({
|
|
2890
3076
|
profile,
|
|
2891
3077
|
socketPath: process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET
|
|
@@ -2903,11 +3089,53 @@ if (import.meta.main) {
|
|
|
2903
3089
|
process.on("SIGINT", () => void stop());
|
|
2904
3090
|
await new Promise(() => {});
|
|
2905
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
|
+
}
|
|
2906
3134
|
if (command === "metal-isolation") {
|
|
2907
3135
|
const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
|
|
2908
3136
|
if (!profilePath)
|
|
2909
3137
|
throw new Error("metal-isolation requires --profile=/absolute/path.json");
|
|
2910
|
-
const profile = JSON.parse(
|
|
3138
|
+
const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
|
|
2911
3139
|
await applyMetalIsolation(profile);
|
|
2912
3140
|
console.log("[metal-isolation] host and guest cgroup boundaries active");
|
|
2913
3141
|
process.exit(0);
|
|
@@ -2952,7 +3180,7 @@ if (import.meta.main) {
|
|
|
2952
3180
|
if (claimArg !== "-" && !claimArg.startsWith("/")) {
|
|
2953
3181
|
throw new Error("metal-apply claim path must be absolute");
|
|
2954
3182
|
}
|
|
2955
|
-
const raw =
|
|
3183
|
+
const raw = readFileSync5(claimArg === "-" ? "/dev/stdin" : claimArg, "utf8");
|
|
2956
3184
|
if (Buffer.byteLength(raw) > 32 * 1024)
|
|
2957
3185
|
throw new Error("metal-apply claim exceeds 32 KiB");
|
|
2958
3186
|
const claim = JSON.parse(raw);
|
|
@@ -2978,9 +3206,9 @@ if (import.meta.main) {
|
|
|
2978
3206
|
metalHostname: process.env.FZ_METAL_HOSTNAME,
|
|
2979
3207
|
run: (claim) => requestMetalProvision(claim, process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET),
|
|
2980
3208
|
metalPreflight: () => ({
|
|
2981
|
-
snpHost:
|
|
2982
|
-
kvm:
|
|
2983
|
-
helper:
|
|
3209
|
+
snpHost: existsSync10("/dev/sev"),
|
|
3210
|
+
kvm: existsSync10("/dev/kvm"),
|
|
3211
|
+
helper: existsSync10(process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET)
|
|
2984
3212
|
}),
|
|
2985
3213
|
onEvent: (event, detail) => console.log(`[metal-agent] ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
|
|
2986
3214
|
});
|
|
@@ -3027,7 +3255,7 @@ if (import.meta.main) {
|
|
|
3027
3255
|
process.exit(1);
|
|
3028
3256
|
}
|
|
3029
3257
|
}
|
|
3030
|
-
const attestationSource =
|
|
3258
|
+
const attestationSource = existsSync10("/dev/sev-guest") ? createSnpAttestationSource() : undefined;
|
|
3031
3259
|
const running = runAgent({
|
|
3032
3260
|
socketPath: process.env.FZ_SOCKET_PATH ?? DEFAULT_SOCKET_PATH,
|
|
3033
3261
|
seedCredential: process.env.FZ_SEED_CREDENTIAL,
|
|
@@ -3041,8 +3269,8 @@ if (import.meta.main) {
|
|
|
3041
3269
|
const enrolmentStatePath = process.env.FZ_ENROL_STATE_FILE ?? DEFAULT_ENROLMENT_STATE_PATH;
|
|
3042
3270
|
let binding = loadGuestBinding(enrolmentStatePath, nodeKey);
|
|
3043
3271
|
const enrolmentCredential = process.env.FZ_ENROL_TOKEN_CREDENTIAL;
|
|
3044
|
-
const enrolmentCredentialAvailable = Boolean(enrolmentCredential && process.env.CREDENTIALS_DIRECTORY &&
|
|
3045
|
-
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));
|
|
3046
3274
|
if (!binding && (enrolmentCredentialAvailable || legacyEnrolmentFileAvailable) && process.env.FZ_API) {
|
|
3047
3275
|
binding = await enrolGuestIdentity({
|
|
3048
3276
|
apiUrl: process.env.FZ_API,
|
|
@@ -3052,7 +3280,7 @@ if (import.meta.main) {
|
|
|
3052
3280
|
nodeKey,
|
|
3053
3281
|
keys,
|
|
3054
3282
|
label: process.env.FZ_NODE_LABEL,
|
|
3055
|
-
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
|
|
3056
3284
|
});
|
|
3057
3285
|
console.log(`[agent] enrolled ${binding.computeReference} in project ${binding.projectKey}/${binding.environmentKey}`);
|
|
3058
3286
|
}
|
|
@@ -3090,6 +3318,19 @@ if (import.meta.main) {
|
|
|
3090
3318
|
}) : undefined;
|
|
3091
3319
|
if (attestationLoop)
|
|
3092
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");
|
|
3093
3334
|
const systemdDeploymentSecrets = createSystemdDeploymentSecrets(process.env.FZ_DEPLOY_SYSTEMD_SECRETS);
|
|
3094
3335
|
const deploymentSecrets = secretCache ? {
|
|
3095
3336
|
async get(name) {
|
|
@@ -3175,8 +3416,9 @@ if (import.meta.main) {
|
|
|
3175
3416
|
const pullDrain = pull?.stop() ?? Promise.resolve();
|
|
3176
3417
|
const vaultDrain = vaultSync?.stop() ?? Promise.resolve();
|
|
3177
3418
|
const attestationDrain = attestationLoop?.stop() ?? Promise.resolve();
|
|
3419
|
+
const migrationDrain = migrationPull?.stop() ?? Promise.resolve();
|
|
3178
3420
|
const pullDrained = pull ? await settleWithin(pullDrain, remaining()) : true;
|
|
3179
|
-
const backgroundDrained = await settleWithin(Promise.all([vaultDrain, attestationDrain]), remaining());
|
|
3421
|
+
const backgroundDrained = await settleWithin(Promise.all([vaultDrain, attestationDrain, migrationDrain]), remaining());
|
|
3180
3422
|
const managerReports = await Promise.all([...new Set(managers.values())].map((manager) => manager.stop(remaining())));
|
|
3181
3423
|
const socketClosed = await closeServerWithin(server, remaining());
|
|
3182
3424
|
const timedOut = managerReports.some((report) => report.timedOut);
|
|
@@ -3193,7 +3435,7 @@ if (import.meta.main) {
|
|
|
3193
3435
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
3194
3436
|
} else {
|
|
3195
3437
|
console.log("[agent] signing/vault mode only; deployment root and pull are not configured");
|
|
3196
|
-
if (vaultSync || attestationLoop) {
|
|
3438
|
+
if (vaultSync || attestationLoop || migrationPull) {
|
|
3197
3439
|
let stopping = false;
|
|
3198
3440
|
const stop = async (signal) => {
|
|
3199
3441
|
if (stopping)
|
|
@@ -3205,7 +3447,8 @@ if (import.meta.main) {
|
|
|
3205
3447
|
console.log(`[agent] ${signal}: stopping background intake and draining`);
|
|
3206
3448
|
const backgroundDrained = await settleWithin(Promise.all([
|
|
3207
3449
|
vaultSync?.stop() ?? Promise.resolve(),
|
|
3208
|
-
attestationLoop?.stop() ?? Promise.resolve()
|
|
3450
|
+
attestationLoop?.stop() ?? Promise.resolve(),
|
|
3451
|
+
migrationPull?.stop() ?? Promise.resolve()
|
|
3209
3452
|
]), remaining());
|
|
3210
3453
|
const socketClosed = await closeServerWithin(server, remaining());
|
|
3211
3454
|
console.log(`[agent] drain ${JSON.stringify({ backgroundDrained, socketClosed })}`);
|
|
@@ -3218,34 +3461,43 @@ if (import.meta.main) {
|
|
|
3218
3461
|
}
|
|
3219
3462
|
export {
|
|
3220
3463
|
validateMetalProfile,
|
|
3464
|
+
validateLifecycleProfile,
|
|
3221
3465
|
tenantNodeApiUrl,
|
|
3222
3466
|
startProvisioningPull,
|
|
3223
3467
|
startNodeVaultSync,
|
|
3224
3468
|
startNodeAttestation,
|
|
3469
|
+
startMigrationPull,
|
|
3225
3470
|
startMetalHelper,
|
|
3471
|
+
startLifecycleHelper,
|
|
3226
3472
|
startDeploymentRunner,
|
|
3227
3473
|
startDeploymentPull,
|
|
3228
3474
|
startControlServer,
|
|
3229
3475
|
startAgent,
|
|
3230
3476
|
runAgent,
|
|
3231
3477
|
requestMetalProvision,
|
|
3478
|
+
requestLifecycleAction,
|
|
3232
3479
|
requestDeploymentCommand,
|
|
3233
3480
|
requestControl,
|
|
3481
|
+
renderWarpMdm,
|
|
3234
3482
|
removeMetalGuest,
|
|
3235
3483
|
pullProvisioningOnce,
|
|
3484
|
+
pullMigrationOnce,
|
|
3236
3485
|
pullDeploymentOnce,
|
|
3237
3486
|
provisionMetalGuest,
|
|
3238
3487
|
projectVaultCoordinate,
|
|
3239
3488
|
projectVaultCacheKey,
|
|
3240
3489
|
metalHousekeepingDropIn,
|
|
3241
3490
|
metalGuestSliceUnit,
|
|
3491
|
+
materializeWarpMdm,
|
|
3242
3492
|
loadTextCredential,
|
|
3243
3493
|
loadSeedCredential,
|
|
3244
3494
|
loadOrCreateSeed,
|
|
3495
|
+
loadLifecycleProfile,
|
|
3245
3496
|
loadGuestBinding,
|
|
3246
3497
|
handleRequest,
|
|
3247
3498
|
handleApplicationRequest,
|
|
3248
3499
|
guestNameFor,
|
|
3500
|
+
executeLifecycleAction,
|
|
3249
3501
|
enrolGuestIdentity,
|
|
3250
3502
|
createSystemdDeploymentSecrets,
|
|
3251
3503
|
createSnpAttestationSource,
|
|
@@ -3265,6 +3517,7 @@ export {
|
|
|
3265
3517
|
DEFAULT_SEED_PATH,
|
|
3266
3518
|
DEFAULT_SEED_CREDENTIAL,
|
|
3267
3519
|
DEFAULT_METAL_HELPER_SOCKET,
|
|
3520
|
+
DEFAULT_LIFECYCLE_HELPER_SOCKET,
|
|
3268
3521
|
DEFAULT_ENROLMENT_STATE_PATH,
|
|
3269
3522
|
DEFAULT_DEPLOYMENT_RUNNER_SOCKET,
|
|
3270
3523
|
DEFAULT_CONTROL_SOCKET,
|