feat: verify server pull access before deployment

This commit is contained in:
NuklearRabbit
2026-07-29 16:17:54 +02:00
parent 430d40354b
commit ae6c41ff90
8 changed files with 196 additions and 16 deletions
+7 -7
View File
@@ -83,7 +83,7 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
efe2f75ec8bfdbad8e2ee68d0f2c4b412ba460c6b841163d7853f56d5b7b63ea 130468 package-lock.json efe2f75ec8bfdbad8e2ee68d0f2c4b412ba460c6b841163d7853f56d5b7b63ea 130468 package-lock.json
f4ac31dd2266494085409ad5fc8bc440aa871296f7c64042495b6b14dec49084 4427 package.json f4ac31dd2266494085409ad5fc8bc440aa871296f7c64042495b6b14dec49084 4427 package.json
3fb463572c62c94bec2d0675c73f8258e02d52cd9632b9fc99d4f24d137caf4e 10392 preload.cjs 82865e814a82628b778c53ec83bcda26bf1109d8bb14c11208530089f4dc96db 10522 preload.cjs
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1 688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
@@ -91,7 +91,7 @@ f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1 00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-update.ps1
f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1 f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 scripts/apply-source-update.ps1
33a040b12fc5deab05f9df5f71986d0bb71b91f15a8cd25e9ab3cf7ca9a26766 4212 scripts/audit-installed-deployments.cjs fca922d7d1de598a0153f3828300c7d0327e0189c06a53a23d9fc33b36d45d91 4741 scripts/audit-installed-deployments.cjs
6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs 6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs
f6f89e893195b9c8ef0ff01e256005b9b3cd7d4a278722979a5e0e616c86a89f 1733 scripts/generate-source-manifest.mjs f6f89e893195b9c8ef0ff01e256005b9b3cd7d4a278722979a5e0e616c86a89f 1733 scripts/generate-source-manifest.mjs
842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs 842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs
@@ -110,7 +110,7 @@ a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
3ce45837099ac7bddc024974bd839575b4b765a7df9055e7d45ef889dc85bf7f 15623 src/main/git-validator-service.cjs 3ce45837099ac7bddc024974bd839575b4b765a7df9055e7d45ef889dc85bf7f 15623 src/main/git-validator-service.cjs
d85d5b1abb35e8bd7f1273a697914eeaf96567d3d4ce3f55f364765d35ea4ad9 19900 src/main/gitea-service.cjs d85d5b1abb35e8bd7f1273a697914eeaf96567d3d4ce3f55f364765d35ea4ad9 19900 src/main/gitea-service.cjs
127678735714595472c4a62188404284c6ae9cab43412b178c71836c9f34e767 50922 src/main/ipc.cjs a8c677d5ee1343f6ff230e8a30c2ecee9f47c56c89518e34f25755d6c0c3b685 51410 src/main/ipc.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
@@ -118,15 +118,15 @@ e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs 52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs
d389e195eb48c5b1b801163c9bcf15e289cf312324aaaa8355b487834503a0fe 141995 src/main/unraid-deployment-service.cjs 80426e8bdd7259c6ed47d1acb9f6cbbbe4b1075f74c8ce5fd759044d9af09d59 149912 src/main/unraid-deployment-service.cjs
b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs
ea82d232b6a31218aeea3a807e4d490341474aa72d5d9c08db99cc3355fdbeb6 239190 src/renderer/app.js 7c0ed496051f33a65312f7acf132eb288ca8260ce4b0b1d4b6f607c1cfac4071 240362 src/renderer/app.js
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
fb7ed47f9aac50d9259d7d3c3bb2010c7bfdd2fe8e8e47ca2744bb22f0057d54 830 src/renderer/index.html fb7ed47f9aac50d9259d7d3c3bb2010c7bfdd2fe8e8e47ca2744bb22f0057d54 830 src/renderer/index.html
81a15afced5b8782346d13a6ccbf3ba576a284d0e756eec6c704735026ee4b4c 61728 src/renderer/mock-bridge.js ae8d6778614077c5f46032035a10fb9803219c14e8bfabefacf1affdec06fb71 62802 src/renderer/mock-bridge.js
34ee56ed08dcd1c2295985b9bb419b981598a2699ee8e65394d86f4177f4797b 77647 src/renderer/styles.css 34ee56ed08dcd1c2295985b9bb419b981598a2699ee8e65394d86f4177f4797b 77647 src/renderer/styles.css
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
@@ -165,7 +165,7 @@ bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs 2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
80e951961f015417160f8ba40b2e5a2fd85177b025cbae66ae482b962e58e8c4 40498 tests/unraid-deployment.test.mjs dde71af691e7e6f1b6a24c1dd5f436d3473ff94db31f987b9bbceeea5661121a 43396 tests/unraid-deployment.test.mjs
4abe7b2fc113c486f35f15c2d629c5b4f24589eada718c4ea58f93551c77d5eb 17777 tests/update-service.test.mjs 4abe7b2fc113c486f35f15c2d629c5b4f24589eada718c4ea58f93551c77d5eb 17777 tests/update-service.test.mjs
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
+1
View File
@@ -125,6 +125,7 @@ contextBridge.exposeInMainWorld(
applyServerReconciliation: (serverId, planId) => invoke('deployment:apply-server-reconciliation', { serverId, planId }), applyServerReconciliation: (serverId, planId) => invoke('deployment:apply-server-reconciliation', { serverId, planId }),
linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'server-git', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }), linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'server-git', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }),
configureServerGitAccess: (repository, profileId) => invoke('deployment:configure-server-git-access', { repository, profileId }), configureServerGitAccess: (repository, profileId) => invoke('deployment:configure-server-git-access', { repository, profileId }),
verifyServerGitProfile: (repository, profileId) => invoke('deployment:verify-server-git-profile', { repository, profileId }),
applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }), applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }),
reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }), reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }),
refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }), refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }),
+14 -6
View File
@@ -16,7 +16,6 @@ app.setPath("userData", userDataPath);
app.whenReady().then(async () => { app.whenReady().then(async () => {
try { try {
const reconcile = process.argv.includes("--reconcile");
const configureAccess = process.argv.includes("--configure-access"); const configureAccess = process.argv.includes("--configure-access");
const repositoryFilter = new Set(String(process.argv.find((value) => value.startsWith("--repository=")) || "") const repositoryFilter = new Set(String(process.argv.find((value) => value.startsWith("--repository=")) || "")
.slice("--repository=".length).toLowerCase().split(",").map((value) => value.trim()).filter(Boolean)); .slice("--repository=".length).toLowerCase().split(",").map((value) => value.trim()).filter(Boolean));
@@ -29,20 +28,29 @@ app.whenReady().then(async () => {
const deployments = new UnraidDeploymentService({ store, ssh, git, gitea, sourcePath: path.resolve(__dirname, "..") }); const deployments = new UnraidDeploymentService({ store, ssh, git, gitea, sourcePath: path.resolve(__dirname, "..") });
const reports = []; const reports = [];
for (const server of store.data.servers || []) { for (const server of store.data.servers || []) {
const report = await deployments.scanServerInventory(server.id, repositories, { autoLink: reconcile }); const report = await deployments.scanServerInventory(server.id, repositories);
const access = []; const access = [];
const seenProfiles = new Set();
for (const repository of repositories) {
for (const profile of repository.deploymentProfiles || store.getDeploymentProfiles(repository.fullName) || []) {
if (profile.serverId !== server.id || profile.deploymentMode !== "server-git" || seenProfiles.has(profile.id)) continue;
seenProfiles.add(profile.id);
try {
access.push(await deployments.verifyServerGitProfile({ repository, profileId: profile.id }));
} catch (error) {
access.push({ repository: repository.fullName, profileId: profile.id, readiness: "Verification incomplete", ready: false, error: error.message });
}
}
}
if (configureAccess) { if (configureAccess) {
const refreshedRepositories = await new RepositoryService(store, git, gitea).refresh(); const refreshedRepositories = await new RepositoryService(store, git, gitea).refresh();
const seenProfiles = new Set();
for (const workload of report.workloads.filter((item) => item.runtime?.running && item.link?.profileId && item.link?.repositoryFullName)) { for (const workload of report.workloads.filter((item) => item.runtime?.running && item.link?.profileId && item.link?.repositoryFullName)) {
if (seenProfiles.has(workload.link.profileId)) continue;
seenProfiles.add(workload.link.profileId);
const repository = refreshedRepositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase()); const repository = refreshedRepositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
if (!repository) continue; if (!repository) continue;
if (repositoryFilter.size && !repositoryFilter.has(String(repository.fullName).toLowerCase())) continue; if (repositoryFilter.size && !repositoryFilter.has(String(repository.fullName).toLowerCase())) continue;
try { try {
const configured = await deployments.configureServerGitAccess({ repository, profileId: workload.link.profileId }); const configured = await deployments.configureServerGitAccess({ repository, profileId: workload.link.profileId });
access.push({ repository: repository.fullName, ready: true, created: configured.created, remoteSha: configured.remoteSha }); access.push({ repository: repository.fullName, profileId: workload.link.profileId, action: "configured", ready: true, created: configured.created, remoteSha: configured.remoteSha });
await new Promise((resolve) => setTimeout(resolve, 1500)); await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (error) { } catch (error) {
access.push({ repository: repository.fullName, ready: false, error: error.message }); access.push({ repository: repository.fullName, ready: false, error: error.message });
+12
View File
@@ -1214,6 +1214,18 @@ function registerIpc({
}); });
return { ...result, state: store.getPublicState() }; return { ...result, state: store.getPublicState() };
}); });
register("deployment:verify-server-git-profile", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const result = await unraid.verifyServerGitProfile({ repository: current, profileId });
await audit.append("deployment.server-git-access-verified", {
repository: current.fullName,
profileId,
readiness: result.readiness,
ready: result.ready,
checkedAt: result.checkedAt,
});
return result;
});
register("deployment:discover-server-workloads", async () => { register("deployment:discover-server-workloads", async () => {
const repositoryList = await repositories.refresh(); const repositoryList = await repositories.refresh();
const remoteRepositories = repositoryList.filter( const remoteRepositories = repositoryList.filter(
+74 -2
View File
@@ -593,14 +593,75 @@ exit 45`),
const remote = this.serverGitRemote(repository, profile); const remote = this.serverGitRemote(repository, profile);
const credentials = this.serverGitCredentialPaths(repository, server); const credentials = this.serverGitCredentialPaths(repository, server);
const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim(); const trustedHostFingerprint = String(profile.serverGitAccess?.hostFingerprint || "").trim();
const command = `[ -s ${shellQuote(credentials.privateKey)} ] && [ -s ${shellQuote(credentials.knownHosts)} ] && actual_host_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.knownHosts)} -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" && { [ -z ${shellQuote(trustedHostFingerprint)} ] || [ "$actual_host_fingerprint" = ${shellQuote(trustedHostFingerprint)} ]; } && ${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)}`; const trustedKeyFingerprint = String(profile.serverGitAccess?.keyFingerprint || "").trim();
const command = `[ -s ${shellQuote(credentials.privateKey)} ] && [ -s ${shellQuote(credentials.publicKey)} ] && [ -s ${shellQuote(credentials.knownHosts)} ] && actual_host_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.knownHosts)} -E sha256 2>/dev/null | awk '{print $2}' | sort -u | paste -sd, -)" && actual_key_fingerprint="$(ssh-keygen -lf ${shellQuote(credentials.publicKey)} -E sha256 2>/dev/null | awk '{print $2}')" && { [ -z ${shellQuote(trustedHostFingerprint)} ] || [ "$actual_host_fingerprint" = ${shellQuote(trustedHostFingerprint)} ]; } && { [ -z ${shellQuote(trustedKeyFingerprint)} ] || [ "$actual_key_fingerprint" = ${shellQuote(trustedKeyFingerprint)} ]; } && remote_output="$(${this.serverGitEnvironment(repository, profile, server)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})" && remote_sha="$(printf '%s' "$remote_output" | awk 'NR==1 {print $1}')" && printf '__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=%s\nkeyFingerprint=%s\nhostFingerprint=%s\n' "$remote_sha" "$actual_key_fingerprint" "$actual_host_fingerprint"`;
const result = await this.ssh.exec(server.id, bash(command), { timeout: 45_000, maxOutput: 256 * 1024 }); const result = await this.ssh.exec(server.id, bash(command), { timeout: 45_000, maxOutput: 256 * 1024 });
return { ready: true, remoteSha: String(result.stdout || "").trim().split(/\s+/)[0] || null }; const output = String(result.stdout || "");
const marker = output.lastIndexOf("__FORGEFLOW_SERVER_GIT_PROBE__");
if (marker < 0) throw new Error("The server pull probe did not return verifiable fingerprint evidence.");
const fields = Object.fromEntries(output.slice(marker + "__FORGEFLOW_SERVER_GIT_PROBE__".length).trim().split(/\r?\n/).map((line) => {
const separator = line.indexOf("=");
return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""];
}));
return { ready: true, remoteSha: fields.remoteSha || null, keyFingerprint: fields.keyFingerprint || null, hostFingerprint: fields.hostFingerprint || null };
} catch (error) { } catch (error) {
return { ready: false, error: error.message }; return { ready: false, error: error.message };
} }
} }
async verifyServerGitProfile({ repository, profileId }) {
const { profile, server, remotePath } = this.resolve(repository, profileId);
const checks = [];
const add = (id, label, status, detail, evidence = {}) => checks.push({ id, label, status, detail, evidence });
if (profile.deploymentMode === "monitor-only") {
add("mode", "Deployment mode", "warning", "This profile is monitoring only and cannot deploy.");
return { readiness: "Monitoring only", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks };
}
if (profile.deploymentMode !== "server-git") {
add("mode", "Deployment mode", "unsupported", "Read-only server-pull verification applies only to Server pull profiles.");
return { readiness: "Unsupported", ready: false, checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, checks };
}
let branchSha = null;
try {
const [owner, repo] = String(repository.fullName || "").split("/");
const branch = await this.gitea.getBranch(owner, repo, profile.branch);
branchSha = branch?.commit?.id || branch?.commit?.sha || null;
add("remote-branch", "Gitea branch", branchSha ? "pass" : "fail", branchSha ? `${profile.branch} at ${branchSha}` : `${profile.branch} did not return a commit SHA.`, { branch: profile.branch, sha: branchSha });
const keys = await this.gitea.listDeployKeys(owner, repo);
const keyId = Number(profile.serverGitAccess?.deployKeyId);
const key = keys.find((item) => Number(item.id) === keyId);
add("deploy-key-scope", "Repository deploy key", key?.read_only === true ? "pass" : "fail", !key ? "The configured deploy key is no longer present in Gitea." : key.read_only === true ? `Key ${key.id} is repository-scoped and read-only.` : `Key ${key.id} has write access and is blocked.`, { keyId: key?.id || keyId || null, readOnly: key?.read_only === true });
} catch (error) {
add("gitea-access", "Gitea verification", "fail", error.message);
}
const access = await this.probeServerGitAccess({ repository, profile, server });
add("server-git-access", "Unraid to Gitea", access.ready ? "pass" : "fail", access.ready ? `Exact branch access verified at ${String(access.remoteSha || "unknown").slice(0, 12)}.` : access.error, access);
let inspection = null;
try {
inspection = await this.inspect({ repository, profileId });
const expectedCompose = profile.generatedCompose ? [".forgeflow/compose.forgeflow.yml"] : this.deploymentComposeFiles(profile);
const composePresent = !inspection.exists || expectedCompose.every((file) => inspection.composeFiles.includes(file));
add("deployment-directory", "Deployment directory", inspection.exists ? "pass" : "warning", inspection.exists ? remotePath : `${remotePath} will be created on first deployment.`, { remotePath, exists: inspection.exists });
add("compose", "Compose configuration", composePresent ? "pass" : "warning", composePresent ? expectedCompose.join(", ") : `Expected after deployment: ${expectedCompose.join(", ")}.`, { files: expectedCompose });
add("preserved-paths", "Preserved runtime paths", "pass", (profile.preservePaths || []).length ? profile.preservePaths.join(", ") : "No preserved runtime paths configured.", { paths: profile.preservePaths || [] });
add("environment-requirements", "Environment requirements", "pass", (profile.detectedMetadata?.envNames || []).length ? `${profile.detectedMetadata.envNames.length} variable name(s) detected; values remain hidden.` : "No environment variable names were detected in server metadata.", { names: profile.detectedMetadata?.envNames || [] });
} catch (error) {
add("server-inspection", "Server inspection", "fail", error.message);
}
const state = this.store.getDeploymentState(profile.id) || {};
const liveSha = state.liveSha || inspection?.head || null;
const running = state.containerRunning;
const healthy = state.healthy;
add("live-commit", "Live server commit", liveSha ? "pass" : "warning", liveSha || "No verifiable live commit is currently recorded.", { liveSha });
add("commit-parity", "Gitea and server parity", branchSha && liveSha && branchSha === liveSha ? "pass" : branchSha && liveSha ? "warning" : "incomplete", branchSha && liveSha ? branchSha === liveSha ? "The exact Gitea commit is live." : `Live ${String(liveSha).slice(0, 12)} differs from Gitea ${String(branchSha).slice(0, 12)}.` : "Parity cannot be proven until both SHAs are available.", { branchSha, liveSha });
add("runtime", "Container runtime", running === true ? "pass" : running === false ? "fail" : "incomplete", running === true ? "The linked container is running." : running === false ? "The linked container is stopped." : "Runtime state has not been verified.");
add("health", "Runtime health", healthy === true ? "pass" : healthy === false ? "fail" : "incomplete", healthy === true ? "Runtime health passed." : healthy === false ? "Runtime health failed." : "No conclusive runtime health evidence is available.");
const failed = checks.some((item) => item.status === "fail");
const incomplete = checks.some((item) => ["warning", "incomplete", "unsupported"].includes(item.status));
const readiness = failed ? (checks.some((item) => item.id.includes("access") || item.id.includes("key")) ? "Access failed" : checks.some((item) => item.id === "runtime" || item.id === "health") ? "Runtime unhealthy" : "Configuration required") : incomplete ? (branchSha && liveSha && branchSha !== liveSha ? "Commit mismatch" : "Verification incomplete") : "Ready";
return { readiness, ready: readiness === "Ready" || readiness === "Commit mismatch", checkedAt: new Date().toISOString(), repository: repository.fullName, profileId, server: { id: server.id, name: server.name }, remotePath, branch: profile.branch, branchSha, liveSha, checks };
}
permissionTargets(profile, server, remotePath) { permissionTargets(profile, server, remotePath) {
const targets = [ const targets = [
{ {
@@ -2707,6 +2768,17 @@ printf 'digest=%s\n' "$digest"
async deploy({ repository, profileId, sha }) { async deploy({ repository, profileId, sha }) {
const targetSha = assertFullCommitSha(sha); const targetSha = assertFullCommitSha(sha);
const { profile, server, remotePath } = this.resolve(repository, profileId); const { profile, server, remotePath } = this.resolve(repository, profileId);
if (profile.deploymentMode === "server-git") {
const verification = await this.verifyServerGitProfile({ repository, profileId });
const requiredChecks = ["remote-branch", "deploy-key-scope", "server-git-access"];
const blocked = verification.checks.filter((check) => requiredChecks.includes(check.id) && check.status !== "pass");
if (blocked.length || !verification.branchSha) {
const error = new Error(`Server pull verification failed: ${blocked.map((check) => check.detail).join("; ") || "the target branch could not be proven"}`);
error.code = "SERVER_GIT_VERIFICATION_FAILED";
error.verification = verification;
throw error;
}
}
const preflight = await this.preflight({ repository, profileId, sha: targetSha }); const preflight = await this.preflight({ repository, profileId, sha: targetSha });
if (!preflight.summary.ready) { if (!preflight.summary.ready) {
const error = new Error(`SSH deployment preflight failed: ${preflight.summary.blocking.join(", ")}`); const error = new Error(`SSH deployment preflight failed: ${preflight.summary.blocking.join(", ")}`);
+19 -1
View File
@@ -156,6 +156,7 @@ const ui = {
setupStep: 0, setupStep: 0,
systemPreflight: null, systemPreflight: null,
deploymentPreflight: null, deploymentPreflight: null,
serverGitVerifications: {},
diagnosticsStatus: null, diagnosticsStatus: null,
troubleshooter: null, troubleshooter: null,
deploymentDiscovery: null, deploymentDiscovery: null,
@@ -978,6 +979,7 @@ function renderProfileCard(repository, profile, compact = false) {
const health = environmentState(profile); const health = environmentState(profile);
const isSsh = profile.provider === "ssh-unraid"; const isSsh = profile.provider === "ssh-unraid";
const mode = deploymentMode(profile); const mode = deploymentMode(profile);
const verification = ui.serverGitVerifications[profile.id];
const targetSha = deploymentTargetSha(repository, profile); const targetSha = deploymentTargetSha(repository, profile);
const ready = canDeploy(repository, profile); const ready = canDeploy(repository, profile);
const modeLabel = { const modeLabel = {
@@ -1017,7 +1019,7 @@ function renderProfileCard(repository, profile, compact = false) {
? mode === "server-git" ? `Gitea ${state.giteaSha ? shortSha(state.giteaSha) : "refresh required"}` : "Committed local HEAD" ? mode === "server-git" ? `Gitea ${state.giteaSha ? shortSha(state.giteaSha) : "refresh required"}` : "Committed local HEAD"
: state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"; : state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare";
const serverAccessAction = isSsh && mode === "server-git" const serverAccessAction = isSsh && mode === "server-git"
? `<button class="button" data-action="configure-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Configure Gitea access</button>` ? `<button class="button" data-action="verify-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Verify server pull</button><button class="button" data-action="configure-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Configure Gitea access</button>`
: ""; : "";
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Deploy source</span><strong>${escapeHtml(sourceLabel)}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong><span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button>${isSsh ? `<button class="button" data-action="repair-deployment-write-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}Check / fix write access</button>` : ""}${serverAccessAction}<button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${managesDockerMan ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan integration" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(shortSha(targetSha))}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`; return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Deploy source</span><strong>${escapeHtml(sourceLabel)}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong><span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button>${isSsh ? `<button class="button" data-action="repair-deployment-write-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}Check / fix write access</button>` : ""}${serverAccessAction}<button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${managesDockerMan ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan integration" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(shortSha(targetSha))}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
} }
@@ -2773,6 +2775,22 @@ app.addEventListener("click", async (event) => {
} else if (action === "run-deployment-preflight") { } else if (action === "run-deployment-preflight") {
if (!repository) repository = profileRepository(target.dataset.profileId); if (!repository) repository = profileRepository(target.dataset.profileId);
await runDeploymentPreflight(repository, target.dataset.profileId); await runDeploymentPreflight(repository, target.dataset.profileId);
} else if (action === "verify-server-git-access") {
const profileId = target.dataset.profileId || ui.selectedProfileId;
if (!repository) repository = profileRepository(profileId);
if (!repository || !profileId) return;
setLoading(true, "Verifying Gitea, deploy key, server commit and runtime…");
try {
const result = await window.forgeflow.verifyServerGitProfile(repository, profileId);
ui.serverGitVerifications[profileId] = result;
render();
const failures = result.checks.filter((check) => check.status === "fail");
showToast(result.readiness, failures[0]?.detail || `Verified ${result.checks.length} server-pull checks without changing the server.`, result.ready ? "success" : "warning");
} catch (error) {
showToast("Server-pull verification failed", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "configure-server-git-access") { } else if (action === "configure-server-git-access") {
const profileId = target.dataset.profileId || ui.selectedProfileId; const profileId = target.dataset.profileId || ui.selectedProfileId;
if (!repository) repository = profileRepository(profileId); if (!repository) repository = profileRepository(profileId);
+21
View File
@@ -1585,6 +1585,27 @@
syncState(); syncState();
return { profile: clone(target), created: true, remoteSha: target.state?.giteaSha || repo.localStatus?.head }; return { profile: clone(target), created: true, remoteSha: target.state?.giteaSha || repo.localStatus?.head };
}, },
async verifyServerGitProfile(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName);
const target = repo?.deploymentProfiles.find((item) => item.id === profileId);
if (!target) throw new Error("Deployment profile not found.");
const branchSha = target.state?.giteaSha || repo.localStatus?.head || null;
const liveSha = target.state?.liveSha || null;
return {
readiness: branchSha && liveSha === branchSha ? "Ready" : "Commit mismatch",
ready: true,
checkedAt: iso(),
repository: repo.fullName,
profileId,
branchSha,
liveSha,
checks: [
{ id: "remote-branch", label: "Gitea branch", status: "pass", detail: "Exact branch resolved." },
{ id: "deploy-key-scope", label: "Repository deploy key", status: "pass", detail: "Repository-scoped and read-only." },
{ id: "server-git-access", label: "Unraid to Gitea", status: "pass", detail: "Pinned SSH access verified." },
],
};
},
async refreshOperations(operationId = null) { async refreshOperations(operationId = null) {
await wait(300); await wait(300);
if (operationId) { if (operationId) {
+48
View File
@@ -80,6 +80,54 @@ test("server pull provisions a pinned repository-scoped key and records access m
assert.equal(result.remoteSha, "a".repeat(40)); assert.equal(result.remoteSha, "a".repeat(40));
}); });
test("server pull verification proves a repository-scoped read-only key and exact commit parity", async () => {
const sha = "c".repeat(40);
const profile = {
id: "profile-verify", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio",
environment: "production", branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"],
serverGitAccess: { deployKeyId: 17, keyFingerprint: "SHA256:key", hostFingerprint: "SHA256:host" },
};
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }),
getDeploymentState: () => ({ liveSha: sha, containerRunning: true, healthy: true }),
},
ssh: { exec: async () => ({ stdout: `__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=${sha}\nkeyFingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` }) },
gitea: {
getBranch: async () => ({ commit: { id: sha } }),
listDeployKeys: async () => [{ id: 17, read_only: true }],
},
});
service.inspect = async () => ({ exists: true, composeFiles: ["compose.yml"], head: sha });
const report = await service.verifyServerGitProfile({ repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.test:Jens/Portfolio.git" }, profileId: profile.id });
assert.equal(report.readiness, "Ready");
assert.equal(report.ready, true);
assert.equal(report.checks.find((check) => check.id === "deploy-key-scope").status, "pass");
});
test("server pull verification blocks a writable Gitea deploy key", async () => {
const sha = "d".repeat(40);
const profile = {
id: "profile-writable", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio",
environment: "production", branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"],
serverGitAccess: { deployKeyId: 18, keyFingerprint: "SHA256:key", hostFingerprint: "SHA256:host" },
};
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }),
getDeploymentState: () => ({ liveSha: sha, containerRunning: true, healthy: true }),
},
ssh: { exec: async () => ({ stdout: `__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=${sha}\nkeyFingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` }) },
gitea: { getBranch: async () => ({ commit: { id: sha } }), listDeployKeys: async () => [{ id: 18, read_only: false }] },
});
service.inspect = async () => ({ exists: true, composeFiles: ["compose.yml"], head: sha });
const report = await service.verifyServerGitProfile({ repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.test:Jens/Portfolio.git" }, profileId: profile.id });
assert.equal(report.readiness, "Access failed");
assert.equal(report.ready, false);
});
test("server workload inventory links running containers to exact Gitea checkouts", () => { test("server workload inventory links running containers to exact Gitea checkouts", () => {
const b64 = (value) => Buffer.from(value).toString("base64"); const b64 = (value) => Buffer.from(value).toString("base64");
const inspect = JSON.stringify([ const inspect = JSON.stringify([