feat: add safe Gitea sync and signed updates
This commit is contained in:
@@ -615,7 +615,10 @@ class ConfigStore {
|
||||
const next = { ...this.data.preferences, ...(preferences || {}) };
|
||||
next.repositoryPollSeconds = Math.min(Math.max(Number(next.repositoryPollSeconds) || 4, 2), 60);
|
||||
next.operationPollSeconds = Math.min(Math.max(Number(next.operationPollSeconds) || 5, 3), 120);
|
||||
next.fetchIntervalMinutes = Math.min(Math.max(Number(next.fetchIntervalMinutes) || 10, 0), 240);
|
||||
const fetchIntervalMinutes = Number(next.fetchIntervalMinutes);
|
||||
next.fetchIntervalMinutes = Number.isFinite(fetchIntervalMinutes)
|
||||
? Math.min(Math.max(fetchIntervalMinutes, 0), 240)
|
||||
: 10;
|
||||
next.autoRefresh = next.autoRefresh !== false;
|
||||
next.preferredCloneProtocol = ['https', 'ssh'].includes(next.preferredCloneProtocol) ? next.preferredCloneProtocol : 'https';
|
||||
next.diagnosticsEnabled = next.diagnosticsEnabled !== false;
|
||||
|
||||
@@ -291,9 +291,13 @@ class DiagnosticsService {
|
||||
dispatchedAt: operation.dispatchedAt,
|
||||
stages: operation.stages,
|
||||
jobs: operation.jobs,
|
||||
logs: operation.logs,
|
||||
failure: operation.failure,
|
||||
pollError: operation.pollError,
|
||||
remoteOutput: operation.logs || operation.failure || operation.pollError ? {
|
||||
included: false,
|
||||
reason: 'Remote build and command output is intentionally omitted because it may contain application secrets unknown to ForgeFlow.',
|
||||
logCharacters: String(operation.logs || '').length,
|
||||
failureRecorded: Boolean(operation.failure),
|
||||
pollErrorRecorded: Boolean(operation.pollError)
|
||||
} : null,
|
||||
applicationState: operation.applicationState,
|
||||
run: operation.run ? {
|
||||
id: operation.run.id,
|
||||
|
||||
+195
-1
@@ -2,11 +2,13 @@
|
||||
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
const { run } = require('./process-runner.cjs');
|
||||
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
|
||||
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
||||
|
||||
const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock'];
|
||||
const MAX_UNTRACKED_DIFF_BYTES = 16 * 1024 * 1024;
|
||||
const {
|
||||
assertSafeRepositoryPath,
|
||||
assertRepositoryRelativePath,
|
||||
@@ -28,6 +30,34 @@ function parseUnifiedDiff(diffText) {
|
||||
return { header, hunks };
|
||||
}
|
||||
|
||||
function parseNameStatus(output) {
|
||||
const entries = String(output || '').split('\0');
|
||||
const changes = [];
|
||||
for (let index = 0; index < entries.length;) {
|
||||
const rawStatus = entries[index++];
|
||||
if (!rawStatus) continue;
|
||||
const code = rawStatus[0];
|
||||
if (code === 'R' || code === 'C') {
|
||||
const originalPath = entries[index++] || '';
|
||||
const filePath = entries[index++] || '';
|
||||
if (filePath) changes.push({ code, status: code === 'R' ? 'renamed' : 'copied', path: filePath, originalPath });
|
||||
continue;
|
||||
}
|
||||
const filePath = entries[index++] || '';
|
||||
if (!filePath) continue;
|
||||
const labels = { A: 'added', D: 'deleted', M: 'modified', T: 'type-changed', U: 'conflict' };
|
||||
changes.push({ code, status: labels[code] || 'changed', path: filePath, originalPath: null });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function parseCompactLog(output) {
|
||||
return String(output || '').split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||
const [sha, shortSha, date, subject] = record.split('\x1f');
|
||||
return { sha, shortSha, date, subject };
|
||||
});
|
||||
}
|
||||
|
||||
class GitService {
|
||||
constructor() {
|
||||
// `git remote get-url` is only re-run when the repository configuration file
|
||||
@@ -316,6 +346,151 @@ class GitService {
|
||||
return { strategy: requested, backupBranch: null, status, lockReport: await this.listGitLocks(root) };
|
||||
}
|
||||
|
||||
async previewWorkspaceSync(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const { status } = await this.fetch(root);
|
||||
const branch = status.branch?.head;
|
||||
const upstream = status.branch?.upstream;
|
||||
if (!status.head || !branch || branch === '(detached)') {
|
||||
const error = new Error('Workspace synchronization requires a named branch with at least one commit.');
|
||||
error.code = 'WORKSPACE_SYNC_BRANCH_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
if (!upstream) {
|
||||
const error = new Error('The current branch has no Gitea upstream. Publish it or switch to a tracked branch first.');
|
||||
error.code = 'WORKSPACE_SYNC_UPSTREAM_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const targetSha = (await run('git', ['rev-parse', '--verify', upstream], { cwd: root, timeout: 30_000 })).stdout.trim();
|
||||
const changes = parseNameStatus((await run('git', [
|
||||
'diff', '--name-status', '-z', '--find-renames', 'HEAD', upstream, '--'
|
||||
], { cwd: root, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 })).stdout);
|
||||
const logFormat = '%H%x1f%h%x1f%aI%x1f%s%x1e';
|
||||
const [incomingResult, localResult, interruptedOperation] = await Promise.all([
|
||||
run('git', ['log', `--format=${logFormat}`, `HEAD..${upstream}`, '-20'], { cwd: root, timeout: 30_000 }),
|
||||
run('git', ['log', `--format=${logFormat}`, `${upstream}..HEAD`, '-20'], { cwd: root, timeout: 30_000 }),
|
||||
this.detectInterruptedOperation(root)
|
||||
]);
|
||||
const blockers = [];
|
||||
if (interruptedOperation) blockers.push(`Finish or abort the active Git ${interruptedOperation} before synchronizing.`);
|
||||
if (status.counts.conflicts) blockers.push(`Resolve ${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} before synchronizing.`);
|
||||
const summary = {
|
||||
resultingTrackedChanges: changes.length,
|
||||
added: changes.filter((item) => item.code === 'A').length,
|
||||
modified: changes.filter((item) => ['M', 'T'].includes(item.code)).length,
|
||||
deleted: changes.filter((item) => item.code === 'D').length,
|
||||
renamed: changes.filter((item) => item.code === 'R').length,
|
||||
localFilesToStash: status.counts.changed,
|
||||
untrackedFilesToStash: status.counts.untracked,
|
||||
localCommitsToProtect: status.branch.ahead,
|
||||
incomingCommits: status.branch.behind
|
||||
};
|
||||
const planId = crypto.createHash('sha256').update(JSON.stringify({
|
||||
head: status.head,
|
||||
targetSha,
|
||||
branch,
|
||||
upstream,
|
||||
fingerprint: this.statusFingerprint(status)
|
||||
})).digest('hex');
|
||||
return {
|
||||
id: planId,
|
||||
repositoryRoot: root,
|
||||
branch,
|
||||
upstream,
|
||||
currentSha: status.head,
|
||||
targetSha,
|
||||
needsSync: status.head !== targetSha || !status.clean,
|
||||
cleanBeforeSync: status.clean,
|
||||
blockers,
|
||||
summary,
|
||||
changes: changes.slice(0, 250),
|
||||
changesTruncated: changes.length > 250,
|
||||
localFiles: status.files.slice(0, 250),
|
||||
localFilesTruncated: status.files.length > 250,
|
||||
incomingCommits: parseCompactLog(incomingResult.stdout),
|
||||
localCommits: parseCompactLog(localResult.stdout),
|
||||
recovery: {
|
||||
safetyBranch: status.branch.ahead > 0,
|
||||
stash: status.counts.changed > 0,
|
||||
untrackedCleanup: status.counts.untracked > 0,
|
||||
ignoredFilesPreserved: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async synchronizeWorkspace(repoPath, expectedPlanId) {
|
||||
const expected = String(expectedPlanId || '').trim();
|
||||
if (!/^[0-9a-f]{64}$/i.test(expected)) {
|
||||
const error = new Error('Apply workspace synchronization only from a reviewed preview.');
|
||||
error.code = 'WORKSPACE_SYNC_PLAN_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
const plan = await this.previewWorkspaceSync(repoPath);
|
||||
if (plan.id !== expected) {
|
||||
const error = new Error('The local workspace or Gitea branch changed after the preview. Review a fresh synchronization plan.');
|
||||
error.code = 'WORKSPACE_SYNC_PLAN_STALE';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
if (plan.blockers.length) {
|
||||
const error = new Error(plan.blockers.join(' '));
|
||||
error.code = 'WORKSPACE_SYNC_BLOCKED';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
if (!plan.needsSync) {
|
||||
return { applied: false, unchanged: true, plan, status: await this.status(plan.repositoryRoot), backupBranch: null, stash: null, cleaned: [] };
|
||||
}
|
||||
|
||||
const root = plan.repositoryRoot;
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
|
||||
let backupBranch = null;
|
||||
let stash = null;
|
||||
if (plan.summary.localCommitsToProtect > 0) {
|
||||
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
|
||||
backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`;
|
||||
await run('git', ['check-ref-format', '--branch', backupBranch], { cwd: root, timeout: 30_000 });
|
||||
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
|
||||
}
|
||||
if (plan.summary.localFilesToStash > 0) {
|
||||
const label = `ForgeFlow workspace sync ${plan.branch} ${stamp}`;
|
||||
await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 });
|
||||
stash = (await this.stashList(root))[0] || null;
|
||||
}
|
||||
|
||||
const protectedStatus = await this.status(root);
|
||||
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
|
||||
const error = new Error('The workspace changed while ForgeFlow was protecting local work. Nothing was reset; review a fresh synchronization plan.');
|
||||
error.code = 'WORKSPACE_SYNC_CONCURRENT_CHANGE';
|
||||
error.recoverable = true;
|
||||
error.backupBranch = backupBranch;
|
||||
error.stash = stash;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await run('git', ['reset', '--hard', plan.targetSha], { cwd: root, timeout: 2 * 60_000 });
|
||||
const status = await this.status(root);
|
||||
if (status.head !== plan.targetSha || !status.clean) {
|
||||
const error = new Error('Git did not verify an exact clean match with the reviewed Gitea commit. Local recovery references were preserved.');
|
||||
error.code = 'WORKSPACE_SYNC_VERIFICATION_FAILED';
|
||||
error.recoverable = true;
|
||||
error.backupBranch = backupBranch;
|
||||
error.stash = stash;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
applied: true,
|
||||
unchanged: false,
|
||||
plan,
|
||||
status,
|
||||
backupBranch,
|
||||
stash,
|
||||
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
|
||||
ignoredFilesPreserved: true
|
||||
};
|
||||
}
|
||||
|
||||
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const safeRemote = assertCloneRemote(remoteUrl);
|
||||
@@ -336,7 +511,26 @@ class GitService {
|
||||
if (!result.stdout && safeFile && !staged) {
|
||||
const candidate = path.resolve(root, safeFile);
|
||||
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.');
|
||||
const content = await fs.readFile(candidate, 'utf8').catch(() => '');
|
||||
const [realRoot, realCandidate, candidateStat] = await Promise.all([
|
||||
fs.realpath(root).catch(() => root),
|
||||
fs.realpath(candidate).catch(() => candidate),
|
||||
fs.stat(candidate).catch(() => null)
|
||||
]);
|
||||
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
||||
const normalizedRoot = normalize(realRoot);
|
||||
const normalizedCandidate = normalize(realCandidate);
|
||||
if (normalizedCandidate !== normalizedRoot && !normalizedCandidate.startsWith(`${normalizedRoot}${path.sep}`)) {
|
||||
const error = new Error('ForgeFlow refuses to read a diff target that resolves outside the repository.');
|
||||
error.code = 'DIFF_TARGET_OUTSIDE_REPOSITORY';
|
||||
throw error;
|
||||
}
|
||||
if (candidateStat?.size > MAX_UNTRACKED_DIFF_BYTES) {
|
||||
const error = new Error('The untracked file is too large to render safely as a diff.');
|
||||
error.code = 'DIFF_FILE_TOO_LARGE';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
const content = candidateStat?.isFile() ? await fs.readFile(candidate, 'utf8').catch(() => '') : '';
|
||||
if (content) return `diff --git a/${safeFile} b/${safeFile}\nnew file mode 100644\n--- /dev/null\n+++ b/${safeFile}\n${content.split('\n').map((line) => `+${line}`).join('\n')}`;
|
||||
}
|
||||
return result.stdout;
|
||||
|
||||
+14
-2
@@ -415,13 +415,25 @@ function registerIpc({
|
||||
await diagnostics.info("server.deleted", { serverId });
|
||||
return store.getPublicState();
|
||||
});
|
||||
register("server:test", async ({ serverId }) => {
|
||||
register("server:test", async ({ serverId, expectedFingerprint = "" }) => {
|
||||
const server = store.getServer(serverId);
|
||||
if (!server) throw new Error("The configured server no longer exists.");
|
||||
const expected = String(expectedFingerprint || "").trim();
|
||||
if (!server.hostFingerprint && !expected) {
|
||||
const probe = await ssh.probeHostFingerprint(serverId);
|
||||
return { ...probe, connected: false, needsTrust: true, state: store.getPublicState() };
|
||||
}
|
||||
if (!server.hostFingerprint && !/^SHA256:[A-Za-z0-9+/]{40,44}$/.test(expected))
|
||||
throw new Error("Confirm the exact SSH host fingerprint returned by ForgeFlow.");
|
||||
const result = await ssh.test(serverId, {
|
||||
trustOnFirstUse: !server.hostFingerprint,
|
||||
expectedFingerprint: server.hostFingerprint ? null : expected,
|
||||
});
|
||||
if (!server.hostFingerprint) {
|
||||
if (result.fingerprint !== expected) {
|
||||
const error = new Error("The SSH host identity changed between preview and confirmation.");
|
||||
error.code = "SSH_HOST_KEY_MISMATCH";
|
||||
throw error;
|
||||
}
|
||||
await store.saveServer(
|
||||
{ ...server, hostFingerprint: result.fingerprint },
|
||||
{},
|
||||
|
||||
@@ -265,6 +265,44 @@ function registerRepositoryIpc({
|
||||
git.repairSync(safePath, strategy),
|
||||
);
|
||||
});
|
||||
register("repository:workspace-sync-preview", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const plan = await withRepositoryMutation(safePath, () =>
|
||||
git.previewWorkspaceSync(safePath),
|
||||
);
|
||||
await diagnostics.info("repository.workspace-sync.previewed", {
|
||||
localPath: safePath,
|
||||
branch: plan.branch,
|
||||
upstream: plan.upstream,
|
||||
currentSha: plan.currentSha,
|
||||
targetSha: plan.targetSha,
|
||||
planId: plan.id,
|
||||
summary: plan.summary,
|
||||
blockers: plan.blockers,
|
||||
});
|
||||
return plan;
|
||||
});
|
||||
register(
|
||||
"repository:workspace-sync-apply",
|
||||
async ({ localPath, expectedPlanId }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const result = await withRepositoryMutation(safePath, () =>
|
||||
git.synchronizeWorkspace(safePath, expectedPlanId),
|
||||
);
|
||||
await audit.append("repository.workspace-synchronized", {
|
||||
localPath: safePath,
|
||||
branch: result.plan.branch,
|
||||
upstream: result.plan.upstream,
|
||||
previousSha: result.plan.currentSha,
|
||||
targetSha: result.plan.targetSha,
|
||||
backupBranch: result.backupBranch,
|
||||
stashSha: result.stash?.sha || null,
|
||||
ignoredFilesPreserved: true,
|
||||
applied: result.applied,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
);
|
||||
register("repository:set-origin", async ({ localPath, remoteUrl }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
|
||||
@@ -26,7 +26,9 @@ class RepositoryMonitor {
|
||||
this.watchers = new Map();
|
||||
this.changed = new Set();
|
||||
this.lastCheckedAt = new Map();
|
||||
this.lastFetchedAt = new Map();
|
||||
this.watchTimer = null;
|
||||
this.fetchRunning = false;
|
||||
}
|
||||
|
||||
setPaths(paths) {
|
||||
@@ -46,6 +48,13 @@ class RepositoryMonitor {
|
||||
for (const existing of [...this.lastCheckedAt.keys()]) {
|
||||
if (!watched.has(existing)) this.lastCheckedAt.delete(existing);
|
||||
}
|
||||
for (const existing of [...this.lastFetchedAt.keys()]) {
|
||||
if (!watched.has(existing)) this.lastFetchedAt.delete(existing);
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const localPath of this.paths) {
|
||||
if (!this.lastFetchedAt.has(localPath)) this.lastFetchedAt.set(localPath, now);
|
||||
}
|
||||
this.syncWatchers();
|
||||
}
|
||||
|
||||
@@ -105,6 +114,60 @@ class RepositoryMonitor {
|
||||
return sinceLastCheck >= SAFETY_CHECK_INTERVAL_MS;
|
||||
}
|
||||
|
||||
fetchIntervalMs() {
|
||||
const minutes = Number(this.store.data.preferences.fetchIntervalMinutes);
|
||||
return Number.isFinite(minutes) && minutes > 0 ? Math.min(minutes, 240) * 60_000 : 0;
|
||||
}
|
||||
|
||||
shouldFetch(localPath, now) {
|
||||
const interval = this.fetchIntervalMs();
|
||||
return interval > 0
|
||||
&& !this.paused.has(localPath)
|
||||
&& now - (this.lastFetchedAt.get(localPath) || now) >= interval;
|
||||
}
|
||||
|
||||
async recordStatus(localPath, status, reason) {
|
||||
const next = this.git.statusFingerprint(status);
|
||||
const previous = this.fingerprints.get(localPath);
|
||||
this.fingerprints.set(localPath, next);
|
||||
if (previous && previous !== next) {
|
||||
await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts, reason });
|
||||
this.onChange?.({ localPath, status, reason });
|
||||
}
|
||||
}
|
||||
|
||||
async fetchRemoteUpdates(now = Date.now()) {
|
||||
if (this.fetchRunning) return;
|
||||
const queue = this.paths.filter((localPath) => this.shouldFetch(localPath, now));
|
||||
if (!queue.length) return;
|
||||
this.fetchRunning = true;
|
||||
try {
|
||||
const workers = Array.from({ length: Math.min(2, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const localPath = queue.shift();
|
||||
// Mark the attempt before awaiting the network. A failing remote should
|
||||
// not be retried every local poll interval.
|
||||
this.lastFetchedAt.set(localPath, Date.now());
|
||||
try {
|
||||
const result = await this.git.fetch(localPath);
|
||||
await this.recordStatus(localPath, result.status, 'remote-state-changed');
|
||||
await this.diagnostics?.debug('repository-monitor.fetch.completed', {
|
||||
localPath,
|
||||
branch: result.status?.branch?.head,
|
||||
ahead: result.status?.branch?.ahead,
|
||||
behind: result.status?.branch?.behind,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.diagnostics?.warning('repository-monitor.fetch.failed', { localPath, message: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
} finally {
|
||||
this.fetchRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
pause(localPath) { if (localPath) this.paused.add(localPath); }
|
||||
resume(localPath) { if (localPath) this.paused.delete(localPath); }
|
||||
|
||||
@@ -128,6 +191,7 @@ class RepositoryMonitor {
|
||||
}
|
||||
|
||||
async tick() {
|
||||
void this.fetchRemoteUpdates().catch((error) => this.diagnostics?.warning('repository-monitor.fetch-cycle.failed', error));
|
||||
if (this.running || !this.paths.length) return;
|
||||
this.running = true;
|
||||
try {
|
||||
@@ -140,13 +204,7 @@ class RepositoryMonitor {
|
||||
this.lastCheckedAt.set(localPath, Date.now());
|
||||
try {
|
||||
const status = await this.git.status(localPath);
|
||||
const next = this.git.statusFingerprint(status);
|
||||
const previous = this.fingerprints.get(localPath);
|
||||
this.fingerprints.set(localPath, next);
|
||||
if (previous && previous !== next) {
|
||||
await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts });
|
||||
this.onChange?.({ localPath, status, reason: 'working-tree-changed' });
|
||||
}
|
||||
await this.recordStatus(localPath, status, 'working-tree-changed');
|
||||
} catch (error) {
|
||||
const next = `error:${error.message}`;
|
||||
const previous = this.fingerprints.get(localPath);
|
||||
|
||||
@@ -53,9 +53,10 @@ function parseCapabilityOutput(output) {
|
||||
}
|
||||
|
||||
class SshService {
|
||||
constructor({ store, diagnostics, idleConnectionMs = 60_000 }) {
|
||||
constructor({ store, diagnostics, idleConnectionMs = 60_000, clientFactory = loadSshClient }) {
|
||||
this.store = store;
|
||||
this.diagnostics = diagnostics;
|
||||
this.clientFactory = clientFactory;
|
||||
// Every command used to pay for a TCP handshake, a key exchange and an
|
||||
// authentication round trip. Sessions are kept per server for a short while
|
||||
// so a sequence of commands shares one connection.
|
||||
@@ -104,7 +105,7 @@ class SshService {
|
||||
return { valid: true, method: 'privateKey', encrypted: Boolean(passphrase), privateKeyPath };
|
||||
}
|
||||
|
||||
async connectionOptions(server, { trustOnFirstUse = false } = {}) {
|
||||
async connectionOptions(server, { trustOnFirstUse = false, expectedFingerprint = null } = {}) {
|
||||
const credentials = this.store.getServerCredentials(server.id);
|
||||
let observedFingerprint = null;
|
||||
const options = {
|
||||
@@ -116,7 +117,8 @@ class SshService {
|
||||
keepaliveCountMax: 3,
|
||||
hostVerifier: (key) => {
|
||||
observedFingerprint = fingerprintKey(key);
|
||||
return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint);
|
||||
const trustedFingerprint = String(server.hostFingerprint || expectedFingerprint || '').trim();
|
||||
return trustOnFirstUse || Boolean(trustedFingerprint && observedFingerprint === trustedFingerprint);
|
||||
},
|
||||
};
|
||||
if (server.authType === 'password') options.password = credentials.password;
|
||||
@@ -137,7 +139,7 @@ class SshService {
|
||||
if (!server) throw new Error('The configured SSH server no longer exists.');
|
||||
// A trust-on-first-use connection is established without checking the
|
||||
// fingerprint, so it must never serve a later verified call.
|
||||
if (options.trustOnFirstUse) return this.withDedicatedClient(server, action, options);
|
||||
if (options.trustOnFirstUse || options.expectedFingerprint) return this.withDedicatedClient(server, action, options);
|
||||
return this.withPooledClient(server, action, options);
|
||||
}
|
||||
|
||||
@@ -243,7 +245,7 @@ class SshService {
|
||||
|
||||
async withDedicatedClient(server, action, options = {}, { keepOpen = false } = {}) {
|
||||
const serverId = server.id;
|
||||
const Client = loadSshClient();
|
||||
const Client = this.clientFactory();
|
||||
const connection = await this.connectionOptions(server, options);
|
||||
const client = new Client();
|
||||
const started = Date.now();
|
||||
@@ -414,7 +416,51 @@ class SshService {
|
||||
}));
|
||||
}
|
||||
|
||||
async test(serverId, { trustOnFirstUse = true } = {}) {
|
||||
async probeHostFingerprint(serverId) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server) throw new Error('The configured SSH server no longer exists.');
|
||||
const Client = this.clientFactory();
|
||||
const client = new Client();
|
||||
let observedFingerprint = null;
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
try { client.end(); } catch { /* handshake already closed */ }
|
||||
callback(value);
|
||||
};
|
||||
const completeProbe = (error = null) => {
|
||||
if (observedFingerprint) {
|
||||
finish(resolve, {
|
||||
fingerprint: observedFingerprint,
|
||||
server: { id: server.id, name: server.name, host: server.host, port: server.port || 22 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const wrapped = new Error(`Could not read the SSH host fingerprint: ${error?.message || 'the server closed the handshake'}`);
|
||||
wrapped.code = error?.code || 'SSH_HOST_KEY_PROBE_FAILED';
|
||||
finish(reject, wrapped);
|
||||
};
|
||||
const timer = setTimeout(() => completeProbe(new Error('The SSH host-key probe timed out.')), 25_000);
|
||||
client.on('error', completeProbe);
|
||||
client.on('close', () => completeProbe());
|
||||
client.on('end', () => completeProbe());
|
||||
client.connect({
|
||||
host: server.host,
|
||||
port: server.port || 22,
|
||||
username: server.username,
|
||||
readyTimeout: 20_000,
|
||||
hostVerifier: (key) => {
|
||||
observedFingerprint = fingerprintKey(key);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async test(serverId, { trustOnFirstUse = false, expectedFingerprint = null } = {}) {
|
||||
return this.withClient(serverId, async (client, server, fingerprint) => {
|
||||
const script = `
|
||||
platform=$(uname -srm 2>/dev/null || true)
|
||||
@@ -449,7 +495,7 @@ printf 'baseWritable=%s\\n' "$base_writable"
|
||||
capabilities,
|
||||
output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'),
|
||||
};
|
||||
}, { trustOnFirstUse });
|
||||
}, { trustOnFirstUse, expectedFingerprint });
|
||||
}
|
||||
|
||||
async exec(serverId, command, options = {}) {
|
||||
|
||||
@@ -431,7 +431,7 @@ function createUnraidInventoryMethods({
|
||||
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink: true });
|
||||
if (plan.additions.length) await this.store.createRecoverySnapshot?.(`automatic-server-links-${serverId}`);
|
||||
const linkedRepositories = new Set(workloads
|
||||
.filter((workload) => workload.link?.repositoryFullName)
|
||||
.filter((workload) => workload.classification?.type !== "stale-link" && workload.link?.repositoryFullName)
|
||||
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
|
||||
for (const addition of plan.additions) {
|
||||
const workload = workloads.find((item) => item.workloadId === addition.workloadId);
|
||||
@@ -466,7 +466,9 @@ function createUnraidInventoryMethods({
|
||||
reconciliationPlan(server, workloads, repositories, { autoLink = true } = {}) {
|
||||
const profiles = this.allSshProfiles().filter((profile) => profile.serverId === server.id);
|
||||
const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId));
|
||||
const linkedRepositories = new Set(workloads.filter((item) => item.link?.repositoryFullName).map((item) => String(item.link.repositoryFullName).toLowerCase()));
|
||||
const linkedRepositories = new Set(workloads
|
||||
.filter((item) => item.classification?.type !== "stale-link" && item.link?.repositoryFullName)
|
||||
.map((item) => String(item.link.repositoryFullName).toLowerCase()));
|
||||
const additions = [];
|
||||
const updates = [];
|
||||
const conflicts = [];
|
||||
|
||||
+151
-19
@@ -14,6 +14,85 @@ function safeRepositoryPart(value, label) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function verifyReleaseManifest({
|
||||
manifestBytes,
|
||||
signatureBytes,
|
||||
publicKey,
|
||||
update,
|
||||
assetName,
|
||||
}) {
|
||||
if (
|
||||
!Buffer.isBuffer(manifestBytes) ||
|
||||
manifestBytes.length < 100 ||
|
||||
manifestBytes.length > 1_000_000
|
||||
) {
|
||||
throw new Error("The signed release manifest has an invalid size.");
|
||||
}
|
||||
const signatureText = Buffer.from(signatureBytes || "")
|
||||
.toString("utf8")
|
||||
.trim();
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(signatureText)) {
|
||||
throw new Error("The release manifest signature is invalid.");
|
||||
}
|
||||
const signature = Buffer.from(signatureText, "base64");
|
||||
if (signature.length !== 64) {
|
||||
throw new Error("The release manifest signature is invalid.");
|
||||
}
|
||||
let verified = false;
|
||||
try {
|
||||
verified = crypto.verify(null, manifestBytes, publicKey, signature);
|
||||
} catch {
|
||||
verified = false;
|
||||
}
|
||||
if (!verified) {
|
||||
const error = new Error(
|
||||
"The release manifest was not signed by the trusted ForgeFlow publisher key.",
|
||||
);
|
||||
error.code = "RELEASE_SIGNATURE_INVALID";
|
||||
throw error;
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(manifestBytes.toString("utf8"));
|
||||
} catch {
|
||||
throw new Error("The signed release manifest is not valid JSON.");
|
||||
}
|
||||
const expectedVersion = String(update.remoteVersion || "").trim();
|
||||
const expectedCommit = String(update.remoteSha || "").toLowerCase();
|
||||
if (
|
||||
manifest.schemaVersion !== 1 ||
|
||||
manifest.product !== "ForgeFlow" ||
|
||||
manifest.version !== expectedVersion ||
|
||||
manifest.tag !== `v${expectedVersion}` ||
|
||||
manifest.signature?.algorithm !== "Ed25519" ||
|
||||
(expectedCommit &&
|
||||
String(manifest.commit || "").toLowerCase() !== expectedCommit)
|
||||
) {
|
||||
const error = new Error(
|
||||
"The signed release manifest does not match the requested ForgeFlow update.",
|
||||
);
|
||||
error.code = "RELEASE_MANIFEST_MISMATCH";
|
||||
throw error;
|
||||
}
|
||||
const artifact = Array.isArray(manifest.artifacts)
|
||||
? manifest.artifacts.find((item) => item?.name === assetName)
|
||||
: null;
|
||||
if (
|
||||
!artifact ||
|
||||
!Number.isSafeInteger(artifact.bytes) ||
|
||||
artifact.bytes < 1_000_000 ||
|
||||
!/^[a-f0-9]{64}$/.test(String(artifact.sha256 || ""))
|
||||
) {
|
||||
const error = new Error(
|
||||
`The signed release manifest has no valid entry for ${assetName}.`,
|
||||
);
|
||||
error.code = "RELEASE_MANIFEST_INCOMPLETE";
|
||||
throw error;
|
||||
}
|
||||
return { manifest, artifact };
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -156,6 +235,7 @@ class UpdateService {
|
||||
powershellPath = null,
|
||||
handshakeTimeoutMs = 12000,
|
||||
handshakePollMs = 100,
|
||||
updatePublicKey = null,
|
||||
}) {
|
||||
this.store = store;
|
||||
this.gitea = gitea;
|
||||
@@ -168,6 +248,7 @@ class UpdateService {
|
||||
this.powershellPath = powershellPath;
|
||||
this.handshakeTimeoutMs = handshakeTimeoutMs;
|
||||
this.handshakePollMs = handshakePollMs;
|
||||
this.updatePublicKey = updatePublicKey;
|
||||
this.staged = null;
|
||||
}
|
||||
|
||||
@@ -291,7 +372,7 @@ class UpdateService {
|
||||
));
|
||||
if (!release || release.draft || release.prerelease) {
|
||||
const error = new Error(
|
||||
`ForgeFlow ${update.remoteVersion} has no published binary release yet. The source branch was updated, but the matching Windows installer/portable assets were not published. Run Publish-Missing-Binary-Release.ps1 from the release source or publish the four required assets in Gitea.`,
|
||||
`ForgeFlow ${update.remoteVersion} has no published binary release yet. The source branch was updated, but the matching signed Windows release was not published. Run Publish-Missing-Binary-Release.ps1 from the release source.`,
|
||||
);
|
||||
error.code = "BINARY_RELEASE_NOT_FOUND";
|
||||
throw error;
|
||||
@@ -300,33 +381,70 @@ class UpdateService {
|
||||
const portable = Boolean(this.appInfo.portableExecutablePath);
|
||||
const assetName = `ForgeFlow-${portable ? "Portable" : "Setup"}-${update.remoteVersion}-win-x64.exe`;
|
||||
const checksumName = `${assetName}.sha256`;
|
||||
const manifestName = `ForgeFlow-${update.remoteVersion}-release-manifest.json`;
|
||||
const signatureName = `${manifestName}.sig`;
|
||||
const assets = Array.isArray(release.assets) ? release.assets : [];
|
||||
const asset = assets.find((item) => item.name === assetName);
|
||||
const checksumAsset = assets.find((item) => item.name === checksumName);
|
||||
if (!asset?.id || !checksumAsset?.id) {
|
||||
const manifestAsset = assets.find((item) => item.name === manifestName);
|
||||
const signatureAsset = assets.find((item) => item.name === signatureName);
|
||||
if (
|
||||
!asset?.id ||
|
||||
!checksumAsset?.id ||
|
||||
!manifestAsset?.id ||
|
||||
!signatureAsset?.id
|
||||
) {
|
||||
const error = new Error(
|
||||
`Release v${update.remoteVersion} is missing ${assetName} or its SHA-256 file.`,
|
||||
`Release v${update.remoteVersion} is incomplete: the executable, SHA-256 file, signed manifest and signature are all required.`,
|
||||
);
|
||||
error.code = "BINARY_RELEASE_INCOMPLETE";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [binary, checksumBytes] = await Promise.all([
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
asset.id,
|
||||
{ downloadUrl: asset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
checksumAsset.id,
|
||||
{ downloadUrl: checksumAsset.browser_download_url },
|
||||
),
|
||||
]);
|
||||
const [binary, checksumBytes, manifestBytes, signatureBytes] =
|
||||
await Promise.all([
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
asset.id,
|
||||
{ downloadUrl: asset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
checksumAsset.id,
|
||||
{ downloadUrl: checksumAsset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
manifestAsset.id,
|
||||
{ downloadUrl: manifestAsset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
signatureAsset.id,
|
||||
{ downloadUrl: signatureAsset.browser_download_url },
|
||||
),
|
||||
]);
|
||||
|
||||
const publicKey =
|
||||
this.updatePublicKey ||
|
||||
(await fs.readFile(
|
||||
path.join(this.sourcePath, "build", "update-signing-public.pem"),
|
||||
));
|
||||
const { manifest, artifact } = verifyReleaseManifest({
|
||||
manifestBytes,
|
||||
signatureBytes,
|
||||
publicKey,
|
||||
update,
|
||||
assetName,
|
||||
});
|
||||
if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) {
|
||||
const preview = binary.subarray(0, 200).toString("utf8").trim();
|
||||
const looksLikeMetadata =
|
||||
@@ -348,6 +466,16 @@ class UpdateService {
|
||||
?.toLowerCase();
|
||||
if (!/^[a-f0-9]{64}$/.test(expectedSha256 || ""))
|
||||
throw new Error("The release SHA-256 file is invalid.");
|
||||
if (expectedSha256 !== artifact.sha256) {
|
||||
throw new Error(
|
||||
"The release checksum does not match the signed publisher manifest.",
|
||||
);
|
||||
}
|
||||
if (binary.length !== artifact.bytes) {
|
||||
throw new Error(
|
||||
"The downloaded Windows update size does not match the signed publisher manifest.",
|
||||
);
|
||||
}
|
||||
const sha256 = crypto.createHash("sha256").update(binary).digest("hex");
|
||||
if (sha256 !== expectedSha256)
|
||||
throw new Error(
|
||||
@@ -368,6 +496,8 @@ class UpdateService {
|
||||
? this.appInfo.portableExecutablePath
|
||||
: this.appInfo.executablePath,
|
||||
releaseTag: release.tag_name,
|
||||
publisherKeyId: manifest.signature.keyId,
|
||||
releaseManifest: manifestName,
|
||||
downloadedAt: new Date().toISOString(),
|
||||
downloaded: true,
|
||||
};
|
||||
@@ -383,6 +513,7 @@ class UpdateService {
|
||||
bytes: binary.length,
|
||||
sha256,
|
||||
portable,
|
||||
publisherKeyId: manifest.signature.keyId,
|
||||
});
|
||||
return metadata;
|
||||
}
|
||||
@@ -713,6 +844,7 @@ class UpdateService {
|
||||
module.exports = {
|
||||
UpdateService,
|
||||
safeRepositoryPart,
|
||||
verifyReleaseManifest,
|
||||
resolveWindowsPowerShellPath,
|
||||
windowsUpdaterSpawnOptions,
|
||||
waitForUpdaterStarted,
|
||||
|
||||
Reference in New Issue
Block a user