From ae6c41ff9038c5d298df9d67c0ade1d7b37e68ef Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:17:54 +0200 Subject: [PATCH] feat: verify server pull access before deployment --- SOURCE_MANIFEST.txt | 14 ++--- preload.cjs | 1 + scripts/audit-installed-deployments.cjs | 20 +++++-- src/main/ipc.cjs | 12 ++++ src/main/unraid-deployment-service.cjs | 76 ++++++++++++++++++++++++- src/renderer/app.js | 20 ++++++- src/renderer/mock-bridge.js | 21 +++++++ tests/unraid-deployment.test.mjs | 48 ++++++++++++++++ 8 files changed, 196 insertions(+), 16 deletions(-) diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 5adb1a8..0077f96 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -83,7 +83,7 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md efe2f75ec8bfdbad8e2ee68d0f2c4b412ba460c6b841163d7853f56d5b7b63ea 130468 package-lock.json f4ac31dd2266494085409ad5fc8bc440aa871296f7c64042495b6b14dec49084 4427 package.json -3fb463572c62c94bec2d0675c73f8258e02d52cd9632b9fc99d4f24d137caf4e 10392 preload.cjs +82865e814a82628b778c53ec83bcda26bf1109d8bb14c11208530089f4dc96db 10522 preload.cjs abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1 @@ -91,7 +91,7 @@ f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 00d57bda5af8c8eda294b72d18b318f024a307b81b0d9205a0821f5240151e31 3814 scripts/apply-binary-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 f6f89e893195b9c8ef0ff01e256005b9b3cd7d4a278722979a5e0e616c86a89f 1733 scripts/generate-source-manifest.mjs 842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs @@ -110,7 +110,7 @@ a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs 3ce45837099ac7bddc024974bd839575b4b765a7df9055e7d45ef889dc85bf7f 15623 src/main/git-validator-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 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs @@ -118,15 +118,15 @@ e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs 52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.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 -ea82d232b6a31218aeea3a807e4d490341474aa72d5d9c08db99cc3355fdbeb6 239190 src/renderer/app.js +7c0ed496051f33a65312f7acf132eb288ca8260ce4b0b1d4b6f607c1cfac4071 240362 src/renderer/app.js 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png 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 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs @@ -165,7 +165,7 @@ bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.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 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs diff --git a/preload.cjs b/preload.cjs index 42fdf1b..db35698 100644 --- a/preload.cjs +++ b/preload.cjs @@ -125,6 +125,7 @@ contextBridge.exposeInMainWorld( 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 }), 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 }), reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }), refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }), diff --git a/scripts/audit-installed-deployments.cjs b/scripts/audit-installed-deployments.cjs index aeab802..2d205e7 100644 --- a/scripts/audit-installed-deployments.cjs +++ b/scripts/audit-installed-deployments.cjs @@ -16,7 +16,6 @@ app.setPath("userData", userDataPath); app.whenReady().then(async () => { try { - const reconcile = process.argv.includes("--reconcile"); const configureAccess = process.argv.includes("--configure-access"); const repositoryFilter = new Set(String(process.argv.find((value) => value.startsWith("--repository=")) || "") .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 reports = []; 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 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) { 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)) { - 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()); if (!repository) continue; if (repositoryFilter.size && !repositoryFilter.has(String(repository.fullName).toLowerCase())) continue; try { 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)); } catch (error) { access.push({ repository: repository.fullName, ready: false, error: error.message }); diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs index 33b61a9..1d9e2d0 100644 --- a/src/main/ipc.cjs +++ b/src/main/ipc.cjs @@ -1214,6 +1214,18 @@ function registerIpc({ }); 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 () => { const repositoryList = await repositories.refresh(); const remoteRepositories = repositoryList.filter( diff --git a/src/main/unraid-deployment-service.cjs b/src/main/unraid-deployment-service.cjs index 6fd76a4..eb08cf2 100644 --- a/src/main/unraid-deployment-service.cjs +++ b/src/main/unraid-deployment-service.cjs @@ -593,14 +593,75 @@ exit 45`), const remote = this.serverGitRemote(repository, profile); const credentials = this.serverGitCredentialPaths(repository, server); 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 }); - 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) { 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) { const targets = [ { @@ -2707,6 +2768,17 @@ printf 'digest=%s\n' "$digest" async deploy({ repository, profileId, sha }) { const targetSha = assertFullCommitSha(sha); 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 }); if (!preflight.summary.ready) { const error = new Error(`SSH deployment preflight failed: ${preflight.summary.blocking.join(", ")}`); diff --git a/src/renderer/app.js b/src/renderer/app.js index d7cd558..8d421bb 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -156,6 +156,7 @@ const ui = { setupStep: 0, systemPreflight: null, deploymentPreflight: null, + serverGitVerifications: {}, diagnosticsStatus: null, troubleshooter: null, deploymentDiscovery: null, @@ -978,6 +979,7 @@ function renderProfileCard(repository, profile, compact = false) { const health = environmentState(profile); const isSsh = profile.provider === "ssh-unraid"; const mode = deploymentMode(profile); + const verification = ui.serverGitVerifications[profile.id]; const targetSha = deploymentTargetSha(repository, profile); const ready = canDeploy(repository, profile); 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" : state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"; const serverAccessAction = isSsh && mode === "server-git" - ? `` + ? `` : ""; return `
${escapeHtml(identity.initial)}
Container${escapeHtml(identity.name)}${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}
${syncLabel}
${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}

${escapeHtml(profile.name)}

${escapeHtml(providerDetail)}

${health.label}
${isSsh ? `` : ""}${serverAccessAction}${webUi ? `` : ""}${managesDockerMan ? `` : ""}${ready ? `` : ""}${state.previousSha && rollbackConfigured ? `` : ""}
`; } @@ -2773,6 +2775,22 @@ app.addEventListener("click", async (event) => { } else if (action === "run-deployment-preflight") { if (!repository) repository = profileRepository(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") { const profileId = target.dataset.profileId || ui.selectedProfileId; if (!repository) repository = profileRepository(profileId); diff --git a/src/renderer/mock-bridge.js b/src/renderer/mock-bridge.js index 1ee0f0c..5928c46 100644 --- a/src/renderer/mock-bridge.js +++ b/src/renderer/mock-bridge.js @@ -1585,6 +1585,27 @@ syncState(); 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) { await wait(300); if (operationId) { diff --git a/tests/unraid-deployment.test.mjs b/tests/unraid-deployment.test.mjs index 88a7b39..2102fc1 100644 --- a/tests/unraid-deployment.test.mjs +++ b/tests/unraid-deployment.test.mjs @@ -80,6 +80,54 @@ test("server pull provisions a pinned repository-scoped key and records access m 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", () => { const b64 = (value) => Buffer.from(value).toString("base64"); const inspect = JSON.stringify([