feat: verify server pull access before deployment
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(", ")}`);
|
||||
|
||||
Reference in New Issue
Block a user