From 6b93391a9b106d5aafad2cab50b0739bdc4fcdb9 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:20:31 +0200 Subject: [PATCH] feat: add transactional deploy key lifecycle --- SOURCE_MANIFEST.txt | 49 ++---- main.cjs | 9 + preload.cjs | 6 + src/main/deploy-key-lifecycle-service.cjs | 191 ++++++++++++++++++++++ src/main/gitea-service.cjs | 13 ++ src/main/ipc.cjs | 28 ++++ src/main/unraid-deploy-key-host.cjs | 76 +++++++++ src/renderer/app.js | 66 +++++++- src/renderer/mock-bridge.js | 25 +++ tests/deploy-key-lifecycle.test.mjs | 139 ++++++++++++++++ 10 files changed, 563 insertions(+), 39 deletions(-) create mode 100644 src/main/deploy-key-lifecycle-service.cjs create mode 100644 src/main/unraid-deploy-key-host.cjs create mode 100644 tests/deploy-key-lifecycle.test.mjs diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 3429e9f..ff981e1 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -15,38 +15,8 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png 5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md -8e84a93929c6f359f090d67c16c7eea4a1c123e8170d3bd9b269f047f8560215 284299 coverage/tmp/coverage-10244-1785335090178-0.json -8e0457e916f249b7a3389894df3f2b664ad8797db1c792e0c107f57ca9f56bec 303088 coverage/tmp/coverage-13124-1785335091173-0.json -f219872995f4c34d4d6628c6ac7859ee51f3441cc03272ab1653da7acf7959bd 354433 coverage/tmp/coverage-18204-1785335089962-0.json -95bee92580b38e4488d5ec5653b0d92e674ad8fa537cee9705143f079cc4993d 317634 coverage/tmp/coverage-19144-1785335101870-0.json -50158ef242c1b19100949f7beb8e40f81f84fed97c6816db672a7c2a6e897016 329357 coverage/tmp/coverage-23492-1785335091006-0.json -93a3e9159d736053e543cff989779d91f1d21e10f0ff6d3e554e867a54513ee3 342639 coverage/tmp/coverage-25088-1785335089991-0.json -fa262d693cb615960736e290858dddedee49a1e7db5f863d8649fcbcbf10afbd 335944 coverage/tmp/coverage-3052-1785335090008-0.json -07850b9d5fce7c6b271cbdef84af7e079f1034a4630699f0c584694228951ae6 282471 coverage/tmp/coverage-34880-1785335091385-0.json -a11df85ec00b29553b6d396f36fc567db2982a619321e0fdd33ec2a628da4fec 298998 coverage/tmp/coverage-36452-1785335089751-0.json -9b7a2ad01bca25678f57d254f93cd894cc34ec2243aef93cb93905dd7ce41a9f 301397 coverage/tmp/coverage-36468-1785335089356-0.json -70aafc483e2158fb120049e59655a4aed7836d25ad91e66dfe8c50016670ee6f 344612 coverage/tmp/coverage-39296-1785335095999-0.json -28278f6e18e9bab0e5c684596591b4cbf814ac477510d8796d036be678086dbb 288319 coverage/tmp/coverage-39956-1785335090649-0.json -ec355623c897784f803bbcf4531ead16c863283d9e58f8d423781706b5300c20 304507 coverage/tmp/coverage-42012-1785335089647-0.json -1d06d8d2a2f2a6ede0c6912dcd8c9fada4955f52b1dbf85d330b950c4feead89 318325 coverage/tmp/coverage-42356-1785335091476-0.json -4dce288c8da452d2b12cfed7de6efd0f36ebb64ba0262220b964bd991e427cd5 352005 coverage/tmp/coverage-43752-1785335089626-0.json -21ed865ff22e016b6da1f4d1886b6f87fd1e4a471c6606d6bcb748c6bdd30b75 111307 coverage/tmp/coverage-44028-1785335091684-0.json -f05901535653256b5a05b8a8f8aa6b5019a54c8d099adbe1d702713a8f00d12c 89174 coverage/tmp/coverage-47072-1785335088858-0.json -d2a8f1f5fff4f7b090b182b6f7666ef06d08b1b0be497b0f7061e5a5234145d0 360056 coverage/tmp/coverage-47440-1785335147487-0.json -3d4927400e2212ce74d9026ab678d9766ce16917d3830bc4e5f47cb48fae6381 344933 coverage/tmp/coverage-48456-1785335092567-0.json -5987881f09402e371a5998b8f5208e41156e4d9a270a4d74aa70512faba406ab 302345 coverage/tmp/coverage-52268-1785335090577-0.json -4d1dde6f8cca29f40b2dd018cdefc9ef2201941323ad53a0af892c6bd4afebc9 345120 coverage/tmp/coverage-58112-1785335104676-0.json -32aa3248a14053e28632d42490d0a4d3b082fa8406e37c35645a9a7cd6e3d628 302512 coverage/tmp/coverage-58124-1785335090307-0.json -ee85e5c654ded256066430543d4fea9394db545e4a92054bad731f65cec7dc73 310268 coverage/tmp/coverage-58672-1785335091776-0.json -b2b99cf55dc44ce6fdb87f9bd62425d72231dc0f04558d0706a2ac7619a4a505 321056 coverage/tmp/coverage-58732-1785335091791-0.json -33c28c4ef5b3c3c89616ff6b6f4f1ce00aca3169c93315f428a69ca62207cfb8 357939 coverage/tmp/coverage-59060-1785335093199-0.json -d2ecb7c43ded5e946f3dbc1067809d5ddb12d74f359c2e62756d243e2cfdc33f 421305 coverage/tmp/coverage-59124-1785335092134-0.json -49af6e452b0f192565453e6a8dffc979948a9cbbeb7453e1ce325cfc47f751b3 296834 coverage/tmp/coverage-59200-1785335090383-0.json -c4b99d78def3b2fe1223e80f3a6648bec128dc89b5b45c1d308d435241e51f21 360489 coverage/tmp/coverage-59612-1785335091346-0.json -72501c88d2e3699ca478f031db0ac31859d39b66db96f66fd8887feda6158a11 367435 coverage/tmp/coverage-59824-1785335091587-0.json -43a7b9b9f701313bbe345c3e7baaa51525d25675fa2cad1a14d72b6a16676730 344498 coverage/tmp/coverage-59992-1785335090598-0.json -24ff83322c312d1a71d3446994011d107daf1da2c06e66c2819ad8782c02f633 335634 coverage/tmp/coverage-60172-1785335098629-0.json -405c0992d25d0779ae42ff7728e5471d737ea446f8c11b6af911927b6be48d11 327419 coverage/tmp/coverage-60332-1785335088943-0.json +2889007aef2cba3e324cda97892a7292ccf9b194e38b2a41c851f65a8a91bc17 89174 coverage/tmp/coverage-22080-1785338379849-0.json +cd725a6cfaacbe4580c61193a8772674279692b9632b4f41612c7eb4e349a57c 345759 coverage/tmp/coverage-40380-1785338384553-0.json 21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md 72e846f591c47a0291e7466e58e052d3d5afcf551c4e6c848632ac3c552a1244 3043 docs/CURRENT_STATE.md @@ -116,11 +86,11 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE -3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs +ca5cebd83df4db0c5b77ded9914eef09e4d027b920e524d631fece8ecfcba6d9 13468 main.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md f3e2a262e251eb165f342dc3c184625ec10d56e536b5a987199b9944f463eee9 181069 package-lock.json 48e25abda43f5463c5c4631f5a29f7e9b2e744383a23489b4e54dedf38c74940 4817 package.json -82865e814a82628b778c53ec83bcda26bf1109d8bb14c11208530089f4dc96db 10522 preload.cjs +d1924fa08d1f33762bb965faca8dfb71d7c9de23927a4e33be663de18945d849 11316 preload.cjs abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1 @@ -142,13 +112,14 @@ d2dd98055e50f11b4e1484531e43fb5ac7f876bea4b9cf5bca2cb0a15022b60a 1913 dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs 80e0afcfa77dbc617c425cd603e5c165ea425b5f47046538657bc1dc51d7b1e7 28670 src/main/config-store.cjs 2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs +86e9fc2eda66b4b563f6c4bbb87d3e8514340d484fb503b73137e63b6b05c3c9 14597 src/main/deploy-key-lifecycle-service.cjs 9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs 3ce45837099ac7bddc024974bd839575b4b765a7df9055e7d45ef889dc85bf7f 15623 src/main/git-validator-service.cjs -d85d5b1abb35e8bd7f1273a697914eeaf96567d3d4ce3f55f364765d35ea4ad9 19900 src/main/gitea-service.cjs -a8c677d5ee1343f6ff230e8a30c2ecee9f47c56c89518e34f25755d6c0c3b685 51410 src/main/ipc.cjs +2faaef0eeec1e473db82b94243674e3812ef5869358b0ec6abf8d645a8794623 20768 src/main/gitea-service.cjs +6b95d18288aa3becacaaaa21ee7f827af87e977755c0e9d68132441fa72f38cc 53023 src/main/ipc.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs @@ -156,15 +127,16 @@ e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs 52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs +2ede80cd1565a7f2c282cc58d35dc0889d58d7465346bc723026b9c8be4df0ac 9501 src/main/unraid-deploy-key-host.cjs b90285d4bece4705554b2dae72f3db61a614b87f57b28a03b1e94d55f1ad4ad0 149903 src/main/unraid-deployment-service.cjs b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs -953b027fedcee2c2396ec5c8cf938013f2630d8ef76ab5e917a02d723303234f 241018 src/renderer/app.js +71cc25ec300737a8a0072602ae3c484ab2e670ef0c88d26b0777717de119512d 247577 src/renderer/app.js 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png fb7ed47f9aac50d9259d7d3c3bb2010c7bfdd2fe8e8e47ca2744bb22f0057d54 830 src/renderer/index.html -ae8d6778614077c5f46032035a10fb9803219c14e8bfabefacf1affdec06fb71 62802 src/renderer/mock-bridge.js +5c6e8e0080043fc320c689c59c9611fa23b895b2fab7783866e8357dd3d1b1fa 65633 src/renderer/mock-bridge.js 45692591428575b518678a6b548c25d3de95f568541e5648b0f06f42c48b5bbf 77872 src/renderer/styles.css 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs @@ -181,6 +153,7 @@ f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs 454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs +b7e009fed4171d6dd6b4c3154ba1d3f7198e98f5b79b298687841fc8169447cd 9354 tests/deploy-key-lifecycle.test.mjs 1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs bf68c4dc91a2604235c6a7848088bcd9566fbeaa089b86ec1e0a4fcfc54ca9d2 7677 tests/deployment-status.test.mjs fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs diff --git a/main.cjs b/main.cjs index 855cd32..150951f 100644 --- a/main.cjs +++ b/main.cjs @@ -25,6 +25,8 @@ const { UnraidDeploymentService, } = require("./src/main/unraid-deployment-service.cjs"); const { AuditService } = require("./src/main/audit-service.cjs"); +const { DeployKeyLifecycleService } = require("./src/main/deploy-key-lifecycle-service.cjs"); +const { UnraidDeployKeyHost } = require("./src/main/unraid-deploy-key-host.cjs"); const { GitValidatorService } = require("./src/main/git-validator-service.cjs"); const { ExternalToolsService, @@ -264,6 +266,12 @@ app sourcePath: app.getAppPath(), onOperationChange: reportOperationChange, }); + const deployKeys = new DeployKeyLifecycleService({ + store, + gitea, + keyHost: new UnraidDeployKeyHost({ ssh }), + audit, + }); const updates = new UpdateService({ store, gitea, @@ -305,6 +313,7 @@ app repositories, deployments, unraid, + deployKeys, ssh, updates, preflight, diff --git a/preload.cjs b/preload.cjs index db35698..3ffd95b 100644 --- a/preload.cjs +++ b/preload.cjs @@ -126,6 +126,12 @@ contextBridge.exposeInMainWorld( linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'server-git', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }), configureServerGitAccess: (repository, profileId) => invoke('deployment:configure-server-git-access', { repository, profileId }), verifyServerGitProfile: (repository, profileId) => invoke('deployment:verify-server-git-profile', { repository, profileId }), + deployKeyInventory: (repository, profileId) => invoke('deployment:deploy-key-inventory', { repository, profileId }), + planDeployKeyRotation: (repository, profileId) => invoke('deployment:plan-deploy-key-rotation', { repository, profileId }), + applyDeployKeyRotation: (repository, profileId, planId) => invoke('deployment:apply-deploy-key-rotation', { repository, profileId, planId }), + planDeployKeyRevocation: (repository, profileId) => invoke('deployment:plan-deploy-key-revocation', { repository, profileId }), + applyDeployKeyRevocation: (repository, profileId, planId) => invoke('deployment:apply-deploy-key-revocation', { repository, profileId, planId }), + restoreDeployKey: (repository, profileId) => invoke('deployment:restore-deploy-key', { repository, profileId }), applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }), reconcileDeployment: (fullName, profileId) => invoke('deployment:reconcile', { fullName, profileId }), refreshOperations: (operationId = null) => invoke('operations:refresh', { operationId }), diff --git a/src/main/deploy-key-lifecycle-service.cjs b/src/main/deploy-key-lifecycle-service.cjs new file mode 100644 index 0000000..7ac3aef --- /dev/null +++ b/src/main/deploy-key-lifecycle-service.cjs @@ -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 }; diff --git a/src/main/gitea-service.cjs b/src/main/gitea-service.cjs index 022c552..81a1313 100644 --- a/src/main/gitea-service.cjs +++ b/src/main/gitea-service.cjs @@ -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, diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs index 1d9e2d0..d1592e4 100644 --- a/src/main/ipc.cjs +++ b/src/main/ipc.cjs @@ -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( diff --git a/src/main/unraid-deploy-key-host.cjs b/src/main/unraid-deploy-key-host.cjs new file mode 100644 index 0000000..948e501 --- /dev/null +++ b/src/main/unraid-deploy-key-host.cjs @@ -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 }; diff --git a/src/renderer/app.js b/src/renderer/app.js index 96b04c8..1926e17 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -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" - ? `` + ? `` : ""; return `
${escapeHtml(identity.initial)}
Container${escapeHtml(identity.name)}${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}
${syncLabel}
${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}

${escapeHtml(profile.name)}

${escapeHtml(providerDetail)}

${health.label}
${isSsh ? `` : ""}${serverAccessAction}${webUi ? `` : ""}${managesDockerMan ? `` : ""}${ready ? `` : ""}${state.previousSha && rollbackConfigured ? `` : ""}
`; } @@ -1466,6 +1467,13 @@ function renderModal() {
` }
${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."}
`; } + if (ui.modal.type === "deploy-key-lifecycle") { + const lifecycle = ui.deployKeyLifecycle; + const inventory = lifecycle?.inventory; + const rotation = lifecycle?.rotation; + const revocation = lifecycle?.revocation; + return ``; + } 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); diff --git a/src/renderer/mock-bridge.js b/src/renderer/mock-bridge.js index 5928c46..8881cd4 100644 --- a/src/renderer/mock-bridge.js +++ b/src/renderer/mock-bridge.js @@ -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) { diff --git a/tests/deploy-key-lifecycle.test.mjs b/tests/deploy-key-lifecycle.test.mjs new file mode 100644 index 0000000..d3cdf9b --- /dev/null +++ b/tests/deploy-key-lifecycle.test.mjs @@ -0,0 +1,139 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { DeployKeyLifecycleService } = require("../src/main/deploy-key-lifecycle-service.cjs"); +const { UnraidDeployKeyHost, parseDeployKeyMarker } = require("../src/main/unraid-deploy-key-host.cjs"); + +const repository = { fullName: "Jens/Portfolio" }; +const baseProfile = { id: "production", serverId: "unraid", deploymentMode: "server-git", serverGitAccess: { configured: true, deployKeyId: 7, keyFingerprint: "SHA256:old", hostFingerprint: "SHA256:host" } }; + +function fixture(overrides = {}) { + let profile = structuredClone(baseProfile); + const events = []; + const remoteKeys = overrides.remoteKeys || [{ id: 7, title: "ForgeFlow old", read_only: true, key: "ssh-ed25519 T0xE old" }]; + const store = { + getDeploymentProfile: () => profile, + getServer: () => ({ id: "unraid", name: "Unraid" }), + getRepositories: () => [{ fullName: repository.fullName }], + getDeploymentProfiles: () => [profile, ...(overrides.otherProfiles || [])], + saveDeploymentProfile: async (_name, value) => { if (overrides.saveFails) throw new Error("switch failed"); profile = structuredClone(value); events.push("profile-saved"); return profile; }, + createRecoverySnapshot: async () => ({ filePath: "snapshot.json" }), + }; + const gitea = { + listDeployKeys: async () => structuredClone(remoteKeys), + createReadOnlyDeployKey: async () => { if (overrides.registrationFails) throw new Error("registration failed"); return { id: 8, title: "new", read_only: overrides.writable !== true, key: "ssh-ed25519 TkVX new" }; }, + deleteDeployKey: async (_owner, _repo, id) => { events.push(`delete:${id}`); if (overrides.deleteOldFails && id === 7) throw new Error("old revoke failed"); return { deleted: true }; }, + }; + const keyHost = { + inspect: async () => overrides.inspect || ({ privateKeyPresent: true, publicKey: "ssh-ed25519 T0xE old", fingerprint: overrides.changedFingerprint ? "SHA256:changed" : "SHA256:old", hostFingerprint: "SHA256:host" }), + backup: async () => ({ recovery: "server-backup", publicKey: "ssh-ed25519 T0xE old" }), + generate: async () => ({ publicKey: "ssh-ed25519 TkVX new", fingerprint: "SHA256:new" }), + verifyCandidate: async () => overrides.verifyFails ? ({ ready: false, fingerprint: "SHA256:new" }) : ({ ready: true, fingerprint: "SHA256:new", hostFingerprint: "SHA256:host", remoteSha: "a".repeat(40) }), + preflightCandidate: async () => { if (overrides.preflightFails) throw new Error("preflight failed"); events.push("preflight"); }, + promote: async () => { events.push("promote"); }, + verifyActive: async () => overrides.postFails ? ({ ready: false }) : ({ ready: true, fingerprint: "SHA256:new" }), + commit: async () => { events.push("commit"); }, + rollback: async () => { events.push("rollback"); }, + revoke: async () => { events.push("revoke-server"); if (overrides.revokeFails) throw new Error("server revoke failed"); }, + restore: async () => ({ publicKey: "ssh-ed25519 UkVTVE9SRQ restored", fingerprint: "SHA256:restored", hostFingerprint: "SHA256:host" }), + }; + const audit = { append: async (name) => events.push(name) }; + const service = new DeployKeyLifecycleService({ store, gitea, keyHost, audit, clock: () => "2026-07-29T00:00:00.000Z" }); + return { service, events, getProfile: () => profile }; +} + +test("deploy-key rotation verifies, switches, revokes and post-verifies in order", async () => { + const { service, events, getProfile } = fixture(); + const plan = await service.planRotation({ repository, profileId: "production" }); + const result = await service.rotate({ repository, profileId: "production", expectedPlanId: plan.id }); + assert.equal(result.profile.serverGitAccess.deployKeyId, 8); + assert.equal(getProfile().serverGitAccess.keyFingerprint, "SHA256:new"); + assert.deepEqual(events.filter((event) => ["preflight", "promote", "profile-saved", "delete:7", "commit"].includes(event)), ["preflight", "promote", "profile-saved", "delete:7", "commit"]); +}); + +for (const [name, overrides, message] of [ + ["registration failure", { registrationFails: true }, /registration failed/], + ["writable candidate", { writable: true }, /write access/], + ["candidate verification failure", { verifyFails: true }, /could not prove/], + ["candidate preflight failure", { preflightFails: true }, /preflight failed/], + ["atomic profile switch failure", { saveFails: true }, /switch failed/], + ["old key revocation failure", { deleteOldFails: true }, /old revoke failed/], + ["post-rotation failure", { postFails: true }, /Post-rotation verification failed/], +]) test(`deploy-key rotation rolls back on ${name}`, async () => { + const { service, events } = fixture(overrides); + const plan = await service.planRotation({ repository, profileId: "production" }); + await assert.rejects(service.rotate({ repository, profileId: "production", expectedPlanId: plan.id }), message); + assert.ok(events.includes("rollback")); +}); + +test("rotation rejects a stale content-addressed plan", async () => { + const { service } = fixture(); + await assert.rejects(service.rotate({ repository, profileId: "production", expectedPlanId: "stale" }), (error) => error.code === "DEPLOY_KEY_ROTATION_PLAN_STALE"); +}); + +test("inventory detects stale, orphaned, shared, conflicting and changed-fingerprint keys", async () => { + const { service } = fixture({ + changedFingerprint: true, + remoteKeys: [{ id: 9, title: "ForgeFlow orphan", read_only: true, key: "ssh-ed25519 T1JQSEFO orphan" }, { id: 10, title: "writable", read_only: false, key: "ssh-ed25519 T0xE old" }], + otherProfiles: [{ id: "staging", serverId: "unraid", serverGitAccess: { deployKeyId: 11, keyFingerprint: "SHA256:changed" } }], + }); + const report = await service.inventory({ repository, profileId: "production" }); + assert.equal(report.stale, true); + assert.equal(report.orphaned.length, 1); + assert.equal(report.shared.length, 1); + assert.equal(report.conflicts.length, 1); + assert.equal(report.ready, false); +}); + +test("revocation requires reviewed impact, disables pull and preserves recovery", async () => { + const { service, events, getProfile } = fixture(); + const plan = await service.planRevocation({ repository, profileId: "production" }); + assert.equal(plan.containersUnaffected, true); + await assert.rejects(service.revoke({ repository, profileId: "production" }), (error) => error.code === "DEPLOY_KEY_REVOCATION_PLAN_REQUIRED"); + const result = await service.revoke({ repository, profileId: "production", expectedPlanId: plan.id }); + assert.equal(result.recovery, "server-backup"); + assert.equal(getProfile().deploymentMode, "monitor-only"); + assert.ok(events.includes("delete:7")); + assert.ok(events.includes("revoke-server")); +}); + +test("revoked access can be restored and verified", async () => { + const { service } = fixture(); + const result = await service.restore({ repository, profileId: "production" }); + assert.equal(result.profile.deploymentMode, "server-git"); + assert.equal(result.profile.serverGitAccess.keyFingerprint, "SHA256:restored"); + assert.equal(result.proof.ready, true); +}); + +test("failed server revocation restores repository access", async () => { + const { service, events, getProfile } = fixture({ revokeFails: true }); + const plan = await service.planRevocation({ repository, profileId: "production" }); + await assert.rejects(service.revoke({ repository, profileId: "production", expectedPlanId: plan.id }), /server revoke failed/); + assert.equal(getProfile().deploymentMode, "server-git"); + assert.ok(events.includes("delete:7")); +}); + +test("Unraid key host parser rejects unverifiable output", () => { + assert.throws(() => parseDeployKeyMarker("ordinary ssh output", "__FORGEFLOW_KEY__"), /did not return/); +}); + +test("Unraid candidate generation returns public evidence and paths but never private key content", async () => { + const publicKey = "ssh-ed25519 TkVX forgeflow"; + const ssh = { exec: async () => ({ stdout: `__FORGEFLOW_KEY_CANDIDATE__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:new\nhostFingerprint=SHA256:host\n` }) }; + const host = new UnraidDeployKeyHost({ ssh }); + const candidate = await host.generate({ repository: { fullName: "Jens/Portfolio" }, server: { id: "unraid", basePath: "/mnt/user/appdata" } }); + assert.equal(candidate.publicKey, publicKey); + assert.equal(candidate.fingerprint, "SHA256:new"); + assert.equal(candidate.privateKey, undefined); + assert.match(candidate.paths.privateKey, /candidate-[0-9a-f-]+\/deploy-key$/); +}); + +test("Unraid active key inspection exposes only public metadata", async () => { + const publicKey = "ssh-ed25519 T0xE forgeflow"; + const ssh = { exec: async () => ({ stdout: `__FORGEFLOW_KEY_INSPECT__\nprivateKeyPresent=true\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:old\nhostFingerprint=SHA256:host\n` }) }; + const host = new UnraidDeployKeyHost({ ssh }); + const evidence = await host.inspect({ repository: { fullName: "Jens/Portfolio" }, server: { id: "unraid", basePath: "/mnt/user/appdata" } }); + assert.deepEqual(evidence, { privateKeyPresent: true, publicKey, fingerprint: "SHA256:old", hostFingerprint: "SHA256:host" }); +});