From e9103295d113a0149b04606154ef052869453585 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 5 Aug 2026 04:51:18 -0500 Subject: [PATCH] fix(dashmate): load ZeroSSL config in force mode --force previously skipped the only task that loaded externalIp/apiKey/paths, so generateCsr crashed with node-forge "Attribute value not specified". Always initialize config/paths, and only skip the existing-valid-certificate short-circuit under --force (mirroring Let's Encrypt). Clear presence flags so keypair/CSR/certificate regenerate. Fixes dashpay/platform#3803 Fixes dashpay/platform#4249 Co-Authored-By: Claude --- .../obtainZeroSSLCertificateTaskFactory.js | 182 +++++++-- ...btainZeroSSLCertificateTaskFactory.spec.js | 347 +++++++++++++++++- 2 files changed, 484 insertions(+), 45 deletions(-) diff --git a/packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js index a08e7ab7d02..bf8f7b77148 100644 --- a/packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js @@ -2,6 +2,7 @@ import { Listr } from 'listr2'; import chalk from 'chalk'; import fs from 'fs'; +import path from 'path'; import lodash from 'lodash'; import wait from '../../../../util/wait.js'; import { ERRORS } from '../../../../ssl/zerossl/validateZeroSslCertificateFactory.js'; @@ -44,6 +45,43 @@ export default function obtainZeroSSLCertificateTaskFactory( */ function obtainZeroSSLCertificateTask(config) { const tasks = new Listr([ + { + title: 'Initialize configuration', + task: async (ctx) => { + // Always load configuration and paths, even under --force. + // The existing-certificate check below is the only step --force should + // skip. Skipping this init left ctx.externalIp/ctx.apiKey undefined, + // which propagated into generateCsr and crashed node-forge with + // "Attribute value not specified." See dashpay/platform#3803 / #4249. + ctx.apiKey = config.get('platform.gateway.ssl.providerConfigs.zerossl.apiKey'); + + if (!ctx.apiKey) { + throw new Error('ZeroSSL API key is not set. Please set it in the config file'); + } + + ctx.externalIp = config.get('externalIp'); + + if (!ctx.externalIp) { + throw new Error('External IP is not set. Please set it in the config file'); + } + + ctx.sslConfigDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'ssl'); + ctx.csrFilePath = path.join(ctx.sslConfigDir, 'csr.pem'); + ctx.privateKeyFilePath = path.join(ctx.sslConfigDir, 'private.key'); + ctx.bundleFilePath = path.join(ctx.sslConfigDir, 'bundle.crt'); + + fs.mkdirSync(ctx.sslConfigDir, { recursive: true }); + + if (ctx.force) { + // Force a clean regeneration: ignore any existing keypair, CSR, bundle, + // or certificate state so the generate/create/save tasks all run. + ctx.isCsrFilePresent = false; + ctx.isPrivateKeyFilePresent = false; + ctx.isBundleFilePresent = false; + ctx.certificate = null; + } + }, + }, { title: 'Check if certificate already exists and not expiring soon', // Skips the check if force flag is set @@ -53,9 +91,6 @@ export default function obtainZeroSSLCertificateTaskFactory( lodash.merge(ctx, data); - // Ensure we have config dir created - fs.mkdirSync(ctx.sslConfigDir, { recursive: true }); - switch (error) { case undefined: // eslint-disable-next-line no-param-reassign @@ -141,13 +176,8 @@ export default function obtainZeroSSLCertificateTaskFactory( ctx.externalIp, ctx.apiKey, ); - - config.set('platform.gateway.ssl.enabled', true); - config.set('platform.gateway.ssl.provider', 'zerossl'); - config.set('platform.gateway.ssl.providerConfigs.zerossl.id', ctx.certificate.id); - - // Save config file - configFileRepository.write(configFile); + // Publish the replacement ID only after its key, CSR, and bundle are ready. + ctx.isCertificateCreated = true; }, }, { @@ -269,33 +299,121 @@ and all Dash service ports listed above.`); }, }, { - title: 'Save certificate private key file', - enabled: (ctx) => !ctx.isPrivateKeyFilePresent, + title: 'Save certificate files and configuration', + enabled: (ctx) => ctx.isCertificateCreated + || !ctx.isPrivateKeyFilePresent + || !ctx.isCsrFilePresent + || !ctx.isBundleFilePresent, task: async (ctx, task) => { - fs.writeFileSync(ctx.privateKeyFilePath, ctx.privateKeyFile, 'utf8'); + const artifacts = [ + { + shouldSave: !ctx.isPrivateKeyFilePresent, + filePath: ctx.privateKeyFilePath, + content: ctx.privateKeyFile, + }, + { + shouldSave: !ctx.isCsrFilePresent, + filePath: ctx.csrFilePath, + content: ctx.csr, + }, + { + shouldSave: !ctx.isBundleFilePresent, + filePath: ctx.bundleFilePath, + content: ctx.certificateFile, + }, + ].filter(({ shouldSave }) => shouldSave); - // eslint-disable-next-line no-param-reassign - task.output = ctx.privateKeyFilePath; - }, - }, - { - title: 'Save certificate request file', - enabled: (ctx) => !ctx.isCsrFilePresent, - task: async (ctx, task) => { - fs.writeFileSync(ctx.csrFilePath, ctx.csr, 'utf8'); + const stagingDir = fs.mkdtempSync(path.join(ctx.sslConfigDir, '.zerossl-')); + const configPaths = [ + 'platform.gateway.ssl.enabled', + 'platform.gateway.ssl.provider', + 'platform.gateway.ssl.providerConfigs.zerossl.id', + ]; + let previousConfig; + let stagedArtifacts; + let artifactInstallStarted = false; + let configWasUpdated = false; - // eslint-disable-next-line no-param-reassign - task.output = ctx.csrFilePath; - }, - }, - { - title: 'Save certificate file', - skip: (ctx) => ctx.isBundleFilePresent, - task: async (ctx, task) => { - fs.writeFileSync(ctx.bundleFilePath, ctx.certificateFile, 'utf8'); + try { + stagedArtifacts = artifacts.map(({ filePath, content }) => { + const stagedFilePath = path.join(stagingDir, path.basename(filePath)); + const wasPresent = fs.existsSync(filePath); + const previousContent = wasPresent + ? fs.readFileSync(filePath, 'utf8') + : undefined; + + fs.writeFileSync(stagedFilePath, content, 'utf8'); + + return { + stagedFilePath, + filePath, + wasPresent, + previousContent, + }; + }); + + artifactInstallStarted = true; + stagedArtifacts.forEach(({ stagedFilePath, filePath }) => { + fs.renameSync(stagedFilePath, filePath); + }); + + if (ctx.isCertificateCreated) { + previousConfig = configPaths.map((configPath) => [ + configPath, + config.get(configPath), + ]); + configWasUpdated = true; + + config.set('platform.gateway.ssl.enabled', true); + config.set('platform.gateway.ssl.provider', 'zerossl'); + config.set( + 'platform.gateway.ssl.providerConfigs.zerossl.id', + ctx.certificate.id, + ); + configFileRepository.write(configFile); + } + } catch (error) { + let rollbackError; + + if (artifactInstallStarted) { + stagedArtifacts.forEach(({ filePath, wasPresent, previousContent }) => { + try { + if (wasPresent) { + fs.writeFileSync(filePath, previousContent, 'utf8'); + } else { + fs.rmSync(filePath, { force: true }); + } + } catch (artifactRollbackError) { + rollbackError = rollbackError || artifactRollbackError; + } + }); + } + + if (configWasUpdated) { + previousConfig.forEach(([configPath, value]) => config.set(configPath, value)); + + try { + configFileRepository.write(configFile); + } catch (configRollbackError) { + rollbackError = rollbackError || configRollbackError; + } + } + + if (rollbackError) { + error.rollbackError = rollbackError; + } + + throw error; + } finally { + try { + fs.rmSync(stagingDir, { recursive: true, force: true }); + } catch { + // A leftover staging directory is safe and must not mask the transaction result. + } + } // eslint-disable-next-line no-param-reassign - task.output = ctx.bundleFilePath; + task.output = artifacts.map(({ filePath }) => filePath).join(', '); }, }, { diff --git a/packages/dashmate/test/unit/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.spec.js index 12bbb460e05..429349cd687 100644 --- a/packages/dashmate/test/unit/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.spec.js @@ -1,16 +1,40 @@ +import fs from 'fs'; +import path from 'path'; import obtainZeroSSLCertificateTaskFactory from '../../../../src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js'; describe('obtainZeroSSLCertificateTaskFactory', () => { let config; let verificationServer; let validateZeroSslCertificate; + let generateCsr; + let generateKeyPair; + let createZeroSSLCertificate; + let verifyDomain; + let downloadCertificate; + let getCertificate; + let listCertificates; + let saveCertificateTask; + let homeDir; + let configFileRepository; + let configFile; let obtainZeroSSLCertificateTask; + let sslConfigDir; beforeEach(function beforeEach() { config = { get: this.sinon.stub(), + set: this.sinon.stub(), getName: this.sinon.stub().returns('local'), }; + config.get + .withArgs('platform.gateway.ssl.providerConfigs.zerossl.apiKey') + .returns('test-api-key'); + config.get.withArgs('externalIp').returns('1.2.3.4'); + + sslConfigDir = path.join('/home/dir', 'local', 'platform', 'gateway', 'ssl'); + homeDir = { + joinPath: this.sinon.stub().callsFake((...parts) => path.join('/home/dir', ...parts)), + }; verificationServer = { setup: this.sinon.stub().resolves(), @@ -20,25 +44,52 @@ describe('obtainZeroSSLCertificateTaskFactory', () => { waitForServerIsResponding: this.sinon.stub().resolves(true), }; - // The first pipeline task calls validateZeroSslCertificate. Rejecting it - // simulates a mid-pipeline failure (e.g. the ZeroSSL API going down), which + // The first non-init pipeline task calls validateZeroSslCertificate. Rejecting + // it simulates a mid-pipeline failure (e.g. the ZeroSSL API going down), which // is the path that previously left the verification server bound to port 80. validateZeroSslCertificate = this.sinon.stub().rejects(new Error('ZeroSSL API unavailable')); + generateCsr = this.sinon.stub().resolves('CSR_PEM'); + generateKeyPair = this.sinon.stub().resolves({ + privateKey: 'PRIVATE_KEY_PEM', + publicKey: 'PUBLIC_KEY_PEM', + }); + createZeroSSLCertificate = this.sinon.stub().resolves({ + id: 'cert-id', + status: 'issued', + common_name: '1.2.3.4', + expires: '2099-01-01 00:00:00', + validation: { other_methods: { '1.2.3.4': {} } }, + }); + verifyDomain = this.sinon.stub().resolves(); + downloadCertificate = this.sinon.stub().resolves('CERT_BUNDLE_PEM'); + getCertificate = this.sinon.stub(); + listCertificates = this.sinon.stub(); + saveCertificateTask = this.sinon.stub(); + configFileRepository = { write: this.sinon.stub() }; + configFile = {}; + + // Prevent the init task from touching the real filesystem. + this.sinon.stub(fs, 'mkdirSync').returns(undefined); + this.sinon.stub(fs, 'mkdtempSync').returns('/home/dir/local/platform/gateway/ssl/.zerossl-test'); + this.sinon.stub(fs, 'writeFileSync').returns(undefined); + this.sinon.stub(fs, 'renameSync').returns(undefined); + this.sinon.stub(fs, 'rmSync').returns(undefined); + obtainZeroSSLCertificateTask = obtainZeroSSLCertificateTaskFactory( - this.sinon.stub(), // generateCsr - this.sinon.stub(), // generateKeyPair - this.sinon.stub(), // createZeroSSLCertificate - this.sinon.stub(), // verifyDomain - this.sinon.stub(), // downloadCertificate - this.sinon.stub(), // getCertificate - this.sinon.stub(), // listCertificates - this.sinon.stub(), // saveCertificateTask + generateCsr, + generateKeyPair, + createZeroSSLCertificate, + verifyDomain, + downloadCertificate, + getCertificate, + listCertificates, + saveCertificateTask, verificationServer, - { joinPath: this.sinon.stub() }, // homeDir + homeDir, validateZeroSslCertificate, - { write: this.sinon.stub() }, // configFileRepository - {}, // configFile + configFileRepository, + configFile, ); }); @@ -61,4 +112,274 @@ describe('obtainZeroSSLCertificateTaskFactory', () => { expect(verificationServer.stop).to.have.been.called(); expect(verificationServer.destroy).to.have.been.called(); }); + + describe('--force mode', () => { + /** + * Abort after create is invoked so we never enter the production + * wait(5000) download-retry loop, while still recording create args. + */ + function rejectAfterCreate() { + const sentinel = new Error('STOP_PIPELINE_AFTER_CERT_REQUEST'); + createZeroSSLCertificate.rejects(sentinel); + return sentinel; + } + + it('should load externalIp/apiKey/paths, skip validation, and regenerate keypair/CSR/certificate', async () => { + const sentinel = rejectAfterCreate(); + const tasks = obtainZeroSSLCertificateTask(config); + + let thrownError; + try { + await tasks.run({ expirationDays: 30, force: true }); + } catch (e) { + thrownError = e; + } + expect(thrownError).to.equal(sentinel); + + // Existing-certificate short-circuit must be skipped under --force. + expect(validateZeroSslCertificate).to.not.have.been.called(); + + // Required context and paths must still be initialized (#3803 / #4249). + expect(homeDir.joinPath).to.have.been.calledWith( + 'local', + 'platform', + 'gateway', + 'ssl', + ); + expect(fs.mkdirSync).to.have.been.calledWith(sslConfigDir, { recursive: true }); + + // Keypair/CSR regenerate because force clears isCsrFilePresent. + expect(generateKeyPair).to.have.been.calledOnce(); + expect(generateCsr).to.have.been.calledOnce(); + // CSR must receive the externalIp loaded from config — previously undefined + // and crashed node-forge with "Attribute value not specified." + expect(generateCsr.firstCall.args[1]).to.equal('1.2.3.4'); + + // New ZeroSSL certificate requested with loaded externalIp and apiKey. + expect(createZeroSSLCertificate).to.have.been.calledOnce(); + expect(createZeroSSLCertificate.firstCall.args).to.deep.equal([ + 'CSR_PEM', + '1.2.3.4', + 'test-api-key', + ]); + }); + + it('should ignore pre-existing presence flags and certificate state under --force', async () => { + const sentinel = rejectAfterCreate(); + const tasks = obtainZeroSSLCertificateTask(config); + + let thrownError; + try { + // Pre-seed flags that would short-circuit generation if force failed + // to clear them (the #4249 undefined-context / stale-presence path). + await tasks.run({ + expirationDays: 30, + force: true, + isCsrFilePresent: true, + isPrivateKeyFilePresent: true, + isBundleFilePresent: true, + certificate: { + id: 'old-cert', + status: 'issued', + common_name: '1.2.3.4', + }, + }); + } catch (e) { + thrownError = e; + } + expect(thrownError).to.equal(sentinel); + + expect(validateZeroSslCertificate).to.not.have.been.called(); + // If isCsrFilePresent stayed true, these would not run. + expect(generateKeyPair).to.have.been.calledOnce(); + expect(generateCsr).to.have.been.calledOnce(); + // If certificate stayed truthy, create would be skipped. + expect(createZeroSSLCertificate).to.have.been.calledOnce(); + }); + + it('should keep the previous certificate configuration when replacement fails', async function it() { + const configValues = { + 'platform.gateway.ssl.enabled': true, + 'platform.gateway.ssl.provider': 'zerossl', + 'platform.gateway.ssl.providerConfigs.zerossl.apiKey': 'test-api-key', + 'platform.gateway.ssl.providerConfigs.zerossl.id': 'old-cert-id', + externalIp: '1.2.3.4', + }; + config.get.callsFake((configPath) => configValues[configPath]); + config.set.callsFake((configPath, value) => { + configValues[configPath] = value; + }); + + createZeroSSLCertificate.resolves({ + id: 'replacement-cert-id', + status: 'pending_validation', + validation: { + other_methods: { + '1.2.3.4': { + file_validation_url_http: 'http://1.2.3.4/.well-known/pki-validation/file', + file_validation_content: ['validation-content'], + }, + }, + }, + }); + const replacementFailure = new Error('verification setup failed'); + verificationServer.setup.rejects(replacementFailure); + + const forceTasks = obtainZeroSSLCertificateTask(config); + let thrownError; + try { + await forceTasks.run({ expirationDays: 30, force: true }); + } catch (e) { + thrownError = e; + } + + expect(thrownError).to.equal(replacementFailure); + expect(configValues['platform.gateway.ssl.providerConfigs.zerossl.id']) + .to.equal('old-cert-id'); + expect(configFileRepository.write).to.not.have.been.called(); + + verificationServer.setup.resolves(); + validateZeroSslCertificate.resolves({ + data: { + certificate: { + id: configValues['platform.gateway.ssl.providerConfigs.zerossl.id'], + status: 'issued', + expires: '2099-01-01 00:00:00', + }, + isCsrFilePresent: true, + isPrivateKeyFilePresent: true, + isBundleFilePresent: true, + }, + }); + + const retryTasks = obtainZeroSSLCertificateTask(config); + await retryTasks.run({ expirationDays: 30 }); + + expect(validateZeroSslCertificate).to.have.been.calledOnce(); + expect(generateKeyPair).to.have.been.calledOnce(); + expect(generateCsr).to.have.been.calledOnce(); + expect(createZeroSSLCertificate).to.have.been.calledOnce(); + expect(configValues['platform.gateway.ssl.providerConfigs.zerossl.id']) + .to.equal('old-cert-id'); + }); + + it('should restore previous artifacts and configuration when persistence fails', async function it() { + const configValues = { + 'platform.gateway.ssl.enabled': true, + 'platform.gateway.ssl.provider': 'zerossl', + 'platform.gateway.ssl.providerConfigs.zerossl.apiKey': 'test-api-key', + 'platform.gateway.ssl.providerConfigs.zerossl.id': 'old-cert-id', + externalIp: '1.2.3.4', + }; + config.get.callsFake((configPath) => configValues[configPath]); + config.set.callsFake((configPath, value) => { + configValues[configPath] = value; + }); + + const previousArtifacts = { + [path.join(sslConfigDir, 'private.key')]: 'OLD_PRIVATE_KEY_PEM', + [path.join(sslConfigDir, 'csr.pem')]: 'OLD_CSR_PEM', + [path.join(sslConfigDir, 'bundle.crt')]: 'OLD_CERT_BUNDLE_PEM', + }; + this.sinon.stub(fs, 'existsSync').callsFake( + (filePath) => Object.hasOwn(previousArtifacts, filePath), + ); + this.sinon.stub(fs, 'readFileSync').callsFake((filePath) => previousArtifacts[filePath]); + + const persistenceFailure = new Error('config persistence failed'); + configFileRepository.write.onFirstCall().throws(persistenceFailure); + + const clock = this.sinon.useFakeTimers(); + const tasks = obtainZeroSSLCertificateTask(config); + let thrownError; + const runPromise = tasks.run({ expirationDays: 30, force: true }).catch((e) => { + thrownError = e; + }); + await clock.tickAsync(5000); + await runPromise; + + expect(thrownError).to.equal(persistenceFailure); + expect(configValues['platform.gateway.ssl.providerConfigs.zerossl.id']) + .to.equal('old-cert-id'); + expect(configFileRepository.write).to.have.been.calledTwice(); + Object.entries(previousArtifacts).forEach(([filePath, content]) => { + expect(fs.writeFileSync).to.have.been.calledWith(filePath, content, 'utf8'); + }); + }); + + it('should not persist replacement configuration when artifact installation fails', async function it() { + const configValues = { + 'platform.gateway.ssl.enabled': true, + 'platform.gateway.ssl.provider': 'zerossl', + 'platform.gateway.ssl.providerConfigs.zerossl.apiKey': 'test-api-key', + 'platform.gateway.ssl.providerConfigs.zerossl.id': 'old-cert-id', + externalIp: '1.2.3.4', + }; + config.get.callsFake((configPath) => configValues[configPath]); + config.set.callsFake((configPath, value) => { + configValues[configPath] = value; + }); + + const artifactFailure = new Error('artifact installation failed'); + fs.renameSync.onSecondCall().throws(artifactFailure); + + const clock = this.sinon.useFakeTimers(); + const tasks = obtainZeroSSLCertificateTask(config); + let thrownError; + const runPromise = tasks.run({ expirationDays: 30, force: true }).catch((e) => { + thrownError = e; + }); + await clock.tickAsync(5000); + await runPromise; + + expect(thrownError).to.equal(artifactFailure); + expect(configValues['platform.gateway.ssl.providerConfigs.zerossl.id']) + .to.equal('old-cert-id'); + expect(configFileRepository.write).to.not.have.been.called(); + }); + + it('should fail with the missing-API-key error before any network or generation work', async () => { + config.get + .withArgs('platform.gateway.ssl.providerConfigs.zerossl.apiKey') + .returns(undefined); + + const tasks = obtainZeroSSLCertificateTask(config); + + let thrownError; + try { + await tasks.run({ expirationDays: 30, force: true }); + } catch (e) { + thrownError = e; + } + + expect(thrownError).to.be.an('error'); + expect(thrownError.message).to.match(/ZeroSSL API key is not set/); + + expect(validateZeroSslCertificate).to.not.have.been.called(); + expect(generateKeyPair).to.not.have.been.called(); + expect(generateCsr).to.not.have.been.called(); + expect(createZeroSSLCertificate).to.not.have.been.called(); + }); + + it('should fail with the missing-external-IP error before any network or generation work', async () => { + config.get.withArgs('externalIp').returns(undefined); + + const tasks = obtainZeroSSLCertificateTask(config); + + let thrownError; + try { + await tasks.run({ expirationDays: 30, force: true }); + } catch (e) { + thrownError = e; + } + + expect(thrownError).to.be.an('error'); + expect(thrownError.message).to.match(/External IP is not set/); + + expect(validateZeroSslCertificate).to.not.have.been.called(); + expect(generateKeyPair).to.not.have.been.called(); + expect(generateCsr).to.not.have.been.called(); + expect(createZeroSSLCertificate).to.not.have.been.called(); + }); + }); });