77 lines
9.3 KiB
JavaScript
77 lines
9.3 KiB
JavaScript
"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 };
|