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
+12
View File
@@ -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(
+74 -2
View File
@@ -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(", ")}`);
+19 -1
View File
@@ -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"
? `<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>`;
}
@@ -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);
+21
View File
@@ -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) {