feat: add transactional deploy key lifecycle
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
function stable(value) {
|
||||
if (Array.isArray(value)) return value.map(stable);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
||||
return value;
|
||||
}
|
||||
|
||||
function planId(value) {
|
||||
return crypto.createHash("sha256").update(JSON.stringify(stable(value))).digest("hex");
|
||||
}
|
||||
|
||||
function keyMaterial(value) {
|
||||
return String(value || "").trim().split(/\s+/).slice(0, 2).join(" ");
|
||||
}
|
||||
|
||||
class DeployKeyLifecycleService {
|
||||
constructor({ store, gitea, keyHost, audit = null, clock = () => new Date().toISOString() }) {
|
||||
this.store = store;
|
||||
this.gitea = gitea;
|
||||
this.keyHost = keyHost;
|
||||
this.audit = audit;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
coordinates(repository) {
|
||||
const [owner, repo] = String(repository?.fullName || "").split("/");
|
||||
if (!owner || !repo) throw Object.assign(new Error("A full Gitea repository name is required."), { code: "DEPLOY_KEY_REPOSITORY_REQUIRED" });
|
||||
return { owner, repo };
|
||||
}
|
||||
|
||||
profile(repository, profileId) {
|
||||
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
|
||||
if (!profile) throw Object.assign(new Error("Deployment profile not found."), { code: "DEPLOY_KEY_PROFILE_NOT_FOUND" });
|
||||
const server = this.store.getServer(profile.serverId);
|
||||
if (!server) throw Object.assign(new Error("Deployment server not found."), { code: "DEPLOY_KEY_SERVER_NOT_FOUND" });
|
||||
return { profile, server };
|
||||
}
|
||||
|
||||
configuredReferences() {
|
||||
const references = [];
|
||||
const configured = this.store.data?.deploymentProfiles
|
||||
? Object.entries(this.store.data.deploymentProfiles).map(([fullName, profiles]) => ({ fullName, profiles }))
|
||||
: (this.store.getRepositories?.() || []).map((repository) => ({ fullName: repository.fullName, profiles: this.store.getDeploymentProfiles(repository.fullName) || [] }));
|
||||
for (const repository of configured) {
|
||||
for (const profile of repository.profiles || []) {
|
||||
if (!profile.serverGitAccess?.deployKeyId && !profile.serverGitAccess?.keyFingerprint) continue;
|
||||
references.push({ repository: repository.fullName, profileId: profile.id, serverId: profile.serverId, keyId: profile.serverGitAccess.deployKeyId || null, fingerprint: profile.serverGitAccess.keyFingerprint || null });
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
async inventory({ repository, profileId }) {
|
||||
const { profile, server } = this.profile(repository, profileId);
|
||||
const { owner, repo } = this.coordinates(repository);
|
||||
const [remoteKeys, serverKey] = await Promise.all([
|
||||
this.gitea.listDeployKeys(owner, repo),
|
||||
this.keyHost.inspect({ repository, profile, server }),
|
||||
]);
|
||||
const configuredId = Number(profile.serverGitAccess?.deployKeyId) || null;
|
||||
const configured = remoteKeys.find((key) => Number(key.id) === configuredId) || null;
|
||||
const material = keyMaterial(serverKey?.publicKey);
|
||||
const matching = material ? remoteKeys.filter((key) => keyMaterial(key.key) === material) : [];
|
||||
const references = this.configuredReferences();
|
||||
const shared = references.filter((reference) => reference.fingerprint && reference.fingerprint === serverKey?.fingerprint && (reference.repository !== repository.fullName || reference.profileId !== profileId));
|
||||
const conflicts = remoteKeys.filter((key) => key.read_only !== true && (!configuredId || Number(key.id) === configuredId || keyMaterial(key.key) === material));
|
||||
const stale = Boolean(configuredId && !configured) || Boolean(profile.serverGitAccess?.keyFingerprint && serverKey?.fingerprint && profile.serverGitAccess.keyFingerprint !== serverKey.fingerprint);
|
||||
const orphaned = remoteKeys.filter((key) => /ForgeFlow/i.test(String(key.title || "")) && !references.some((reference) => Number(reference.keyId) === Number(key.id)));
|
||||
return {
|
||||
repository: repository.fullName, profileId, server: { id: server.id, name: server.name },
|
||||
configuredKey: configured ? { id: configured.id, title: configured.title, readOnly: configured.read_only === true, key: configured.key || null } : null,
|
||||
serverKey, matchingKeys: matching.map((key) => ({ id: key.id, readOnly: key.read_only === true })),
|
||||
stale, orphaned: orphaned.map((key) => ({ id: key.id, title: key.title })), shared, conflicts: conflicts.map((key) => ({ id: key.id, title: key.title, readOnly: false })),
|
||||
ready: Boolean(configured && configured.read_only === true && serverKey?.privateKeyPresent && serverKey?.fingerprint === profile.serverGitAccess?.keyFingerprint && !shared.length && !conflicts.length),
|
||||
checkedAt: this.clock(),
|
||||
};
|
||||
}
|
||||
|
||||
async planRotation({ repository, profileId }) {
|
||||
const evidence = await this.inventory({ repository, profileId });
|
||||
const plan = {
|
||||
operation: "rotate-deploy-key", repository: repository.fullName, profileId,
|
||||
currentKeyId: evidence.configuredKey?.id || null, currentFingerprint: evidence.serverKey?.fingerprint || null,
|
||||
serverId: evidence.server.id, impact: ["Generate a new private key on the linked server", "Register only its public key in this repository", "Verify read-only branch access", "Switch the profile atomically", "Revoke the previous key after the switch"],
|
||||
recovery: "The previous server key and profile metadata remain recoverable until post-rotation verification succeeds.", evidence,
|
||||
};
|
||||
plan.id = planId(plan);
|
||||
await this.audit?.append?.("deployment.deploy-key-rotation-planned", { repository: repository.fullName, profileId, planId: plan.id });
|
||||
return plan;
|
||||
}
|
||||
|
||||
async rotate({ repository, profileId, expectedPlanId }) {
|
||||
const plan = await this.planRotation({ repository, profileId });
|
||||
if (!expectedPlanId) throw Object.assign(new Error("Review a deploy-key rotation plan before applying it."), { code: "DEPLOY_KEY_ROTATION_PLAN_REQUIRED", plan });
|
||||
if (expectedPlanId !== plan.id) throw Object.assign(new Error("Deploy-key evidence changed after preview. Review a fresh plan."), { code: "DEPLOY_KEY_ROTATION_PLAN_STALE", plan });
|
||||
const { profile, server } = this.profile(repository, profileId);
|
||||
const { owner, repo } = this.coordinates(repository);
|
||||
const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-rotation:${repository.fullName}:${profileId}`);
|
||||
const previous = { profile: structuredClone(profile), key: await this.keyHost.backup({ repository, profile, server }), remoteKey: plan.evidence.configuredKey };
|
||||
let candidate = null;
|
||||
let registered = null;
|
||||
let switched = false;
|
||||
let oldRevoked = false;
|
||||
try {
|
||||
candidate = await this.keyHost.generate({ repository, profile, server });
|
||||
if (!candidate?.publicKey || !candidate?.fingerprint || candidate.privateKey) throw Object.assign(new Error("The server did not return safe public-key evidence."), { code: "DEPLOY_KEY_CANDIDATE_INVALID" });
|
||||
registered = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · ${candidate.fingerprint.slice(-12)}`, publicKey: candidate.publicKey });
|
||||
if (registered.read_only !== true) throw Object.assign(new Error("Gitea registered the candidate with write access."), { code: "DEPLOY_KEY_NOT_READ_ONLY" });
|
||||
const proof = await this.keyHost.verifyCandidate({ repository, profile, server, candidate, keyId: registered.id });
|
||||
if (!proof?.ready || proof.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("The candidate deploy key could not prove read-only repository access."), { code: "DEPLOY_KEY_CANDIDATE_VERIFICATION_FAILED", proof });
|
||||
await this.keyHost.preflightCandidate({ repository, profile, server, candidate, proof });
|
||||
await this.keyHost.promote({ repository, profile, server, candidate, previous });
|
||||
const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { configured: true, deployKeyId: registered.id, keyFingerprint: candidate.fingerprint, hostFingerprint: proof.hostFingerprint, configuredAt: this.clock(), rotatedAt: this.clock(), previousKeyId: previous.remoteKey?.id || null } });
|
||||
switched = true;
|
||||
if (previous.remoteKey?.id) {
|
||||
await this.gitea.deleteDeployKey(owner, repo, previous.remoteKey.id);
|
||||
oldRevoked = true;
|
||||
}
|
||||
const post = await this.keyHost.verifyActive({ repository, profile: updated, server });
|
||||
if (!post?.ready || post.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("Post-rotation verification failed."), { code: "DEPLOY_KEY_POST_ROTATION_FAILED", post });
|
||||
await this.keyHost.commit({ repository, profile: updated, server, candidate, previous });
|
||||
await this.audit?.append?.("deployment.deploy-key-rotated", { repository: repository.fullName, profileId, oldKeyId: previous.remoteKey?.id || null, newKeyId: registered.id, fingerprint: candidate.fingerprint, snapshot: snapshot?.filePath || null });
|
||||
return { profile: updated, proof: post, snapshot, recovery: previous.key?.recovery || null };
|
||||
} catch (error) {
|
||||
try {
|
||||
if (candidate) await this.keyHost.rollback({ repository, profile, server, candidate, previous });
|
||||
if (registered?.id) await this.gitea.deleteDeployKey(owner, repo, registered.id).catch(() => {});
|
||||
let restoredKey = null;
|
||||
if (oldRevoked && previous.remoteKey?.key) restoredKey = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: previous.remoteKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: previous.remoteKey.key });
|
||||
if (switched) await this.store.saveDeploymentProfile(repository.fullName, restoredKey ? { ...previous.profile, serverGitAccess: { ...previous.profile.serverGitAccess, deployKeyId: restoredKey.id } } : previous.profile);
|
||||
} catch (rollbackError) {
|
||||
error.rollbackError = rollbackError.message;
|
||||
}
|
||||
await this.audit?.append?.("deployment.deploy-key-rotation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_ROTATION_FAILED", rollbackError: error.rollbackError || null });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async planRevocation({ repository, profileId }) {
|
||||
const evidence = await this.inventory({ repository, profileId });
|
||||
const plan = { operation: "revoke-deploy-key", repository: repository.fullName, profileId, keyId: evidence.configuredKey?.id || null, fingerprint: evidence.serverKey?.fingerprint || null, linkedDeployments: [profileId], impact: ["Remove this repository deploy key from Gitea", "Disable server-pull deployment until restored", "Preserve server-side recovery material"], containersUnaffected: true, evidence };
|
||||
plan.id = planId(plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
async revoke({ repository, profileId, expectedPlanId }) {
|
||||
const plan = await this.planRevocation({ repository, profileId });
|
||||
if (!expectedPlanId) throw Object.assign(new Error("Review revocation impact before applying it."), { code: "DEPLOY_KEY_REVOCATION_PLAN_REQUIRED", plan });
|
||||
if (plan.id !== expectedPlanId) throw Object.assign(new Error("Deploy-key evidence changed after preview."), { code: "DEPLOY_KEY_REVOCATION_PLAN_STALE", plan });
|
||||
const { profile, server } = this.profile(repository, profileId);
|
||||
const { owner, repo } = this.coordinates(repository);
|
||||
const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-revocation:${repository.fullName}:${profileId}`);
|
||||
const recovery = await this.keyHost.backup({ repository, profile, server });
|
||||
let remoteDeleted = false;
|
||||
try {
|
||||
if (plan.keyId) { await this.gitea.deleteDeployKey(owner, repo, plan.keyId); remoteDeleted = true; }
|
||||
await this.keyHost.revoke({ repository, profile, server, recovery });
|
||||
const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, configured: false, revokedAt: this.clock(), recoveryAvailable: true }, deploymentMode: "monitor-only" });
|
||||
await this.audit?.append?.("deployment.deploy-key-revoked", { repository: repository.fullName, profileId, keyId: plan.keyId, snapshot: snapshot?.filePath || null });
|
||||
return { profile: updated, snapshot, recovery: recovery?.recovery || null };
|
||||
} catch (error) {
|
||||
if (remoteDeleted && plan.evidence.configuredKey?.key) {
|
||||
const restored = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: plan.evidence.configuredKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: plan.evidence.configuredKey.key });
|
||||
await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, deployKeyId: restored.id } });
|
||||
}
|
||||
await this.keyHost.restore({ repository, profile, server }).catch(() => {});
|
||||
await this.audit?.append?.("deployment.deploy-key-revocation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_REVOCATION_FAILED" });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async restore({ repository, profileId }) {
|
||||
const { profile, server } = this.profile(repository, profileId);
|
||||
const restored = await this.keyHost.restore({ repository, profile, server });
|
||||
if (!restored?.publicKey || !restored?.fingerprint) throw Object.assign(new Error("No valid deploy-key recovery material exists."), { code: "DEPLOY_KEY_RECOVERY_UNAVAILABLE" });
|
||||
const { owner, repo } = this.coordinates(repository);
|
||||
const key = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · restored`, publicKey: restored.publicKey });
|
||||
if (key.read_only !== true) throw Object.assign(new Error("The restored key is not read-only."), { code: "DEPLOY_KEY_NOT_READ_ONLY" });
|
||||
const proposed = { ...profile, deploymentMode: "server-git", serverGitAccess: { configured: true, deployKeyId: key.id, keyFingerprint: restored.fingerprint, hostFingerprint: restored.hostFingerprint, restoredAt: this.clock() } };
|
||||
const proof = await this.keyHost.verifyActive({ repository, profile: proposed, server });
|
||||
if (!proof?.ready) throw Object.assign(new Error("Restored access could not be verified."), { code: "DEPLOY_KEY_RECOVERY_VERIFICATION_FAILED" });
|
||||
const updated = await this.store.saveDeploymentProfile(repository.fullName, proposed);
|
||||
await this.audit?.append?.("deployment.deploy-key-restored", { repository: repository.fullName, profileId, keyId: key.id, fingerprint: restored.fingerprint });
|
||||
return { profile: updated, proof };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DeployKeyLifecycleService, keyMaterial, deployKeyPlanId: planId };
|
||||
@@ -264,6 +264,19 @@ class GiteaService {
|
||||
return { ...result.data, created: true };
|
||||
}
|
||||
|
||||
async createReadOnlyDeployKey({ owner, repo, title, publicKey }) {
|
||||
const key = String(publicKey || "").trim();
|
||||
if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key)) throw new Error("A valid SSH public key is required.");
|
||||
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`, { method: "POST", body: { title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255), key, read_only: true } });
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async deleteDeployKey(owner, repo, keyId) {
|
||||
if (!Number.isInteger(Number(keyId)) || Number(keyId) <= 0) throw new Error("A valid deploy-key ID is required.");
|
||||
await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys/${Number(keyId)}`, { method: "DELETE" });
|
||||
return { deleted: true, keyId: Number(keyId) };
|
||||
}
|
||||
|
||||
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
|
||||
const query = new URLSearchParams({
|
||||
state,
|
||||
|
||||
@@ -84,6 +84,7 @@ function registerIpc({
|
||||
repositories,
|
||||
deployments,
|
||||
unraid,
|
||||
deployKeys,
|
||||
ssh,
|
||||
updates,
|
||||
preflight,
|
||||
@@ -1226,6 +1227,33 @@ function registerIpc({
|
||||
});
|
||||
return result;
|
||||
});
|
||||
register("deployment:deploy-key-inventory", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
return deployKeys.inventory({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:plan-deploy-key-rotation", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
return deployKeys.planRotation({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:apply-deploy-key-rotation", async ({ repository, profileId, planId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await deployKeys.rotate({ repository: current, profileId, expectedPlanId: planId });
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:plan-deploy-key-revocation", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
return deployKeys.planRevocation({ repository: current, profileId });
|
||||
});
|
||||
register("deployment:apply-deploy-key-revocation", async ({ repository, profileId, planId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await deployKeys.revoke({ repository: current, profileId, expectedPlanId: planId });
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:restore-deploy-key", async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await deployKeys.restore({ repository: current, profileId });
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:discover-server-workloads", async () => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter(
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path").posix;
|
||||
const { shellQuote } = require("./ssh-service.cjs");
|
||||
|
||||
const bash = (command) => `printf '%s' ${shellQuote(Buffer.from(`set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\n${command}`, "utf8").toString("base64"))} | base64 -d | bash`;
|
||||
function parseMarker(stdout, marker) {
|
||||
const text = String(stdout || "");
|
||||
const index = text.lastIndexOf(marker);
|
||||
if (index < 0) throw new Error(`Server key operation did not return ${marker}.`);
|
||||
return Object.fromEntries(text.slice(index + marker.length).trim().split(/\r?\n/).map((line) => { const separator = line.indexOf("="); return separator > 0 ? [line.slice(0, separator), line.slice(separator + 1)] : [line, ""]; }));
|
||||
}
|
||||
|
||||
class UnraidDeployKeyHost {
|
||||
constructor({ ssh }) { this.ssh = ssh; }
|
||||
paths(repository, server) {
|
||||
const id = crypto.createHash("sha256").update(String(repository.fullName).toLowerCase()).digest("hex").slice(0, 24);
|
||||
const directory = path.join(server.basePath, ".forgeflow", "git-credentials", id);
|
||||
return { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts"), recovery: path.join(directory, "recovery") };
|
||||
}
|
||||
remote(repository, profile) {
|
||||
const value = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl].map((item) => String(item || "").trim()).find((item) => /^ssh:\/\//i.test(item) || /^[^@\s]+@[^:\s]+:.+/.test(item));
|
||||
if (!value) throw Object.assign(new Error("Server pull requires a Gitea SSH URL."), { code: "SERVER_GIT_SSH_URL_REQUIRED" });
|
||||
return value;
|
||||
}
|
||||
environment(paths) { return `GIT_SSH_COMMAND=${shellQuote(`ssh -i ${paths.privateKey} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${paths.knownHosts}`)}`; }
|
||||
async execute(server, script, options = {}) { return this.ssh.exec(server.id, bash(script), { timeout: options.timeout || 30_000, maxOutput: options.maxOutput || 128 * 1024 }); }
|
||||
async inspect({ repository, server }) {
|
||||
const p = this.paths(repository, server); const marker = "__FORGEFLOW_KEY_INSPECT__";
|
||||
const script = `printf '%s\\n' ${shellQuote(marker)}; printf 'privateKeyPresent=%s\\n' "$([ -s ${shellQuote(p.privateKey)} ] && echo true || echo false)"; printf 'publicKey=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n' || true)"; printf 'fingerprint=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}' || true)"; printf 'hostFingerprint=%s\\n' "$([ -s ${shellQuote(p.knownHosts)} ] && ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, - || true)"`;
|
||||
const f = parseMarker((await this.execute(server, script)).stdout, marker);
|
||||
return { privateKeyPresent: f.privateKeyPresent === "true", publicKey: f.publicKey ? Buffer.from(f.publicKey, "base64").toString("utf8").trim() : null, fingerprint: f.fingerprint || null, hostFingerprint: f.hostFingerprint || null };
|
||||
}
|
||||
async backup({ repository, server }) {
|
||||
const p = this.paths(repository, server); const slot = path.join(p.recovery, `backup-${Date.now()}-${crypto.randomUUID()}`); const marker = "__FORGEFLOW_KEY_BACKUP__";
|
||||
const script = `umask 077; mkdir -p ${shellQuote(slot)}; for name in deploy-key deploy-key.pub known_hosts; do [ ! -e ${shellQuote(p.directory)}/"$name" ] || cp -p ${shellQuote(p.directory)}/"$name" ${shellQuote(slot)}/"$name"; done; printf '%s\\n' ${shellQuote(marker)}; printf 'recovery=%s\\n' ${shellQuote(slot)}; printf 'publicKey=%s\\n' "$([ -s ${shellQuote(p.publicKey)} ] && base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n' || true)"`;
|
||||
const f = parseMarker((await this.execute(server, script)).stdout, marker);
|
||||
return { recovery: f.recovery, publicKey: f.publicKey ? Buffer.from(f.publicKey, "base64").toString("utf8").trim() : null };
|
||||
}
|
||||
async generate({ repository, server }) {
|
||||
const active = this.paths(repository, server); const directory = path.join(active.directory, `candidate-${crypto.randomUUID()}`); const p = { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts") }; const marker = "__FORGEFLOW_KEY_CANDIDATE__";
|
||||
const script = `umask 077; mkdir -p ${shellQuote(directory)}; ssh-keygen -q -t ed25519 -N '' -C ${shellQuote(`forgeflow-rotation:${repository.fullName}`)} -f ${shellQuote(p.privateKey)}; cp -p ${shellQuote(active.knownHosts)} ${shellQuote(p.knownHosts)}; chmod 600 ${shellQuote(p.privateKey)} ${shellQuote(p.knownHosts)}; chmod 644 ${shellQuote(p.publicKey)}; printf '%s\\n' ${shellQuote(marker)}; printf 'publicKey=%s\\n' "$(base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
|
||||
const f = parseMarker((await this.execute(server, script)).stdout, marker);
|
||||
return { paths: p, publicKey: Buffer.from(f.publicKey, "base64").toString("utf8").trim(), fingerprint: f.fingerprint, hostFingerprint: f.hostFingerprint };
|
||||
}
|
||||
async verifyCandidate({ repository, profile, server, candidate }) {
|
||||
const marker = "__FORGEFLOW_KEY_PROOF__"; const remote = this.remote(repository, profile); const p = candidate.paths;
|
||||
const script = `output="$(${this.environment(p)} git ls-remote --exit-code ${shellQuote(remote)} ${shellQuote(`refs/heads/${profile.branch}`)})"; printf '%s\\n' ${shellQuote(marker)}; printf 'remoteSha=%s\\n' "$(printf '%s' "$output" | awk 'NR==1 {print $1}')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
|
||||
const f = parseMarker((await this.execute(server, script, { timeout: 45_000, maxOutput: 256 * 1024 })).stdout, marker);
|
||||
return { ready: /^[0-9a-f]{40}$/i.test(f.remoteSha || ""), remoteSha: f.remoteSha || null, fingerprint: f.fingerprint || null, hostFingerprint: f.hostFingerprint || null };
|
||||
}
|
||||
async preflightCandidate(context) { const proof = await this.verifyCandidate(context); if (!proof.ready) throw new Error("Candidate preflight did not prove the remote branch."); return proof; }
|
||||
async promote({ repository, server, candidate }) {
|
||||
const p = this.paths(repository, server); const c = candidate.paths;
|
||||
await this.execute(server, `test -s ${shellQuote(c.privateKey)}; test -s ${shellQuote(c.publicKey)}; test -s ${shellQuote(c.knownHosts)}; cp -p ${shellQuote(c.privateKey)} ${shellQuote(p.privateKey)}.new; cp -p ${shellQuote(c.publicKey)} ${shellQuote(p.publicKey)}.new; cp -p ${shellQuote(c.knownHosts)} ${shellQuote(p.knownHosts)}.new; mv ${shellQuote(p.privateKey)}.new ${shellQuote(p.privateKey)}; mv ${shellQuote(p.publicKey)}.new ${shellQuote(p.publicKey)}; mv ${shellQuote(p.knownHosts)}.new ${shellQuote(p.knownHosts)}`);
|
||||
}
|
||||
async verifyActive({ repository, profile, server }) { const paths = this.paths(repository, server); return this.verifyCandidate({ repository, profile, server, candidate: { paths } }); }
|
||||
async rollback({ repository, server, candidate, previous }) {
|
||||
const p = this.paths(repository, server); const recovery = previous.key.recovery;
|
||||
await this.execute(server, `for name in deploy-key deploy-key.pub known_hosts; do test ! -s ${shellQuote(recovery)}/"$name" || cp -p ${shellQuote(recovery)}/"$name" ${shellQuote(p.directory)}/"$name"; done; rm -rf -- ${shellQuote(candidate.paths.directory)}`);
|
||||
}
|
||||
async commit({ server, candidate }) { await this.execute(server, `rm -rf -- ${shellQuote(candidate.paths.directory)}`); }
|
||||
async revoke({ repository, server }) {
|
||||
const p = this.paths(repository, server); const revoked = path.join(p.recovery, `revoked-${Date.now()}-${crypto.randomUUID()}`);
|
||||
await this.execute(server, `umask 077; mkdir -p ${shellQuote(revoked)}; for name in deploy-key deploy-key.pub known_hosts; do [ ! -e ${shellQuote(p.directory)}/"$name" ] || mv ${shellQuote(p.directory)}/"$name" ${shellQuote(revoked)}/"$name"; done`);
|
||||
}
|
||||
async restore({ repository, server }) {
|
||||
const p = this.paths(repository, server); const marker = "__FORGEFLOW_KEY_RESTORE__";
|
||||
const script = `slot="$(find ${shellQuote(p.recovery)} -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | sort | tail -1)"; test -n "$slot"; for name in deploy-key deploy-key.pub known_hosts; do test -s "$slot/$name"; cp -p "$slot/$name" ${shellQuote(p.directory)}/"$name"; done; printf '%s\\n' ${shellQuote(marker)}; printf 'publicKey=%s\\n' "$(base64 < ${shellQuote(p.publicKey)} | tr -d '\\r\\n')"; printf 'fingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.publicKey)} -E sha256 | awk '{print $2}')"; printf 'hostFingerprint=%s\\n' "$(ssh-keygen -lf ${shellQuote(p.knownHosts)} -E sha256 | awk '{print $2}' | sort -u | paste -sd, -)"`;
|
||||
const f = parseMarker((await this.execute(server, script)).stdout, marker);
|
||||
return { publicKey: Buffer.from(f.publicKey, "base64").toString("utf8").trim(), fingerprint: f.fingerprint, hostFingerprint: f.hostFingerprint };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UnraidDeployKeyHost, parseDeployKeyMarker: parseMarker };
|
||||
+65
-1
@@ -157,6 +157,7 @@ const ui = {
|
||||
systemPreflight: null,
|
||||
deploymentPreflight: null,
|
||||
serverGitVerifications: {},
|
||||
deployKeyLifecycle: null,
|
||||
diagnosticsStatus: null,
|
||||
troubleshooter: null,
|
||||
deploymentDiscovery: null,
|
||||
@@ -1019,7 +1020,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="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>`
|
||||
? `<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="manage-deploy-key" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Deploy key lifecycle</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>`;
|
||||
}
|
||||
@@ -1466,6 +1467,13 @@ function renderModal() {
|
||||
<div class="field full"><label>Healthcheck URL (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="https://app.example.com/health" /></div>`
|
||||
}<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label></div><div class="notice" style="margin-top:13px">${icon("shield")}${ssh ? "Server pull fetches the exact selected Gitea commit with a repository-scoped read-only key, validates Compose and services, then promotes atomically with rollback protection." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}</div></div><footer class="modal-footer">${existing.id ? `<button class="button danger" data-action="delete-deployment-profile" data-profile-id="${attr(existing.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-deployment-profile" data-profile-id="${attr(existing.id || "")}" ${ssh && !servers.length ? "disabled" : ""}>Save environment</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "deploy-key-lifecycle") {
|
||||
const lifecycle = ui.deployKeyLifecycle;
|
||||
const inventory = lifecycle?.inventory;
|
||||
const rotation = lifecycle?.rotation;
|
||||
const revocation = lifecycle?.revocation;
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="deploy-key-title"><header class="modal-header"><h2 id="deploy-key-title">Deploy key lifecycle</h2><button class="icon-button" data-action="close-modal" aria-label="Close deploy key lifecycle">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero ${inventory?.ready ? "" : "danger"}">${icon(inventory?.ready ? "shield" : "warning")}<div><strong>${inventory?.ready ? "Repository access is verified" : "Deploy key requires review"}</strong><span>${escapeHtml(inventory?.repository || "")} · ${escapeHtml(inventory?.server?.name || "server")}</span></div></div><div class="confirm-grid"><span>Key ID</span><strong>${escapeHtml(inventory?.configuredKey?.id || "Missing")}</strong><span>Fingerprint</span><strong class="mono">${escapeHtml(inventory?.serverKey?.fingerprint || "Unavailable")}</strong><span>Rights</span><strong>${inventory?.configuredKey?.readOnly ? "Repository-scoped · read-only" : "Unverified or writable"}</strong><span>Stale</span><strong>${inventory?.stale ? "Yes · blocked" : "No"}</strong><span>Shared references</span><strong>${inventory?.shared?.length || 0}</strong><span>Orphaned Gitea keys</span><strong>${inventory?.orphaned?.length || 0}</strong></div><section class="settings-group" style="margin-top:16px"><h3>Rotation impact</h3><ul>${(rotation?.impact || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><p>${escapeHtml(rotation?.recovery || "")}</p><small class="mono">Plan ${escapeHtml(rotation?.id || "unavailable")}</small></section><section class="settings-group"><h3>Revocation impact</h3><ul>${(revocation?.impact || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><p>Containers remain untouched. Server pull changes to monitoring-only until restored.</p><small class="mono">Plan ${escapeHtml(revocation?.id || "unavailable")}</small></section></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button" data-action="restore-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}">Restore</button><button class="button danger" data-action="confirm-revoke-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}" ${revocation?.id ? "" : "disabled"}>Revoke key</button><button class="button primary" data-action="confirm-rotate-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}" ${rotation?.id ? "" : "disabled"}>Rotate safely</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "deployment-preflight") {
|
||||
const profile =
|
||||
repository?.deploymentProfiles?.find(
|
||||
@@ -2785,6 +2793,62 @@ 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 === "manage-deploy-key") {
|
||||
const profileId = target.dataset.profileId || ui.selectedProfileId;
|
||||
if (!repository) repository = profileRepository(profileId);
|
||||
if (!repository || !profileId) return;
|
||||
setLoading(true, "Inspecting deploy-key lifecycle without changing access…");
|
||||
try {
|
||||
const [inventory, rotation, revocation] = await Promise.all([
|
||||
window.forgeflow.deployKeyInventory(repository, profileId),
|
||||
window.forgeflow.planDeployKeyRotation(repository, profileId),
|
||||
window.forgeflow.planDeployKeyRevocation(repository, profileId),
|
||||
]);
|
||||
ui.deployKeyLifecycle = { repositoryId: repository.id, profileId, inventory, rotation, revocation };
|
||||
ui.modal = { type: "deploy-key-lifecycle" };
|
||||
render();
|
||||
} catch (error) {
|
||||
showToast("Could not inspect deploy key", error.message, "error");
|
||||
} finally { setLoading(false); }
|
||||
} else if (action === "confirm-rotate-deploy-key") {
|
||||
const lifecycle = ui.deployKeyLifecycle;
|
||||
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
|
||||
if (!targetRepository || !lifecycle?.rotation?.id) return;
|
||||
setLoading(true, "Rotating and verifying the repository deploy key…");
|
||||
try {
|
||||
const result = await window.forgeflow.applyDeployKeyRotation(targetRepository, lifecycle.profileId, lifecycle.rotation.id);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.modal = null; ui.deployKeyLifecycle = null;
|
||||
await refreshRepositories(false, true);
|
||||
showToast("Deploy key rotated", `New fingerprint ${result.profile?.serverGitAccess?.keyFingerprint || "verified"}.`, "success");
|
||||
} catch (error) { showToast("Deploy-key rotation failed safely", error.message, "error"); }
|
||||
finally { setLoading(false); }
|
||||
} else if (action === "confirm-revoke-deploy-key") {
|
||||
const lifecycle = ui.deployKeyLifecycle;
|
||||
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
|
||||
if (!targetRepository || !lifecycle?.revocation?.id) return;
|
||||
setLoading(true, "Revoking repository access while preserving recovery…");
|
||||
try {
|
||||
const result = await window.forgeflow.applyDeployKeyRevocation(targetRepository, lifecycle.profileId, lifecycle.revocation.id);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.modal = null; ui.deployKeyLifecycle = null;
|
||||
await refreshRepositories(false, true);
|
||||
showToast("Deploy key revoked", "Server pull is disabled; containers were not changed and recovery is available.", "success");
|
||||
} catch (error) { showToast("Deploy-key revocation failed", error.message, "error"); }
|
||||
finally { setLoading(false); }
|
||||
} else if (action === "restore-deploy-key") {
|
||||
const lifecycle = ui.deployKeyLifecycle;
|
||||
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
|
||||
if (!targetRepository || !lifecycle?.profileId) return;
|
||||
setLoading(true, "Restoring and verifying repository access…");
|
||||
try {
|
||||
const result = await window.forgeflow.restoreDeployKey(targetRepository, lifecycle.profileId);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.modal = null; ui.deployKeyLifecycle = null;
|
||||
await refreshRepositories(false, true);
|
||||
showToast("Deploy key restored", "Read-only server pull access is verified again.", "success");
|
||||
} catch (error) { showToast("Deploy-key recovery failed", error.message, "error"); }
|
||||
finally { setLoading(false); }
|
||||
} else if (action === "verify-server-git-access") {
|
||||
const profileId = target.dataset.profileId || ui.selectedProfileId;
|
||||
if (!repository) repository = profileRepository(profileId);
|
||||
|
||||
@@ -1606,6 +1606,31 @@
|
||||
],
|
||||
};
|
||||
},
|
||||
async deployKeyInventory(repository, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName);
|
||||
const profile = repo?.deploymentProfiles.find((item) => item.id === profileId);
|
||||
return { repository: repo.fullName, profileId, server: { id: profile.serverId, name: "Unraid" }, configuredKey: { id: profile.serverGitAccess?.deployKeyId || 17, readOnly: true }, serverKey: { privateKeyPresent: true, fingerprint: profile.serverGitAccess?.keyFingerprint || "SHA256:demo" }, stale: false, orphaned: [], shared: [], conflicts: [], ready: true, checkedAt: iso() };
|
||||
},
|
||||
async planDeployKeyRotation(repository, profileId) {
|
||||
const evidence = await this.deployKeyInventory(repository, profileId);
|
||||
return { id: `rotation-${profileId}`, operation: "rotate-deploy-key", impact: ["Generate a new server-side key", "Verify read-only access", "Switch atomically", "Revoke the previous key"], recovery: "Previous access remains recoverable until verification succeeds.", evidence };
|
||||
},
|
||||
async applyDeployKeyRotation(repository, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
|
||||
profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 18, keyFingerprint: "SHA256:rotated", rotatedAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state) };
|
||||
},
|
||||
async planDeployKeyRevocation(repository, profileId) {
|
||||
const evidence = await this.deployKeyInventory(repository, profileId);
|
||||
return { id: `revocation-${profileId}`, operation: "revoke-deploy-key", impact: ["Remove the repository key", "Disable server pull", "Preserve recovery material"], containersUnaffected: true, evidence };
|
||||
},
|
||||
async applyDeployKeyRevocation(repository, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
|
||||
profile.deploymentMode = "monitor-only"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: false, revokedAt: iso(), recoveryAvailable: true }; syncState(); return { profile: clone(profile), state: clone(state) };
|
||||
},
|
||||
async restoreDeployKey(repository, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
|
||||
profile.deploymentMode = "server-git"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 19, keyFingerprint: "SHA256:restored", restoredAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state), proof: { ready: true } };
|
||||
},
|
||||
async refreshOperations(operationId = null) {
|
||||
await wait(300);
|
||||
if (operationId) {
|
||||
|
||||
Reference in New Issue
Block a user