@forgezero/agent 0.1.22 → 0.1.24
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 +10 -0
- package/dist/fz-agent.js +494 -218
- package/dist/fz.js +221 -18
- package/dist/guest-enrolment.d.ts +9 -0
- package/dist/guest-enrolment.js +22 -0
- 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 +22 -0
- package/dist/provision.js +215 -16
- 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";
|
|
@@ -1158,6 +1158,26 @@ import {
|
|
|
1158
1158
|
writeFileSync as writeFileSync2
|
|
1159
1159
|
} from "fs";
|
|
1160
1160
|
import { dirname as dirname2 } from "path";
|
|
1161
|
+
function privateNetworkAttachmentFromEnvironment(env = process.env) {
|
|
1162
|
+
const values = {
|
|
1163
|
+
accountId: env.FZ_CF_ACCOUNT_ID?.trim() ?? "",
|
|
1164
|
+
tunnelId: env.FZ_CF_TUNNEL_ID?.trim() ?? "",
|
|
1165
|
+
virtualNetworkId: env.FZ_CF_VIRTUAL_NETWORK_ID?.trim() ?? "",
|
|
1166
|
+
warpPolicyId: env.FZ_CF_WARP_POLICY_ID?.trim() ?? ""
|
|
1167
|
+
};
|
|
1168
|
+
const supplied = [values.accountId, values.tunnelId, values.virtualNetworkId, values.warpPolicyId].some(Boolean);
|
|
1169
|
+
if (!supplied)
|
|
1170
|
+
return;
|
|
1171
|
+
if (!values.accountId || !values.tunnelId || !values.warpPolicyId) {
|
|
1172
|
+
throw new Error("private-network attachment requires account, Tunnel and WARP policy ids");
|
|
1173
|
+
}
|
|
1174
|
+
return {
|
|
1175
|
+
accountId: values.accountId,
|
|
1176
|
+
tunnelId: values.tunnelId,
|
|
1177
|
+
...values.virtualNetworkId ? { virtualNetworkId: values.virtualNetworkId } : {},
|
|
1178
|
+
warpPolicyId: values.warpPolicyId
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1161
1181
|
var validBinding = (value, expectedNodeKey) => {
|
|
1162
1182
|
if (!value || typeof value !== "object")
|
|
1163
1183
|
return false;
|
|
@@ -1204,6 +1224,7 @@ async function enrolGuestIdentity(options) {
|
|
|
1204
1224
|
token,
|
|
1205
1225
|
label: options.label,
|
|
1206
1226
|
gitDeployPublicKey: options.gitDeployPublicKey,
|
|
1227
|
+
privateNetworkAttachment: options.privateNetworkAttachment,
|
|
1207
1228
|
publicKeys: {
|
|
1208
1229
|
ed25519: options.keys.ed25519.publicKey,
|
|
1209
1230
|
mlDsa: options.keys.mlDsa.publicKey
|
|
@@ -1373,6 +1394,132 @@ function startProvisioningPull(options) {
|
|
|
1373
1394
|
};
|
|
1374
1395
|
}
|
|
1375
1396
|
|
|
1397
|
+
// src/migration-pull.ts
|
|
1398
|
+
class MigrationClaimLostError extends Error {
|
|
1399
|
+
constructor(message) {
|
|
1400
|
+
super(message);
|
|
1401
|
+
this.name = "MigrationClaimLostError";
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
var post2 = (options, operation, body) => postSignedNode(options, `v1/node/migrations/${operation}`, body);
|
|
1405
|
+
async function complete2(options, body) {
|
|
1406
|
+
const attempts = Math.max(1, Math.min(options.completionAttempts ?? 5, 10));
|
|
1407
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1408
|
+
let last;
|
|
1409
|
+
for (let attempt = 1;attempt <= attempts; attempt += 1) {
|
|
1410
|
+
try {
|
|
1411
|
+
await post2(options, "complete", body);
|
|
1412
|
+
return;
|
|
1413
|
+
} catch (cause) {
|
|
1414
|
+
last = cause;
|
|
1415
|
+
if (cause instanceof SignedNodeHttpError && cause.status < 500)
|
|
1416
|
+
throw cause;
|
|
1417
|
+
if (attempt < attempts)
|
|
1418
|
+
await sleep(Math.min(2000, 250 * 2 ** (attempt - 1)));
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
throw last;
|
|
1422
|
+
}
|
|
1423
|
+
async function pullMigrationOnce(options) {
|
|
1424
|
+
const response = await post2(options, "claim", {});
|
|
1425
|
+
if (!response.claim)
|
|
1426
|
+
return { status: "idle" };
|
|
1427
|
+
const claim = response.claim;
|
|
1428
|
+
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
1429
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
1430
|
+
const now = options.now ?? Date.now;
|
|
1431
|
+
let expires = claim.claimExpiresAtTs;
|
|
1432
|
+
let stopped = false;
|
|
1433
|
+
let timer;
|
|
1434
|
+
let renewal = null;
|
|
1435
|
+
let lost = null;
|
|
1436
|
+
const schedule = (override) => {
|
|
1437
|
+
if (stopped)
|
|
1438
|
+
return;
|
|
1439
|
+
const delay = override ?? Math.max(1000, Math.min(5 * 60000, Math.floor(Math.max(0, expires - now()) / 3)));
|
|
1440
|
+
timer = setTimer(() => {
|
|
1441
|
+
if (stopped || renewal)
|
|
1442
|
+
return;
|
|
1443
|
+
let retry;
|
|
1444
|
+
renewal = post2(options, "renew", {
|
|
1445
|
+
migrationKey: claim.migrationKey,
|
|
1446
|
+
claimToken: claim.claimToken
|
|
1447
|
+
}).then((value) => {
|
|
1448
|
+
expires = value.claimExpiresAtTs;
|
|
1449
|
+
}).catch((cause) => {
|
|
1450
|
+
if (cause instanceof SignedNodeHttpError && cause.status < 500) {
|
|
1451
|
+
lost = new MigrationClaimLostError(cause.message);
|
|
1452
|
+
stopped = true;
|
|
1453
|
+
} else
|
|
1454
|
+
retry = Math.max(1000, options.renewRetryMs ?? 5000);
|
|
1455
|
+
}).finally(() => {
|
|
1456
|
+
renewal = null;
|
|
1457
|
+
if (!stopped)
|
|
1458
|
+
schedule(retry);
|
|
1459
|
+
});
|
|
1460
|
+
}, delay);
|
|
1461
|
+
};
|
|
1462
|
+
schedule();
|
|
1463
|
+
let evidence;
|
|
1464
|
+
try {
|
|
1465
|
+
evidence = await options.run(claim);
|
|
1466
|
+
} catch (cause) {
|
|
1467
|
+
stopped = true;
|
|
1468
|
+
clearTimer(timer);
|
|
1469
|
+
await renewal;
|
|
1470
|
+
if (lost)
|
|
1471
|
+
throw lost;
|
|
1472
|
+
const reason = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2000);
|
|
1473
|
+
await complete2(options, {
|
|
1474
|
+
migrationKey: claim.migrationKey,
|
|
1475
|
+
claimToken: claim.claimToken,
|
|
1476
|
+
ok: false,
|
|
1477
|
+
detail: reason
|
|
1478
|
+
});
|
|
1479
|
+
return { status: "failed", claim, reason };
|
|
1480
|
+
}
|
|
1481
|
+
stopped = true;
|
|
1482
|
+
clearTimer(timer);
|
|
1483
|
+
await renewal;
|
|
1484
|
+
if (lost)
|
|
1485
|
+
throw lost;
|
|
1486
|
+
await complete2(options, {
|
|
1487
|
+
migrationKey: claim.migrationKey,
|
|
1488
|
+
claimToken: claim.claimToken,
|
|
1489
|
+
ok: true,
|
|
1490
|
+
evidence
|
|
1491
|
+
});
|
|
1492
|
+
return { status: "completed", claim, evidence };
|
|
1493
|
+
}
|
|
1494
|
+
function startMigrationPull(options) {
|
|
1495
|
+
const interval = Math.max(1000, options.intervalMs ?? 5000);
|
|
1496
|
+
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
1497
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
1498
|
+
let stopped = false;
|
|
1499
|
+
let timer;
|
|
1500
|
+
let active = null;
|
|
1501
|
+
const tick = () => {
|
|
1502
|
+
if (stopped || active)
|
|
1503
|
+
return;
|
|
1504
|
+
active = pullMigrationOnce(options).then((result) => options.onEvent?.(result.status, result)).catch((cause) => options.onEvent?.("poll-failed", cause)).finally(() => {
|
|
1505
|
+
active = null;
|
|
1506
|
+
if (!stopped)
|
|
1507
|
+
timer = setTimer(tick, interval);
|
|
1508
|
+
});
|
|
1509
|
+
};
|
|
1510
|
+
tick();
|
|
1511
|
+
return {
|
|
1512
|
+
async stop() {
|
|
1513
|
+
stopped = true;
|
|
1514
|
+
clearTimer(timer);
|
|
1515
|
+
await active;
|
|
1516
|
+
},
|
|
1517
|
+
get active() {
|
|
1518
|
+
return !stopped;
|
|
1519
|
+
}
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1376
1523
|
// src/metal-helper-socket.ts
|
|
1377
1524
|
import { chmodSync as chmodSync5, existsSync as existsSync6, unlinkSync as unlinkSync5 } from "fs";
|
|
1378
1525
|
import { connect as connect2, createServer as createServer3 } from "net";
|
|
@@ -1489,55 +1636,6 @@ function shapeEgressUnitDirectives(tap, guaranteedMbps, burstMbps) {
|
|
|
1489
1636
|
];
|
|
1490
1637
|
}
|
|
1491
1638
|
|
|
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
1639
|
// src/ubuntu.ts
|
|
1542
1640
|
var SUPPORTED_GUEST_IMAGE = Object.freeze({
|
|
1543
1641
|
key: "ubuntu-resolute-20260731",
|
|
@@ -1718,7 +1816,7 @@ var yamlFile = (path, content, permissions) => ` - path: ${JSON.stringify(path)
|
|
|
1718
1816
|
encoding: b64
|
|
1719
1817
|
content: ${base64(content)}
|
|
1720
1818
|
`;
|
|
1721
|
-
function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true) {
|
|
1819
|
+
function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true, nodeLabel = "compute") {
|
|
1722
1820
|
const agentBun = "/usr/local/lib/forgezero/bun";
|
|
1723
1821
|
const attestationSetup = attested ? `# The report device is not part of the encryption path, so a guest can appear
|
|
1724
1822
|
# healthy and encrypted while attestation is silently impossible. Install and
|
|
@@ -1732,26 +1830,7 @@ printf 'sev-guest
|
|
|
1732
1830
|
` : "";
|
|
1733
1831
|
return `#!/usr/bin/env bash
|
|
1734
1832
|
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
|
|
1833
|
+
${attestationSetup}if [[ ! -x /usr/local/bin/bun ]]; then
|
|
1755
1834
|
curl -fsSL https://bun.sh/install -o /run/fz-bun-install
|
|
1756
1835
|
printf '%s %s
|
|
1757
1836
|
' '${profile.bunInstallerSha256}' /run/fz-bun-install | sha256sum -c -
|
|
@@ -1759,132 +1838,16 @@ chmod 0400 /var/lib/forgezero/enrol-token.cred
|
|
|
1759
1838
|
install -m 0755 ${agentBun}/bin/bun /usr/local/bin/bun
|
|
1760
1839
|
rm -f /run/fz-bun-install
|
|
1761
1840
|
fi
|
|
1762
|
-
if [[ ! -x /usr/local/bin/fz-agent ]]; then
|
|
1841
|
+
if [[ ! -x /usr/local/bin/fz-agent ]] || [[ "$(/usr/local/bin/fz-agent --version 2>/dev/null || true)" != "${profile.agentVersion}" ]]; then
|
|
1763
1842
|
env BUN_INSTALL=${agentBun} /usr/local/bin/bun add -g --no-cache --force @forgezero/agent@${profile.agentVersion}
|
|
1764
1843
|
ln -sfn ${agentBun}/bin/fz-agent /usr/local/bin/fz-agent
|
|
1765
1844
|
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
|
|
1845
|
+
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
1846
|
`;
|
|
1879
1847
|
}
|
|
1880
1848
|
function cloudInit(profile, claim, manifest) {
|
|
1881
1849
|
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" });
|
|
1850
|
+
const bootstrap = guestBootstrapScript(profile, claim.spec.confidential, enrolled, manifest.name);
|
|
1888
1851
|
const access = claim.access;
|
|
1889
1852
|
const users = access ? `users:
|
|
1890
1853
|
- default
|
|
@@ -1896,8 +1859,8 @@ function cloudInit(profile, claim, manifest) {
|
|
|
1896
1859
|
${access.sshPublicKeys.map((key) => ` - ${JSON.stringify(key)}`).join(`
|
|
1897
1860
|
`)}
|
|
1898
1861
|
` : "";
|
|
1899
|
-
const enrolmentFiles = enrolled ?
|
|
1900
|
-
`, "0600")
|
|
1862
|
+
const enrolmentFiles = enrolled ? yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
|
|
1863
|
+
`, "0600") : "";
|
|
1901
1864
|
return {
|
|
1902
1865
|
userData: `#cloud-config
|
|
1903
1866
|
package_update: true
|
|
@@ -1906,10 +1869,8 @@ ${users}disable_root: true
|
|
|
1906
1869
|
# every dynamically claimed repository must be cloneable on a clean image.
|
|
1907
1870
|
packages: [curl, ca-certificates, openssl, openssh-client, git, unzip${claim.spec.confidential ? ", python3" : ""}]
|
|
1908
1871
|
write_files:
|
|
1909
|
-
${enrolmentFiles}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}
|
|
1872
|
+
${enrolmentFiles}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}runcmd:
|
|
1910
1873
|
- [ bash, /usr/local/sbin/forgezero-guest-bootstrap ]
|
|
1911
|
-
${enrolled ? ` - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
|
|
1912
|
-
` : ""}
|
|
1913
1874
|
`,
|
|
1914
1875
|
metaData: `instance-id: ${manifest.name}-${claim.attempt}
|
|
1915
1876
|
local-hostname: ${manifest.name}
|
|
@@ -2732,22 +2693,268 @@ async function closeServerWithin(server, timeoutMs) {
|
|
|
2732
2693
|
return closed;
|
|
2733
2694
|
}
|
|
2734
2695
|
|
|
2696
|
+
// src/lifecycle-helper.ts
|
|
2697
|
+
import { chmodSync as chmodSync7, existsSync as existsSync9, readFileSync as readFileSync4, unlinkSync as unlinkSync7 } from "fs";
|
|
2698
|
+
import { connect as connect4, createConnection, createServer as createServer5, isIP } from "net";
|
|
2699
|
+
var DEFAULT_LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
|
|
2700
|
+
var MAX_REQUEST_BYTES4 = 16 * 1024;
|
|
2701
|
+
var REQUEST_TIMEOUT_MS = 5000;
|
|
2702
|
+
var ACTION_TIMEOUT_MS = 10 * 60000;
|
|
2703
|
+
var unitPattern = /^[A-Za-z0-9_.@-]+\.service$/;
|
|
2704
|
+
var privateIp = (value) => {
|
|
2705
|
+
const address = value.replace(/^\[|\]$/g, "").toLowerCase();
|
|
2706
|
+
if (isIP(address) === 4) {
|
|
2707
|
+
const [a, b] = address.split(".").map(Number);
|
|
2708
|
+
return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
|
|
2709
|
+
}
|
|
2710
|
+
if (isIP(address) === 6) {
|
|
2711
|
+
const first = Number.parseInt(address.split(":", 1)[0], 16);
|
|
2712
|
+
return Number.isFinite(first) && (first & 65024) === 64512;
|
|
2713
|
+
}
|
|
2714
|
+
return false;
|
|
2715
|
+
};
|
|
2716
|
+
function validateLifecycleProfile(profile) {
|
|
2717
|
+
if (!Array.isArray(profile.apiUnits) || profile.apiUnits.length < 1 || profile.apiUnits.some((unit) => !unitPattern.test(unit))) {
|
|
2718
|
+
throw new Error("lifecycle profile needs one or more valid API service units");
|
|
2719
|
+
}
|
|
2720
|
+
if (!unitPattern.test(profile.databaseUnit))
|
|
2721
|
+
throw new Error("lifecycle profile database unit is invalid");
|
|
2722
|
+
const apiUrl = new URL(profile.apiHealthUrl);
|
|
2723
|
+
if (apiUrl.protocol !== "http:" || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(apiUrl.hostname)) {
|
|
2724
|
+
throw new Error("API health URL must be loopback HTTP");
|
|
2725
|
+
}
|
|
2726
|
+
const databaseUrl = new URL(profile.databaseHealthUrl);
|
|
2727
|
+
if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
|
|
2728
|
+
throw new Error("database health URL must be loopback or private HTTP");
|
|
2729
|
+
}
|
|
2730
|
+
if (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
|
|
2731
|
+
throw new Error("lifecycle profile database ports are invalid");
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
function loadLifecycleProfile(path) {
|
|
2735
|
+
const profile = JSON.parse(readFileSync4(path, "utf8"));
|
|
2736
|
+
validateLifecycleProfile(profile);
|
|
2737
|
+
return profile;
|
|
2738
|
+
}
|
|
2739
|
+
var spawnLifecycleCommand = async (argv) => {
|
|
2740
|
+
const child = Bun.spawn([...argv], {
|
|
2741
|
+
stdout: "pipe",
|
|
2742
|
+
stderr: "pipe",
|
|
2743
|
+
env: { PATH: "/usr/sbin:/usr/bin:/sbin:/bin" }
|
|
2744
|
+
});
|
|
2745
|
+
let timedOut = false;
|
|
2746
|
+
const timer = setTimeout(() => {
|
|
2747
|
+
timedOut = true;
|
|
2748
|
+
child.kill("SIGTERM");
|
|
2749
|
+
}, ACTION_TIMEOUT_MS);
|
|
2750
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
2751
|
+
new Response(child.stdout).text(),
|
|
2752
|
+
new Response(child.stderr).text(),
|
|
2753
|
+
child.exited
|
|
2754
|
+
]);
|
|
2755
|
+
clearTimeout(timer);
|
|
2756
|
+
return { exitCode: timedOut ? 124 : exitCode, stdout, stderr };
|
|
2757
|
+
};
|
|
2758
|
+
var requireSuccess = async (exec, argv, label) => {
|
|
2759
|
+
const result = await exec(argv);
|
|
2760
|
+
if (result.exitCode !== 0)
|
|
2761
|
+
throw new Error(`${label} failed: ${(result.stderr || result.stdout).trim() || `exit ${result.exitCode}`}`);
|
|
2762
|
+
return result.stdout;
|
|
2763
|
+
};
|
|
2764
|
+
var probeTcp = (host, port, timeoutMs = 5000) => new Promise((resolve2, reject) => {
|
|
2765
|
+
const socket = createConnection({ host, port });
|
|
2766
|
+
socket.setTimeout(timeoutMs);
|
|
2767
|
+
socket.once("connect", () => {
|
|
2768
|
+
socket.destroy();
|
|
2769
|
+
resolve2();
|
|
2770
|
+
});
|
|
2771
|
+
socket.once("timeout", () => {
|
|
2772
|
+
socket.destroy();
|
|
2773
|
+
reject(new Error(`private peer ${host}:${port} timed out`));
|
|
2774
|
+
});
|
|
2775
|
+
socket.once("error", reject);
|
|
2776
|
+
});
|
|
2777
|
+
async function executeLifecycleAction(profile, claim, exec = spawnLifecycleCommand, tcpProbe = probeTcp, fetcher = fetch) {
|
|
2778
|
+
validateLifecycleProfile(profile);
|
|
2779
|
+
switch (claim.action) {
|
|
2780
|
+
case "network-ready": {
|
|
2781
|
+
if (!claim.peerPrivateAddresses?.length)
|
|
2782
|
+
throw new Error("network claim has no private peers");
|
|
2783
|
+
if (claim.network === "cloudflare-warp") {
|
|
2784
|
+
const status = await requireSuccess(exec, ["/usr/bin/warp-cli", "--accept-tos", "status"], "WARP status");
|
|
2785
|
+
if (!/\bconnected\b/i.test(status) || /\bdisconnected\b/i.test(status))
|
|
2786
|
+
throw new Error("WARP is not connected");
|
|
2787
|
+
}
|
|
2788
|
+
for (const address of claim.peerPrivateAddresses) {
|
|
2789
|
+
for (const port of profile.databasePorts)
|
|
2790
|
+
await tcpProbe(address, port);
|
|
2791
|
+
}
|
|
2792
|
+
return {
|
|
2793
|
+
targetAgentReady: true,
|
|
2794
|
+
privateNetworkReady: true,
|
|
2795
|
+
...claim.network === "cloudflare-warp" ? { warpConnected: true } : {}
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
case "database-member-ready": {
|
|
2799
|
+
await requireSuccess(exec, ["/usr/bin/systemctl", "is-active", profile.databaseUnit], "database service check");
|
|
2800
|
+
const response = await fetcher(profile.databaseHealthUrl, { signal: AbortSignal.timeout(5000) });
|
|
2801
|
+
if (!response.ok && response.status !== 401)
|
|
2802
|
+
throw new Error(`database health returned HTTP ${response.status}`);
|
|
2803
|
+
return { databaseMemberHealthy: true };
|
|
2804
|
+
}
|
|
2805
|
+
case "api-ready": {
|
|
2806
|
+
const response = await fetcher(profile.apiHealthUrl, { signal: AbortSignal.timeout(5000) });
|
|
2807
|
+
if (!response.ok)
|
|
2808
|
+
throw new Error(`API health returned HTTP ${response.status}`);
|
|
2809
|
+
return { apiHealthy: true };
|
|
2810
|
+
}
|
|
2811
|
+
case "source-drained":
|
|
2812
|
+
await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits], "API drain");
|
|
2813
|
+
return { sourceDrained: true };
|
|
2814
|
+
case "source-stopped":
|
|
2815
|
+
await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits, profile.databaseUnit], "source retirement");
|
|
2816
|
+
return { sourceStopped: true };
|
|
2817
|
+
default:
|
|
2818
|
+
throw new Error("unknown lifecycle action");
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
function startLifecycleHelper(options) {
|
|
2822
|
+
validateLifecycleProfile(options.profile);
|
|
2823
|
+
const socketPath = options.socketPath ?? DEFAULT_LIFECYCLE_HELPER_SOCKET;
|
|
2824
|
+
if (existsSync9(socketPath))
|
|
2825
|
+
unlinkSync7(socketPath);
|
|
2826
|
+
let tail = Promise.resolve();
|
|
2827
|
+
const server = createServer5((socket) => {
|
|
2828
|
+
let buffer = "";
|
|
2829
|
+
socket.setTimeout(REQUEST_TIMEOUT_MS, () => socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request timed out" } })}
|
|
2830
|
+
`));
|
|
2831
|
+
socket.on("data", (chunk) => {
|
|
2832
|
+
buffer += chunk.toString("utf8");
|
|
2833
|
+
if (buffer.length > MAX_REQUEST_BYTES4)
|
|
2834
|
+
return void socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request too large" } })}
|
|
2835
|
+
`);
|
|
2836
|
+
const newline = buffer.indexOf(`
|
|
2837
|
+
`);
|
|
2838
|
+
if (newline < 0)
|
|
2839
|
+
return;
|
|
2840
|
+
socket.setTimeout(0);
|
|
2841
|
+
const line = buffer.slice(0, newline);
|
|
2842
|
+
buffer = "";
|
|
2843
|
+
const work = async () => {
|
|
2844
|
+
let request;
|
|
2845
|
+
try {
|
|
2846
|
+
request = JSON.parse(line);
|
|
2847
|
+
} catch {
|
|
2848
|
+
return { ok: false, error: { code: "REFUSED", message: "invalid request" } };
|
|
2849
|
+
}
|
|
2850
|
+
if (request?.op !== "apply" || !request.claim)
|
|
2851
|
+
return { ok: false, error: { code: "REFUSED", message: "unknown operation" } };
|
|
2852
|
+
try {
|
|
2853
|
+
return { ok: true, evidence: await executeLifecycleAction(options.profile, request.claim, options.exec, options.tcpProbe, options.fetch) };
|
|
2854
|
+
} catch (cause) {
|
|
2855
|
+
return { ok: false, error: { code: "FAILED", message: cause instanceof Error ? cause.message : "lifecycle action failed" } };
|
|
2856
|
+
}
|
|
2857
|
+
};
|
|
2858
|
+
const response = tail.then(work, work);
|
|
2859
|
+
tail = response;
|
|
2860
|
+
response.then((value) => socket.end(`${JSON.stringify(value)}
|
|
2861
|
+
`));
|
|
2862
|
+
});
|
|
2863
|
+
socket.on("error", () => socket.destroy());
|
|
2864
|
+
});
|
|
2865
|
+
server.listen(socketPath, () => chmodSync7(socketPath, 432));
|
|
2866
|
+
return {
|
|
2867
|
+
server,
|
|
2868
|
+
async stop() {
|
|
2869
|
+
await new Promise((resolve2) => server.close(() => resolve2()));
|
|
2870
|
+
await tail;
|
|
2871
|
+
}
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
function requestLifecycleAction(claim, socketPath = DEFAULT_LIFECYCLE_HELPER_SOCKET) {
|
|
2875
|
+
return new Promise((resolve2, reject) => {
|
|
2876
|
+
const socket = connect4(socketPath, () => socket.write(`${JSON.stringify({ op: "apply", claim })}
|
|
2877
|
+
`));
|
|
2878
|
+
socket.setTimeout(ACTION_TIMEOUT_MS + 1e4, () => {
|
|
2879
|
+
socket.destroy();
|
|
2880
|
+
reject(new Error("lifecycle helper response timed out"));
|
|
2881
|
+
});
|
|
2882
|
+
let buffer = "";
|
|
2883
|
+
socket.on("data", (chunk) => {
|
|
2884
|
+
buffer += chunk.toString("utf8");
|
|
2885
|
+
const newline = buffer.indexOf(`
|
|
2886
|
+
`);
|
|
2887
|
+
if (newline < 0)
|
|
2888
|
+
return;
|
|
2889
|
+
socket.end();
|
|
2890
|
+
try {
|
|
2891
|
+
const response = JSON.parse(buffer.slice(0, newline));
|
|
2892
|
+
if (response.ok)
|
|
2893
|
+
resolve2(response.evidence);
|
|
2894
|
+
else
|
|
2895
|
+
reject(new Error(response.error.message));
|
|
2896
|
+
} catch (cause) {
|
|
2897
|
+
reject(cause);
|
|
2898
|
+
}
|
|
2899
|
+
});
|
|
2900
|
+
socket.on("error", reject);
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
// src/warp-config.ts
|
|
2905
|
+
import { chmodSync as chmodSync8, mkdirSync as mkdirSync5, renameSync as renameSync3, symlinkSync, unlinkSync as unlinkSync8, writeFileSync as writeFileSync5 } from "fs";
|
|
2906
|
+
var xml = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
2907
|
+
function renderWarpMdm(options) {
|
|
2908
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.organization))
|
|
2909
|
+
throw new Error("WARP organization is invalid");
|
|
2910
|
+
if (!options.clientId.trim() || !options.clientSecret.trim())
|
|
2911
|
+
throw new Error("WARP service-token credentials are empty");
|
|
2912
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2913
|
+
<dict>
|
|
2914
|
+
<key>auth_client_id</key><string>${xml(options.clientId.trim())}</string>
|
|
2915
|
+
<key>auth_client_secret</key><string>${xml(options.clientSecret.trim())}</string>
|
|
2916
|
+
<key>auto_connect</key><integer>1</integer>
|
|
2917
|
+
<key>onboarding</key><false/>
|
|
2918
|
+
<key>organization</key><string>${xml(options.organization)}</string>
|
|
2919
|
+
<key>service_mode</key><string>warp</string>
|
|
2920
|
+
</dict>
|
|
2921
|
+
`;
|
|
2922
|
+
}
|
|
2923
|
+
function materializeWarpMdm(options) {
|
|
2924
|
+
const runtimePath = options.runtimePath ?? "/run/forgezero-warp/mdm.xml";
|
|
2925
|
+
const servicePath = options.servicePath ?? "/var/lib/cloudflare-warp/mdm.xml";
|
|
2926
|
+
mkdirSync5(runtimePath.replace(/\/[^/]+$/, ""), { recursive: true, mode: 448 });
|
|
2927
|
+
mkdirSync5(servicePath.replace(/\/[^/]+$/, ""), { recursive: true, mode: 448 });
|
|
2928
|
+
const next = `${runtimePath}.next`;
|
|
2929
|
+
writeFileSync5(next, renderWarpMdm(options), { mode: 384 });
|
|
2930
|
+
chmodSync8(next, 384);
|
|
2931
|
+
renameSync3(next, runtimePath);
|
|
2932
|
+
try {
|
|
2933
|
+
unlinkSync8(servicePath);
|
|
2934
|
+
} catch (cause) {
|
|
2935
|
+
if (cause.code !== "ENOENT")
|
|
2936
|
+
throw cause;
|
|
2937
|
+
}
|
|
2938
|
+
symlinkSync(runtimePath, servicePath);
|
|
2939
|
+
return { runtimePath, servicePath };
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2735
2942
|
// src/version.ts
|
|
2736
|
-
var VERSION = "0.1.
|
|
2943
|
+
var VERSION = "0.1.24";
|
|
2737
2944
|
|
|
2738
2945
|
// src/index.ts
|
|
2739
2946
|
function loadOrCreateSeed(path) {
|
|
2740
|
-
if (
|
|
2741
|
-
const seed2 = new Uint8Array(Buffer.from(
|
|
2947
|
+
if (existsSync10(path)) {
|
|
2948
|
+
const seed2 = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
|
|
2742
2949
|
if (seed2.length < 32) {
|
|
2743
2950
|
throw new Error(`agent: the seed at ${path} is too short to derive a key from.`);
|
|
2744
2951
|
}
|
|
2745
2952
|
return seed2;
|
|
2746
2953
|
}
|
|
2747
|
-
|
|
2954
|
+
mkdirSync6(dirname4(path), { recursive: true });
|
|
2748
2955
|
const seed = new Uint8Array(randomBytes(32));
|
|
2749
|
-
|
|
2750
|
-
|
|
2956
|
+
writeFileSync6(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
|
|
2957
|
+
chmodSync9(path, 384);
|
|
2751
2958
|
return seed;
|
|
2752
2959
|
}
|
|
2753
2960
|
var DEFAULT_SOCKET_PATH = DEFAULT_SOCKET;
|
|
@@ -2758,9 +2965,9 @@ function loadSeedCredential(name = DEFAULT_SEED_CREDENTIAL, directory = process.
|
|
|
2758
2965
|
if (!directory)
|
|
2759
2966
|
throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the node identity.");
|
|
2760
2967
|
const path = `${directory}/${name}`;
|
|
2761
|
-
if (!
|
|
2968
|
+
if (!existsSync10(path))
|
|
2762
2969
|
throw new Error(`agent: the systemd credential ${name} is missing at ${path}.`);
|
|
2763
|
-
const seed = new Uint8Array(Buffer.from(
|
|
2970
|
+
const seed = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
|
|
2764
2971
|
if (seed.length < 32)
|
|
2765
2972
|
throw new Error(`agent: the systemd credential ${name} is too short to derive a key from.`);
|
|
2766
2973
|
return seed;
|
|
@@ -2770,7 +2977,7 @@ function loadTextCredential(name, directory = process.env.CREDENTIALS_DIRECTORY)
|
|
|
2770
2977
|
throw new Error("agent: CREDENTIALS_DIRECTORY is missing; systemd did not load the credential.");
|
|
2771
2978
|
if (!/^[A-Za-z0-9_.-]+$/.test(name))
|
|
2772
2979
|
throw new Error("agent: invalid systemd credential name.");
|
|
2773
|
-
const value =
|
|
2980
|
+
const value = readFileSync5(`${directory}/${name}`, "utf8").trim();
|
|
2774
2981
|
if (!value)
|
|
2775
2982
|
throw new Error(`agent: systemd credential ${name} is empty.`);
|
|
2776
2983
|
return value;
|
|
@@ -2876,7 +3083,8 @@ if (import.meta.main) {
|
|
|
2876
3083
|
nodeKey: nodeKey2,
|
|
2877
3084
|
keys: keys2,
|
|
2878
3085
|
label: process.env.FZ_NODE_LABEL,
|
|
2879
|
-
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ?
|
|
3086
|
+
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
|
|
3087
|
+
privateNetworkAttachment: privateNetworkAttachmentFromEnvironment()
|
|
2880
3088
|
});
|
|
2881
3089
|
console.log(`[agent] enrolled ${binding2.computeReference} in project ${binding2.projectKey}/${binding2.environmentKey}`);
|
|
2882
3090
|
process.exit(0);
|
|
@@ -2885,7 +3093,7 @@ if (import.meta.main) {
|
|
|
2885
3093
|
const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
|
|
2886
3094
|
if (!profilePath)
|
|
2887
3095
|
throw new Error("metal-helper requires --profile=/absolute/path.json");
|
|
2888
|
-
const profile = JSON.parse(
|
|
3096
|
+
const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
|
|
2889
3097
|
const helper = startMetalHelper({
|
|
2890
3098
|
profile,
|
|
2891
3099
|
socketPath: process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET
|
|
@@ -2903,11 +3111,53 @@ if (import.meta.main) {
|
|
|
2903
3111
|
process.on("SIGINT", () => void stop());
|
|
2904
3112
|
await new Promise(() => {});
|
|
2905
3113
|
}
|
|
3114
|
+
if (command === "lifecycle-helper") {
|
|
3115
|
+
const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
|
|
3116
|
+
if (!profilePath?.startsWith("/"))
|
|
3117
|
+
throw new Error("lifecycle-helper requires --profile=/absolute/path.json");
|
|
3118
|
+
if (typeof process.getuid !== "function" || process.getuid() !== 0) {
|
|
3119
|
+
throw new Error("lifecycle-helper must run as root");
|
|
3120
|
+
}
|
|
3121
|
+
const helper = startLifecycleHelper({
|
|
3122
|
+
profile: loadLifecycleProfile(profilePath),
|
|
3123
|
+
socketPath: process.env.FZ_LIFECYCLE_HELPER_SOCKET ?? DEFAULT_LIFECYCLE_HELPER_SOCKET
|
|
3124
|
+
});
|
|
3125
|
+
console.log(`[lifecycle-helper] listening on ${process.env.FZ_LIFECYCLE_HELPER_SOCKET ?? DEFAULT_LIFECYCLE_HELPER_SOCKET}`);
|
|
3126
|
+
let stopping = false;
|
|
3127
|
+
const stop = async () => {
|
|
3128
|
+
if (stopping)
|
|
3129
|
+
return;
|
|
3130
|
+
stopping = true;
|
|
3131
|
+
await helper.stop();
|
|
3132
|
+
process.exit(0);
|
|
3133
|
+
};
|
|
3134
|
+
process.on("SIGTERM", () => void stop());
|
|
3135
|
+
process.on("SIGINT", () => void stop());
|
|
3136
|
+
await new Promise(() => {});
|
|
3137
|
+
}
|
|
3138
|
+
if (command === "warp-config") {
|
|
3139
|
+
const organization = args.find((arg) => arg.startsWith("--organization="))?.slice("--organization=".length);
|
|
3140
|
+
if (!organization)
|
|
3141
|
+
throw new Error("warp-config requires --organization=<team-name>");
|
|
3142
|
+
if (typeof process.getuid !== "function" || process.getuid() !== 0)
|
|
3143
|
+
throw new Error("warp-config must run as root");
|
|
3144
|
+
const clientIdCredential = process.env.FZ_WARP_CLIENT_ID_CREDENTIAL;
|
|
3145
|
+
const clientSecretCredential = process.env.FZ_WARP_CLIENT_SECRET_CREDENTIAL;
|
|
3146
|
+
if (!clientIdCredential || !clientSecretCredential)
|
|
3147
|
+
throw new Error("warp-config requires systemd service-token credentials");
|
|
3148
|
+
materializeWarpMdm({
|
|
3149
|
+
organization,
|
|
3150
|
+
clientId: loadTextCredential(clientIdCredential),
|
|
3151
|
+
clientSecret: loadTextCredential(clientSecretCredential)
|
|
3152
|
+
});
|
|
3153
|
+
console.log("[warp-config] enrollment materialized in tmpfs");
|
|
3154
|
+
process.exit(0);
|
|
3155
|
+
}
|
|
2906
3156
|
if (command === "metal-isolation") {
|
|
2907
3157
|
const profilePath = args.find((arg) => arg.startsWith("--profile="))?.slice("--profile=".length);
|
|
2908
3158
|
if (!profilePath)
|
|
2909
3159
|
throw new Error("metal-isolation requires --profile=/absolute/path.json");
|
|
2910
|
-
const profile = JSON.parse(
|
|
3160
|
+
const profile = JSON.parse(readFileSync5(profilePath, "utf8"));
|
|
2911
3161
|
await applyMetalIsolation(profile);
|
|
2912
3162
|
console.log("[metal-isolation] host and guest cgroup boundaries active");
|
|
2913
3163
|
process.exit(0);
|
|
@@ -2952,7 +3202,7 @@ if (import.meta.main) {
|
|
|
2952
3202
|
if (claimArg !== "-" && !claimArg.startsWith("/")) {
|
|
2953
3203
|
throw new Error("metal-apply claim path must be absolute");
|
|
2954
3204
|
}
|
|
2955
|
-
const raw =
|
|
3205
|
+
const raw = readFileSync5(claimArg === "-" ? "/dev/stdin" : claimArg, "utf8");
|
|
2956
3206
|
if (Buffer.byteLength(raw) > 32 * 1024)
|
|
2957
3207
|
throw new Error("metal-apply claim exceeds 32 KiB");
|
|
2958
3208
|
const claim = JSON.parse(raw);
|
|
@@ -2978,9 +3228,9 @@ if (import.meta.main) {
|
|
|
2978
3228
|
metalHostname: process.env.FZ_METAL_HOSTNAME,
|
|
2979
3229
|
run: (claim) => requestMetalProvision(claim, process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET),
|
|
2980
3230
|
metalPreflight: () => ({
|
|
2981
|
-
snpHost:
|
|
2982
|
-
kvm:
|
|
2983
|
-
helper:
|
|
3231
|
+
snpHost: existsSync10("/dev/sev"),
|
|
3232
|
+
kvm: existsSync10("/dev/kvm"),
|
|
3233
|
+
helper: existsSync10(process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET)
|
|
2984
3234
|
}),
|
|
2985
3235
|
onEvent: (event, detail) => console.log(`[metal-agent] ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
|
|
2986
3236
|
});
|
|
@@ -3027,7 +3277,7 @@ if (import.meta.main) {
|
|
|
3027
3277
|
process.exit(1);
|
|
3028
3278
|
}
|
|
3029
3279
|
}
|
|
3030
|
-
const attestationSource =
|
|
3280
|
+
const attestationSource = existsSync10("/dev/sev-guest") ? createSnpAttestationSource() : undefined;
|
|
3031
3281
|
const running = runAgent({
|
|
3032
3282
|
socketPath: process.env.FZ_SOCKET_PATH ?? DEFAULT_SOCKET_PATH,
|
|
3033
3283
|
seedCredential: process.env.FZ_SEED_CREDENTIAL,
|
|
@@ -3041,8 +3291,8 @@ if (import.meta.main) {
|
|
|
3041
3291
|
const enrolmentStatePath = process.env.FZ_ENROL_STATE_FILE ?? DEFAULT_ENROLMENT_STATE_PATH;
|
|
3042
3292
|
let binding = loadGuestBinding(enrolmentStatePath, nodeKey);
|
|
3043
3293
|
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 &&
|
|
3294
|
+
const enrolmentCredentialAvailable = Boolean(enrolmentCredential && process.env.CREDENTIALS_DIRECTORY && existsSync10(`${process.env.CREDENTIALS_DIRECTORY}/${enrolmentCredential}`));
|
|
3295
|
+
const legacyEnrolmentFileAvailable = Boolean(process.env.FZ_ENROL_TOKEN_FILE && existsSync10(process.env.FZ_ENROL_TOKEN_FILE));
|
|
3046
3296
|
if (!binding && (enrolmentCredentialAvailable || legacyEnrolmentFileAvailable) && process.env.FZ_API) {
|
|
3047
3297
|
binding = await enrolGuestIdentity({
|
|
3048
3298
|
apiUrl: process.env.FZ_API,
|
|
@@ -3052,7 +3302,8 @@ if (import.meta.main) {
|
|
|
3052
3302
|
nodeKey,
|
|
3053
3303
|
keys,
|
|
3054
3304
|
label: process.env.FZ_NODE_LABEL,
|
|
3055
|
-
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ?
|
|
3305
|
+
gitDeployPublicKey: process.env.FZ_GIT_PUBLIC_KEY_FILE ? readFileSync5(process.env.FZ_GIT_PUBLIC_KEY_FILE, "utf8").trim() : undefined,
|
|
3306
|
+
privateNetworkAttachment: privateNetworkAttachmentFromEnvironment()
|
|
3056
3307
|
});
|
|
3057
3308
|
console.log(`[agent] enrolled ${binding.computeReference} in project ${binding.projectKey}/${binding.environmentKey}`);
|
|
3058
3309
|
}
|
|
@@ -3090,6 +3341,19 @@ if (import.meta.main) {
|
|
|
3090
3341
|
}) : undefined;
|
|
3091
3342
|
if (attestationLoop)
|
|
3092
3343
|
console.log("[agent] periodic SEV-SNP attestation enabled");
|
|
3344
|
+
const migrationEnabled = process.env.FZ_MIGRATION_PULL === "true";
|
|
3345
|
+
if (migrationEnabled && (!binding || !nodeApiUrl)) {
|
|
3346
|
+
throw new Error("agent: outbound lifecycle work requires a persisted compute enrolment binding");
|
|
3347
|
+
}
|
|
3348
|
+
const migrationPull = migrationEnabled ? startMigrationPull({
|
|
3349
|
+
apiUrl: nodeApiUrl,
|
|
3350
|
+
nodeKey,
|
|
3351
|
+
keys,
|
|
3352
|
+
run: (claim) => requestLifecycleAction(claim, process.env.FZ_LIFECYCLE_HELPER_SOCKET ?? DEFAULT_LIFECYCLE_HELPER_SOCKET),
|
|
3353
|
+
onEvent: (event, detail) => console.log(`[agent] migration ${event}${detail ? ` ${JSON.stringify(detail)}` : ""}`)
|
|
3354
|
+
}) : undefined;
|
|
3355
|
+
if (migrationPull)
|
|
3356
|
+
console.log("[agent] PQ-authenticated outbound lifecycle claims enabled");
|
|
3093
3357
|
const systemdDeploymentSecrets = createSystemdDeploymentSecrets(process.env.FZ_DEPLOY_SYSTEMD_SECRETS);
|
|
3094
3358
|
const deploymentSecrets = secretCache ? {
|
|
3095
3359
|
async get(name) {
|
|
@@ -3175,8 +3439,9 @@ if (import.meta.main) {
|
|
|
3175
3439
|
const pullDrain = pull?.stop() ?? Promise.resolve();
|
|
3176
3440
|
const vaultDrain = vaultSync?.stop() ?? Promise.resolve();
|
|
3177
3441
|
const attestationDrain = attestationLoop?.stop() ?? Promise.resolve();
|
|
3442
|
+
const migrationDrain = migrationPull?.stop() ?? Promise.resolve();
|
|
3178
3443
|
const pullDrained = pull ? await settleWithin(pullDrain, remaining()) : true;
|
|
3179
|
-
const backgroundDrained = await settleWithin(Promise.all([vaultDrain, attestationDrain]), remaining());
|
|
3444
|
+
const backgroundDrained = await settleWithin(Promise.all([vaultDrain, attestationDrain, migrationDrain]), remaining());
|
|
3180
3445
|
const managerReports = await Promise.all([...new Set(managers.values())].map((manager) => manager.stop(remaining())));
|
|
3181
3446
|
const socketClosed = await closeServerWithin(server, remaining());
|
|
3182
3447
|
const timedOut = managerReports.some((report) => report.timedOut);
|
|
@@ -3193,7 +3458,7 @@ if (import.meta.main) {
|
|
|
3193
3458
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
3194
3459
|
} else {
|
|
3195
3460
|
console.log("[agent] signing/vault mode only; deployment root and pull are not configured");
|
|
3196
|
-
if (vaultSync || attestationLoop) {
|
|
3461
|
+
if (vaultSync || attestationLoop || migrationPull) {
|
|
3197
3462
|
let stopping = false;
|
|
3198
3463
|
const stop = async (signal) => {
|
|
3199
3464
|
if (stopping)
|
|
@@ -3205,7 +3470,8 @@ if (import.meta.main) {
|
|
|
3205
3470
|
console.log(`[agent] ${signal}: stopping background intake and draining`);
|
|
3206
3471
|
const backgroundDrained = await settleWithin(Promise.all([
|
|
3207
3472
|
vaultSync?.stop() ?? Promise.resolve(),
|
|
3208
|
-
attestationLoop?.stop() ?? Promise.resolve()
|
|
3473
|
+
attestationLoop?.stop() ?? Promise.resolve(),
|
|
3474
|
+
migrationPull?.stop() ?? Promise.resolve()
|
|
3209
3475
|
]), remaining());
|
|
3210
3476
|
const socketClosed = await closeServerWithin(server, remaining());
|
|
3211
3477
|
console.log(`[agent] drain ${JSON.stringify({ backgroundDrained, socketClosed })}`);
|
|
@@ -3218,34 +3484,43 @@ if (import.meta.main) {
|
|
|
3218
3484
|
}
|
|
3219
3485
|
export {
|
|
3220
3486
|
validateMetalProfile,
|
|
3487
|
+
validateLifecycleProfile,
|
|
3221
3488
|
tenantNodeApiUrl,
|
|
3222
3489
|
startProvisioningPull,
|
|
3223
3490
|
startNodeVaultSync,
|
|
3224
3491
|
startNodeAttestation,
|
|
3492
|
+
startMigrationPull,
|
|
3225
3493
|
startMetalHelper,
|
|
3494
|
+
startLifecycleHelper,
|
|
3226
3495
|
startDeploymentRunner,
|
|
3227
3496
|
startDeploymentPull,
|
|
3228
3497
|
startControlServer,
|
|
3229
3498
|
startAgent,
|
|
3230
3499
|
runAgent,
|
|
3231
3500
|
requestMetalProvision,
|
|
3501
|
+
requestLifecycleAction,
|
|
3232
3502
|
requestDeploymentCommand,
|
|
3233
3503
|
requestControl,
|
|
3504
|
+
renderWarpMdm,
|
|
3234
3505
|
removeMetalGuest,
|
|
3235
3506
|
pullProvisioningOnce,
|
|
3507
|
+
pullMigrationOnce,
|
|
3236
3508
|
pullDeploymentOnce,
|
|
3237
3509
|
provisionMetalGuest,
|
|
3238
3510
|
projectVaultCoordinate,
|
|
3239
3511
|
projectVaultCacheKey,
|
|
3240
3512
|
metalHousekeepingDropIn,
|
|
3241
3513
|
metalGuestSliceUnit,
|
|
3514
|
+
materializeWarpMdm,
|
|
3242
3515
|
loadTextCredential,
|
|
3243
3516
|
loadSeedCredential,
|
|
3244
3517
|
loadOrCreateSeed,
|
|
3518
|
+
loadLifecycleProfile,
|
|
3245
3519
|
loadGuestBinding,
|
|
3246
3520
|
handleRequest,
|
|
3247
3521
|
handleApplicationRequest,
|
|
3248
3522
|
guestNameFor,
|
|
3523
|
+
executeLifecycleAction,
|
|
3249
3524
|
enrolGuestIdentity,
|
|
3250
3525
|
createSystemdDeploymentSecrets,
|
|
3251
3526
|
createSnpAttestationSource,
|
|
@@ -3265,6 +3540,7 @@ export {
|
|
|
3265
3540
|
DEFAULT_SEED_PATH,
|
|
3266
3541
|
DEFAULT_SEED_CREDENTIAL,
|
|
3267
3542
|
DEFAULT_METAL_HELPER_SOCKET,
|
|
3543
|
+
DEFAULT_LIFECYCLE_HELPER_SOCKET,
|
|
3268
3544
|
DEFAULT_ENROLMENT_STATE_PATH,
|
|
3269
3545
|
DEFAULT_DEPLOYMENT_RUNNER_SOCKET,
|
|
3270
3546
|
DEFAULT_CONTROL_SOCKET,
|