From 0657417cf012e28cb9f2ecfce83fd7c8a60e1e93 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 19 Aug 2026 20:23:30 +0700 Subject: [PATCH 1/9] fix(dashmate): doctor never reports SSL certificate problems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dashmate doctor` could not report a certificate problem for any SSL provider. Five defects stacked on top of each other: 1. `collectSamplesTaskFactory` did not await `validateZeroSslCertificate`. `error` and `data` destructured off a Promise and were both undefined, `obfuscateObjectRecursive(undefined, …)` is a silent no-op, and the analyser gates on `ssl?.error` — so a ZeroSSL certificate never produced a problem, no matter how long it had been expired. 2. The three metrics samples did not await `fetchTextOrError`, storing an unresolved Promise. Diagnostic archives serialised it as `{}`, so reports from nodes with metrics enabled carried no metrics at all. 3. `analyseConfigFactory` used `{block.cyanBright …}`, which is not a chalk style. The message table is one object literal, built eagerly, so this threw `Unknown Chalk style: block` for *every* certificate error of every provider — including a Let's Encrypt renewal failure, where the operator got a one-line crash instead of the diagnosis. 4. Two ZeroSSL messages dereferenced `ssl?.data?.certificate.expires` and `.common_name` without optional chaining. Errors raised before the certificate is fetched (API key unset, external IP unset) threw while the table was being built. 5. `ZEROSSL_ERRORS` and `LETSENCRYPT_ERRORS` share the names `CERTIFICATE_EXPIRES_SOON` and `EXTERNAL_IP_IS_NOT_SET`. In a single literal the later Let's Encrypt entry won, so an expiring ZeroSSL certificate was reported as a Let's Encrypt one and the suggested fix was the wrong provider's command. Messages are now grouped per provider and selected by the configured provider, so neither can shadow the other. Defect 1 has been present since the doctor shipped (f2ded52c63). Tests would have caught this in CI. Both new specs run against the unfixed source first: RED (7 failing, 2 passing) analyseConfigFactory 1) Let's Encrypt certificate that expires soon 2) ZeroSSL certificate that expires soon 3) ZeroSSL API key is not set 4) external IP is not set 5) certificate files are not found -> Error: Unknown Chalk style: block collectSamplesTaskFactory 6) ZeroSSL certificate that expired months ago -> expected undefined to equal 'CERTIFICATE_EXPIRES_SOON' 7) metrics collected as text -> expected Promise{…} to equal 'metrics_sample 1' GREEN (9 passing) Defect 5 is masked in that run by defect 3 throwing first; it was observed in isolation once the chalk style was corrected, as `expected 'Let's Encrypt certificate expires at…' to include 'ZeroSSL certificate expires at'`. Full dashmate unit suite: 299 passing. Co-Authored-By: Claude Opus 5 --- .../doctor/analyse/analyseConfigFactory.js | 36 +++- .../tasks/doctor/collectSamplesTaskFactory.js | 11 +- .../analyse/analyseConfigFactory.spec.js | 104 ++++++++++ .../doctor/collectSamplesTaskFactory.spec.js | 185 ++++++++++++++++++ 4 files changed, 322 insertions(+), 14 deletions(-) create mode 100644 packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js create mode 100644 packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js diff --git a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js index 80007be0129..57538c3cece 100644 --- a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js @@ -60,10 +60,7 @@ export default function analyseConfigFactory() { } break; default: { - const { - description, - solution, - } = { + const fileProblems = { // File provider error 'not-valid': { description: 'SSL certificate files are not valid', @@ -82,15 +79,17 @@ Private key file path: {bold.cyanBright ${ssl?.data?.privateFilePath}} Or use ZeroSSL https://docs.dash.org/en/stable/masternodes/dashmate.html#ssl-certificate`, }, - // ZeroSSL validation errors + }; + + const zeroSslProblems = { [ZEROSSL_ERRORS.API_KEY_IS_NOT_SET]: { description: 'ZeroSSL API key is not set.', solution: chalk`Please obtain your API key from {underline.cyanBright https://app.zerossl.com/developer} -And then update your configuration with {block.cyanBright dashmate config set platform.gateway.ssl.providerConfigs.zerossl.apiKey [KEY]}`, +And then update your configuration with {bold.cyanBright dashmate config set platform.gateway.ssl.providerConfigs.zerossl.apiKey [KEY]}`, }, [ZEROSSL_ERRORS.EXTERNAL_IP_IS_NOT_SET]: { description: 'External IP is not set.', - solution: chalk`Please update your configuration to include your external IP using {block.cyanBright dashmate config set externalIp [IP]}`, + solution: chalk`Please update your configuration to include your external IP using {bold.cyanBright dashmate config set externalIp [IP]}`, }, [ZEROSSL_ERRORS.CERTIFICATE_ID_IS_NOT_SET]: { description: 'ZeroSSL certificate is not configured', @@ -102,7 +101,7 @@ And then update your configuration with {block.cyanBright dashmate config set pl and revoke the previous certificate in the ZeroSSL dashboard`, }, [ZEROSSL_ERRORS.EXTERNAL_IP_MISMATCH]: { - description: chalk`ZeroSSL IP ${ssl?.data?.certificate.common_name} does not match external IP ${ssl?.data?.externalIp}.`, + description: chalk`ZeroSSL IP ${ssl?.data?.certificate?.common_name} does not match external IP ${ssl?.data?.externalIp}.`, solution: chalk`Please regenerate the certificate using {bold.cyanBright dashmate ssl obtain --force} and revoke the previous certificate in the ZeroSSL dashboard`, }, @@ -113,7 +112,7 @@ This makes auto-renewal impossible.`, and revoke the previous certificate in the ZeroSSL dashboard`, }, [ZEROSSL_ERRORS.CERTIFICATE_EXPIRES_SOON]: { - description: chalk`ZeroSSL certificate expires at ${ssl?.data?.certificate.expires}.`, + description: chalk`ZeroSSL certificate expires at ${ssl?.data?.certificate?.expires}.`, solution: chalk`Please run {bold.cyanBright dashmate ssl obtain} to get a new one`, }, [ZEROSSL_ERRORS.CERTIFICATE_IS_NOT_VALIDATED]: { @@ -128,7 +127,9 @@ and revoke the previous certificate in the ZeroSSL dashboard`, description: ssl?.data?.error?.message, solution: chalk`Please contact ZeroSSL support if needed.`, }, - // Let's Encrypt validation errors + }; + + const letsEncryptProblems = { [LETSENCRYPT_ERRORS.EMAIL_IS_NOT_SET]: { description: 'Let\'s Encrypt email is not set.', solution: chalk`Please update your configuration with {bold.cyanBright dashmate config set platform.gateway.ssl.providerConfigs.letsencrypt.email [EMAIL]}`, @@ -157,6 +158,21 @@ and revoke the previous certificate in the ZeroSSL dashboard`, description: chalk`Let's Encrypt certificate is not valid.`, solution: chalk`Please run {bold.cyanBright dashmate ssl obtain --provider=letsencrypt --force} to get a new one.`, }, + }; + + // Both providers report some errors under the same name, so only the + // configured provider's messages are considered. Otherwise one provider's + // message would describe a problem found by the other one. + const providerProblems = config.get('platform.gateway.ssl.provider') === 'letsencrypt' + ? letsEncryptProblems + : zeroSslProblems; + + const { + description, + solution, + } = { + ...fileProblems, + ...providerProblems, }[ssl.error] ?? {}; if (description) { diff --git a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js index 46d8f4fd7ad..b1dae0481ef 100644 --- a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js @@ -105,7 +105,10 @@ export default function collectSamplesTaskFactory( const { error, data, - } = validateZeroSslCertificate(config, Certificate.EXPIRATION_LIMIT_DAYS); + } = await validateZeroSslCertificate( + config, + Certificate.EXPIRATION_LIMIT_DAYS, + ); obfuscateObjectRecursive(data, (_field, value) => (typeof value === 'string' ? value.replaceAll( process.env.USER, @@ -311,7 +314,7 @@ export default function collectSamplesTaskFactory( const url = `http://${config.get('platform.drive.tenderdash.rpc.host')}:${config.get('platform.drive.tenderdash.rpc.port')}/metrics`; - const result = fetchTextOrError(url); + const result = await fetchTextOrError(url); ctx.samples.setServiceInfo('drive_tenderdash', 'metrics', result); } @@ -322,7 +325,7 @@ export default function collectSamplesTaskFactory( const url = `http://${config.get('platform.drive.abci.metrics.host')}:${config.get('platform.drive.abci.metrics.port')}/metrics`; - const result = fetchTextOrError(url); + const result = await fetchTextOrError(url); ctx.samples.setServiceInfo('drive_abci', 'metrics', result); } @@ -333,7 +336,7 @@ export default function collectSamplesTaskFactory( const url = `http://${config.get('platform.gateway.metrics.host')}:${config.get('platform.gateway.metrics.port')}/metrics`; - const result = fetchTextOrError(url); + const result = await fetchTextOrError(url); ctx.samples.setServiceInfo('gateway', 'metrics', result); } diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js new file mode 100644 index 00000000000..4a24a84d23e --- /dev/null +++ b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js @@ -0,0 +1,104 @@ +import getBaseConfigFactory from '../../../../configs/defaults/getBaseConfigFactory.js'; +import analyseConfigFactory from '../../../../src/doctor/analyse/analyseConfigFactory.js'; +import { SEVERITY } from '../../../../src/doctor/Prescription.js'; +import Samples from '../../../../src/doctor/Samples.js'; +import { ERRORS as LETSENCRYPT_ERRORS } from '../../../../src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js'; +import { ERRORS as ZEROSSL_ERRORS } from '../../../../src/ssl/zerossl/validateZeroSslCertificateFactory.js'; + +describe('analyseConfigFactory', () => { + let analyseConfig; + let config; + let samples; + + /** + * @param {Object} ssl + * @param {string} [provider=zerossl] + * @return {Problem[]} + */ + function analyseSslSample(ssl, provider = 'zerossl') { + config.set('platform.gateway.ssl.provider', provider); + + samples.setServiceInfo('gateway', 'ssl', ssl); + + return analyseConfig(samples); + } + + beforeEach(() => { + config = getBaseConfigFactory()(); + + config.set('platform.enable', true); + + samples = new Samples(); + samples.setDashmateConfig(config); + + // Ports are reported healthy so that only certificate problems are analysed + samples.setServiceInfo('core', 'p2pPort', 'OPEN'); + samples.setServiceInfo('gateway', 'httpPort', 'OPEN'); + samples.setServiceInfo('drive_tenderdash', 'p2pPort', 'OPEN'); + + analyseConfig = analyseConfigFactory(); + }); + + it('should report a problem for a Let\'s Encrypt certificate that expires soon', () => { + const problems = analyseSslSample({ + error: LETSENCRYPT_ERRORS.CERTIFICATE_EXPIRES_SOON, + data: { certificate: { expires: '2026-01-01' } }, + }, 'letsencrypt'); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getDescription()).to.include('Let\'s Encrypt certificate expires at'); + expect(problems[0].getSeverity()).to.equal(SEVERITY.HIGH); + }); + + it('should report a problem for a ZeroSSL certificate that expires soon', () => { + const problems = analyseSslSample({ + error: ZEROSSL_ERRORS.CERTIFICATE_EXPIRES_SOON, + data: { certificate: { expires: '2026-01-01' } }, + }); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getDescription()).to.include('ZeroSSL certificate expires at'); + expect(problems[0].getSeverity()).to.equal(SEVERITY.HIGH); + }); + + it('should report a problem when the ZeroSSL API key is not set', () => { + const problems = analyseSslSample({ + error: ZEROSSL_ERRORS.API_KEY_IS_NOT_SET, + data: {}, + }); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getDescription()).to.include('ZeroSSL API key is not set'); + expect(problems[0].getSolution()).to.include('dashmate config set platform.gateway.ssl.providerConfigs.zerossl.apiKey'); + }); + + it('should report a problem when the external IP is not set', () => { + const problems = analyseSslSample({ + error: ZEROSSL_ERRORS.EXTERNAL_IP_IS_NOT_SET, + data: {}, + }); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getDescription()).to.include('External IP is not set'); + expect(problems[0].getSolution()).to.include('dashmate config set externalIp'); + }); + + it('should report a problem when certificate files are not found', () => { + const problems = analyseSslSample({ + error: 'not-exist', + data: { + chainFilePath: '/home/dashmate/ssl/bundle.crt', + privateFilePath: '/home/dashmate/ssl/private.key', + }, + }, 'file'); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getDescription()).to.include('SSL certificate files are not found'); + }); + + it('should not report a problem for a valid certificate', () => { + const problems = analyseSslSample({ data: {} }); + + expect(problems).to.be.empty(); + }); +}); diff --git a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js new file mode 100644 index 00000000000..cccb9554b7e --- /dev/null +++ b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js @@ -0,0 +1,185 @@ +import fs from 'fs'; +import path from 'path'; +import { Listr } from 'listr2'; +import getBaseConfigFactory from '../../../../../configs/defaults/getBaseConfigFactory.js'; +import HomeDir from '../../../../../src/config/HomeDir.js'; +import analyseConfigFactory from '../../../../../src/doctor/analyse/analyseConfigFactory.js'; +import { SEVERITY } from '../../../../../src/doctor/Prescription.js'; +import Samples from '../../../../../src/doctor/Samples.js'; +import collectSamplesTaskFactory from '../../../../../src/listr/tasks/doctor/collectSamplesTaskFactory.js'; +import Certificate from '../../../../../src/ssl/zerossl/Certificate.js'; +import validateZeroSslCertificateFactory, { ERRORS as ZEROSSL_ERRORS } from '../../../../../src/ssl/zerossl/validateZeroSslCertificateFactory.js'; +import providers from '../../../../../src/status/providers.js'; + +const EXTERNAL_IP = '198.51.100.7'; + +/** + * Format a date the way the ZeroSSL API reports certificate dates + * + * @param {Date} date + * @return {string} + */ +function toZeroSslDate(date) { + const pad = (number) => String(number).padStart(2, '0'); + + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + + `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +/** + * @param {number} days + * @return {Date} + */ +function daysFromNow(days) { + const date = new Date(); + + date.setDate(date.getDate() + days); + + return date; +} + +describe('collectSamplesTaskFactory', () => { + let homeDir; + let config; + let getCertificate; + let collectSamplesTask; + let analyseConfig; + let samples; + + /** + * Run the sample collection the same way the doctor command does: as a subtask + * of a parent list, so the parent's renderer applies. + * + * @return {Promise} + */ + async function collectSamples() { + const tasks = new Listr( + [{ task: () => collectSamplesTask(config) }], + { renderer: 'silent' }, + ); + + await tasks.run({ samples }); + } + + beforeEach(function beforeEach() { + homeDir = HomeDir.createTemp(); + + config = getBaseConfigFactory()(); + + config.set('externalIp', EXTERNAL_IP); + config.set('platform.enable', true); + config.set('platform.gateway.ssl.enabled', true); + config.set('platform.gateway.ssl.provider', 'zerossl'); + config.set('platform.gateway.ssl.providerConfigs.zerossl.apiKey', 'a'.repeat(32)); + config.set('platform.gateway.ssl.providerConfigs.zerossl.id', 'b'.repeat(32)); + + // The ZeroSSL validator inspects the certificate files on disk + const sslDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'ssl'); + + fs.mkdirSync(sslDir, { recursive: true }); + fs.writeFileSync(path.join(sslDir, 'csr.pem'), 'csr', 'utf8'); + fs.writeFileSync(path.join(sslDir, 'private.key'), 'private key', 'utf8'); + fs.writeFileSync(path.join(sslDir, 'bundle.crt'), 'bundle', 'utf8'); + + getCertificate = this.sinon.stub(); + + this.sinon.stub(providers.mnowatch, 'checkPortStatus').resolves('OPEN'); + + this.sinon.stub(global, 'fetch').resolves({ + json: async () => ({}), + text: async () => 'metrics_sample 1', + }); + + const dockerCompose = { + throwErrorIfNotInstalled: this.sinon.stub().resolves(), + inspectService: this.sinon.stub().resolves({}), + logs: this.sinon.stub().resolves({ out: '', err: '' }), + }; + + const rpcClient = { + getBestChainLock: this.sinon.stub().resolves({ result: {} }), + quorum: this.sinon.stub().resolves({ result: {} }), + getBlockchainInfo: this.sinon.stub().resolves({ result: {} }), + getPeerInfo: this.sinon.stub().resolves({ result: {} }), + mnsync: this.sinon.stub().resolves({ result: {} }), + masternode: this.sinon.stub().resolves({ result: {} }), + }; + + collectSamplesTask = collectSamplesTaskFactory( + dockerCompose, + this.sinon.stub().returns(rpcClient), + this.sinon.stub().resolves('127.0.0.1'), + this.sinon.stub().returns({ request: this.sinon.stub().resolves({}) }), + this.sinon.stub().resolves([]), + this.sinon.stub().resolves({}), + homeDir, + validateZeroSslCertificateFactory(homeDir, getCertificate), + this.sinon.stub().resolves({}), + ); + + analyseConfig = analyseConfigFactory(); + + samples = new Samples(); + }); + + afterEach(() => { + homeDir.remove(); + }); + + it('should report a problem for a ZeroSSL certificate that expired months ago', async () => { + const expiredAt = daysFromNow(-180); + + getCertificate.resolves(new Certificate({ + id: 'certificate-id', + common_name: EXTERNAL_IP, + status: 'issued', + created: toZeroSslDate(daysFromNow(-270)), + expires: toZeroSslDate(expiredAt), + })); + + await collectSamples(); + + expect(samples.getServiceInfo('gateway', 'ssl').error) + .to.equal(ZEROSSL_ERRORS.CERTIFICATE_EXPIRES_SOON); + + const problems = analyseConfig(samples); + + const sslProblem = problems + .find((problem) => problem.getDescription().includes('ZeroSSL certificate expires at')); + + expect(sslProblem).to.exist(); + expect(sslProblem.getSeverity()).to.equal(SEVERITY.HIGH); + }); + + it('should not report a problem for a valid ZeroSSL certificate', async () => { + getCertificate.resolves(new Certificate({ + id: 'certificate-id', + common_name: EXTERNAL_IP, + status: 'issued', + created: toZeroSslDate(daysFromNow(-1)), + expires: toZeroSslDate(daysFromNow(89)), + })); + + await collectSamples(); + + expect(samples.getServiceInfo('gateway', 'ssl').error).to.be.undefined(); + + expect(analyseConfig(samples)).to.be.empty(); + }); + + it('should collect metrics as text rather than an unresolved promise', async () => { + config.set('platform.gateway.metrics.enabled', true); + + getCertificate.resolves(new Certificate({ + id: 'certificate-id', + common_name: EXTERNAL_IP, + status: 'issued', + created: toZeroSslDate(daysFromNow(-1)), + expires: toZeroSslDate(daysFromNow(89)), + })); + + await collectSamples(); + + expect(samples.getServiceInfo('gateway', 'metrics')).to.equal('metrics_sample 1'); + }); +}); From 27c902e1911c5f420d4784123a4dbc10307d0fda Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 19 Aug 2026 22:32:53 +0700 Subject: [PATCH 2/9] feat(dashmate): detect certificate problems the gateway is actually serving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every certificate check dashmate had read a file or asked the provider's API. Nothing ever opened a connection to see what the gateway presents, so a certificate that was renewed on disk but never reached Envoy — a missed reload, an un-copied bundle — looked healthy to all of them. A live scan of mainnet found 88 of 353 evonodes serving an expired certificate, unreachable by any standards-compliant client, some for over a year. Doctor now connects to the gateway and reports what it serves: - expired, with the two causes distinguished. Serving an expired certificate while a newer one sits on disk means renewal never reached the gateway, and the fix is a restart; serving an expired certificate that matches disk means renewal itself is failing, and the fix is in the helper's logs. Both were one indistinguishable message before. - renewed on disk but not picked up, while the served one is still valid. This is the only warning that arrives before the node goes dark. - not trusted by standard clients, reported separately from expiry because a connection surfaces only its first verification failure, so an expired and untrusted certificate would otherwise hide the second fault. - issued for the wrong address, judged against the external IP rather than the address dialled. This is evaluated first and stops the comparisons below it: a certificate that does not name this node means the connection did not reach this node's gateway, so its contents say nothing about this node. - the gateway not answering TLS at all. Inbound port 80, which both providers validate over, is collected and reported only alongside a certificate problem. The port is bound for the seconds a validation takes, so an external check finds it closed on healthy nodes too: in the mainnet scan 52 nodes with valid, actively renewing certificates looked identical to 4 expired ones. Reported on its own it would fire thirteen times more often than it is right; reported as a possible cause of a renewal that is demonstrably failing, it is the first thing to check. Validity is judged against the time the samples were taken rather than the time they are analysed, because a report is commonly opened days after collection and a Let's Encrypt certificate for an IP address lives about six days. The served certificate is flattened to plain values before it is stored. A verified chain ends at a self-signed root whose issuer points back at itself, and that cycle makes both JSON serialisation and the sample obfuscation pass throw — on healthy nodes only, since a chain that fails verification terminates early. Tests cover the probe against real TLS servers, including a peer that accepts the connection and never completes the handshake and one that trickles bytes to keep an inactivity timer alive, since the socket timeout option is not a deadline and does not close the connection. Full dashmate unit suite: 320 passing. Co-Authored-By: Claude Opus 5 --- packages/dashmate/src/createDIContainer.js | 2 + .../analyseGatewayCertificateFactory.js | 142 ++++++++++++ .../src/doctor/analyseSamplesFactory.js | 4 + .../tasks/doctor/collectSamplesTaskFactory.js | 62 +++++ .../src/ssl/probeServedCertificate.js | 155 +++++++++++++ .../dashmate/src/ssl/readCertificateBundle.js | 76 +++++++ .../analyseGatewayCertificateFactory.spec.js | 167 ++++++++++++++ .../doctor/collectSamplesTaskFactory.spec.js | 68 ++++++ .../unit/ssl/probeServedCertificate.spec.js | 214 ++++++++++++++++++ 9 files changed, 890 insertions(+) create mode 100644 packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js create mode 100644 packages/dashmate/src/ssl/probeServedCertificate.js create mode 100644 packages/dashmate/src/ssl/readCertificateBundle.js create mode 100644 packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js create mode 100644 packages/dashmate/test/unit/ssl/probeServedCertificate.spec.js diff --git a/packages/dashmate/src/createDIContainer.js b/packages/dashmate/src/createDIContainer.js index 9b8b1c74e96..97a4ff67671 100644 --- a/packages/dashmate/src/createDIContainer.js +++ b/packages/dashmate/src/createDIContainer.js @@ -18,6 +18,7 @@ import createConfigFileFactory from './config/configFile/createConfigFileFactory import migrateConfigFileFactory from './config/configFile/migrateConfigFileFactory.js'; import DefaultConfigs from './config/DefaultConfigs.js'; import analyseConfigFactory from './doctor/analyse/analyseConfigFactory.js'; +import analyseGatewayCertificateFactory from './doctor/analyse/analyseGatewayCertificateFactory.js'; import analyseCoreFactory from './doctor/analyse/analyseCoreFactory.js'; import analysePlatformFactory from './doctor/analyse/analysePlatformFactory.js'; import analyseServiceContainersFactory from './doctor/analyse/analyseServiceContainersFactory.js'; @@ -365,6 +366,7 @@ export default async function createDIContainer(options = {}) { analyseSystemResources: asFunction(analyseSystemResourcesFactory).singleton(), analyseServiceContainers: asFunction(analyseServiceContainersFactory).singleton(), analyseConfig: asFunction(analyseConfigFactory).singleton(), + analyseGatewayCertificate: asFunction(analyseGatewayCertificateFactory).singleton(), analyseCore: asFunction(analyseCoreFactory).singleton(), analysePlatform: asFunction(analysePlatformFactory).singleton(), unarchiveSamples: asFunction(unarchiveSamplesFactory).singleton(), diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js new file mode 100644 index 00000000000..c11d7e78c15 --- /dev/null +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -0,0 +1,142 @@ +import chalk from 'chalk'; +import { SEVERITY } from '../Prescription.js'; +import Problem from '../Problem.js'; + +/** + * The manual obtain command writes certificate files but does not signal the gateway, so an + * operator following the advice can succeed and see no change on the wire. Every message about + * a certificate the gateway has not picked up has to say this. + */ +const RESTART_HINT = chalk`Then restart the node so the gateway picks it up: {bold.cyanBright dashmate restart}`; + +export default function analyseGatewayCertificateFactory() { + /** + * Analyse the certificate the gateway actually serves. + * + * @typedef analyseGatewayCertificate + * @param {Samples} samples + * @return {Problem[]} + */ + function analyseGatewayCertificate(samples) { + const config = samples.getDashmateConfig(); + + if (!config?.get('platform.enable')) { + return []; + } + + const served = samples.getServiceInfo('gateway', 'servedCertificate'); + + if (!served) { + return []; + } + + const problems = []; + + // Certificate validity is judged against the moment the samples were taken, not the moment + // they are analysed. A report is often opened days after it was collected, and the node's + // certificate may be renewed every few days, so judging at analysis time would report every + // healthy node as expired. + const now = samples.date?.getTime() ?? Date.now(); + + if (served.state === 'unreachable') { + problems.push(new Problem( + `The gateway did not answer a TLS connection (${served.reason}). Clients may not be able to connect`, + chalk`Please check that the gateway is running and listening: {bold.cyanBright dashmate status platform}`, + SEVERITY.MEDIUM, + )); + + return problems; + } + + if (served.state !== 'served') { + return problems; + } + + const externalIp = config.get('externalIp'); + + // An identity mismatch is evaluated first and stops the comparisons below. It means the + // connection did not reach this node's gateway at all - another config or a proxy answering + // on the same port - and in that case the certificate it returned says nothing about this + // node, so reporting it as a wrong or stale certificate would be misleading. + if (served.identityVerified === false) { + problems.push(new Problem( + `The certificate served on port ${served.port} is not valid for ${externalIp}: ${served.identityError}`, + chalk`Either the certificate is issued for the wrong address, or something other than this +node's gateway is answering on that port. Check that no other node or proxy is using it, then +regenerate the certificate if needed: {bold.cyanBright dashmate ssl obtain --force} +${RESTART_HINT}`, + SEVERITY.HIGH, + )); + + return problems; + } + + const servedExpiresAt = new Date(served.certificate.validTo).getTime(); + const isServedExpired = servedExpiresAt <= now; + const onDiskDiffers = served.matchesOnDisk === false; + + if (isServedExpired && onDiskDiffers) { + problems.push(new Problem( + `The gateway is serving a certificate that expired on ${served.certificate.validTo}, ` + + 'while a newer one is already present on disk', + chalk`The certificate was renewed but never reached the gateway. +{bold.cyanBright dashmate restart}`, + SEVERITY.HIGH, + )); + } else if (isServedExpired) { + problems.push(new Problem( + `The gateway is serving a certificate that expired on ${served.certificate.validTo}. ` + + 'Clients cannot connect to this node', + chalk`Renewal has not succeeded. Check the renewal logs: +{bold.cyanBright dashmate logs dashmate_helper} +Then obtain a new certificate: {bold.cyanBright dashmate ssl obtain} +${RESTART_HINT}`, + SEVERITY.HIGH, + )); + } else if (onDiskDiffers) { + // Still serving a valid certificate, but the renewed one has not been picked up, so this + // node goes dark when the served certificate expires. + problems.push(new Problem( + 'The gateway is serving an older certificate than the one on disk. ' + + `It will stop accepting clients on ${served.certificate.validTo}`, + chalk`The certificate was renewed but never reached the gateway. +{bold.cyanBright dashmate restart}`, + SEVERITY.HIGH, + )); + } + + // Reported separately from expiry because the connection surfaces only its first + // verification failure: a certificate that is both expired and untrusted reports only the + // expiry, and the second fault would otherwise stay hidden until the first was fixed. + if (!served.chainVerified && !isServedExpired) { + problems.push(new Problem( + `The certificate served by the gateway is not trusted by standard clients (${served.chainError})`, + chalk`Clients verifying against public certificate authorities will reject this node. +If the certificate chain is incomplete, make sure the bundle contains the issuing +certificates as well as the server certificate. +${RESTART_HINT}`, + SEVERITY.HIGH, + )); + } + + // Both providers validate over inbound port 80. Being closed is only reported alongside a + // certificate problem: the port is bound just for the seconds a validation takes, so an + // external check finds it closed on healthy nodes too and on its own would be noise. + const acmeHttpPort = samples.getServiceInfo('gateway', 'acmeHttpPort'); + + if (problems.length > 0 && acmeHttpPort && acmeHttpPort !== 'OPEN') { + problems.push(new Problem( + 'Inbound port 80 is not reachable, which is how certificates are validated. ' + + 'This may be why renewal is failing', + chalk`Please make sure port 80 on ${externalIp} accepts incoming connections from the +internet. Both certificate providers connect back to it to validate this node's +address before issuing a certificate. If you are behind NAT, forward port 80 as well.`, + SEVERITY.MEDIUM, + )); + } + + return problems; + } + + return analyseGatewayCertificate; +} diff --git a/packages/dashmate/src/doctor/analyseSamplesFactory.js b/packages/dashmate/src/doctor/analyseSamplesFactory.js index f3aae62e8b7..3ce87feaed4 100644 --- a/packages/dashmate/src/doctor/analyseSamplesFactory.js +++ b/packages/dashmate/src/doctor/analyseSamplesFactory.js @@ -5,6 +5,7 @@ import Problem from './Problem.js'; * @param {analyseSystemResources} analyseSystemResources * @param {analyseServiceContainers} analyseServiceContainers * @param {analyseConfig} analyseConfig + * @param {analyseGatewayCertificate} analyseGatewayCertificate * @param {analyseCore} analyseCore * @param {analysePlatform} analysePlatform * @return {analyseSamples} @@ -13,6 +14,7 @@ export default function analyseSamplesFactory( analyseSystemResources, analyseServiceContainers, analyseConfig, + analyseGatewayCertificate, analyseCore, analysePlatform, ) { @@ -41,6 +43,8 @@ export default function analyseSamplesFactory( problems.push(...analyseConfig(samples)); + problems.push(...analyseGatewayCertificate(samples)); + problems.push(...analyseCore(samples)); problems.push(...analysePlatform(samples)); diff --git a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js index b1dae0481ef..3d6ca81d88f 100644 --- a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js @@ -7,6 +7,8 @@ import obfuscateConfig from '../../../config/obfuscateConfig.js'; import { DASHMATE_VERSION } from '../../../constants.js'; import LegoCertificate from '../../../ssl/letsencrypt/LegoCertificate.js'; import Certificate from '../../../ssl/zerossl/Certificate.js'; +import probeServedCertificate, { STATE as PROBE_STATE } from '../../../ssl/probeServedCertificate.js'; +import readCertificateBundle from '../../../ssl/readCertificateBundle.js'; import providers from '../../../status/providers.js'; import hideString from '../../../util/hideString.js'; import obfuscateObjectRecursive from '../../../util/obfuscateObjectRecursive.js'; @@ -190,6 +192,66 @@ export default function collectSamplesTaskFactory( } }, }, + { + // Every other certificate check reads a file or the provider's API, so a + // certificate that was renewed on disk but never reached the gateway looks + // healthy to all of them. This connects to the gateway and records what it + // actually serves. Doctor only ever runs from the CLI - the helper exposes + // status and nothing else - so the listener is reached on its published port. + enabled: () => config.get('platform.enable') + && config.get('platform.gateway.ssl.provider') !== 'self-signed', + title: 'Gateway served certificate', + task: async () => { + const listenerHost = config.get('platform.gateway.listeners.dapiAndDrive.host'); + const port = config.get('platform.gateway.listeners.dapiAndDrive.port'); + + const result = await probeServedCertificate({ + host: listenerHost === '0.0.0.0' ? '127.0.0.1' : listenerHost, + port, + externalIp: config.get('externalIp'), + }); + + result.port = port; + + if (result.state === PROBE_STATE.SERVED) { + // Read beside the probe rather than at analysis time: renewal replaces the + // file and signals the gateway moments apart, and the rest of the sample + // collection takes long enough that the two would routinely be read from + // either side of a renewal and reported as a mismatch. + const onDisk = readCertificateBundle(path.join( + homeDir.joinPath(config.getName(), 'platform', 'gateway', 'ssl'), + 'bundle.crt', + )); + + result.onDisk = onDisk && { + fingerprint256: onDisk.fingerprint256, + validTo: onDisk.validTo.toUTCString(), + }; + + result.matchesOnDisk = onDisk + ? onDisk.fingerprint256 === result.certificate.fingerprint256 + : null; + } + + obfuscateObjectRecursive(result, (_field, value) => (typeof value === 'string' ? value.replaceAll( + process.env.USER, + hideString(process.env.USER), + ) : value)); + + ctx.samples.setServiceInfo('gateway', 'servedCertificate', result); + }, + }, + { + // Both certificate providers validate this node over inbound port 80. + enabled: () => config.get('platform.enable'), + title: 'ACME HTTP validation port', + task: async () => { + const response = await providers.mnowatch.checkPortStatus(80, config.get('externalIp')) + .catch((e) => e.toString()); + + ctx.samples.setServiceInfo('gateway', 'acmeHttpPort', response); + }, + }, { title: 'Core P2P port', task: async () => { diff --git a/packages/dashmate/src/ssl/probeServedCertificate.js b/packages/dashmate/src/ssl/probeServedCertificate.js new file mode 100644 index 00000000000..6678d681b36 --- /dev/null +++ b/packages/dashmate/src/ssl/probeServedCertificate.js @@ -0,0 +1,155 @@ +import tls from 'node:tls'; + +/** + * How long the whole probe may take, matching the timeout used for the port checks. + * + * This is an absolute budget covering the TCP connect and the TLS handshake together. The + * socket's own timeout option cannot serve as one: it is an inactivity timer that resets on + * every byte received and does not close the socket, so a peer trickling data keeps a probe + * alive indefinitely. + */ +export const PROBE_TIMEOUT_MS = 5000; + +export const STATE = { + SERVED: 'served', + UNREACHABLE: 'unreachable', + SKIPPED: 'skipped', +}; + +/** + * Flatten a TLS peer certificate into plain values. + * + * The peer certificate must never be stored as it is: a chain that verifies ends at a + * self-signed root whose issuerCertificate points back at itself, and that cycle makes both + * JSON serialisation and the sample obfuscation pass fail. Its raw and pubkey fields are also + * buffers that serialise into thousands of numbers. + * + * @param {Object} peerCertificate + * @return {Object} + */ +function flattenCertificate(peerCertificate) { + return { + fingerprint256: peerCertificate.fingerprint256, + validFrom: peerCertificate.valid_from, + validTo: peerCertificate.valid_to, + subject: peerCertificate.subject?.CN ?? null, + issuer: peerCertificate.issuer?.CN ?? null, + subjectAltName: peerCertificate.subjectaltname ?? null, + serialNumber: peerCertificate.serialNumber ?? null, + }; +} + +/** + * Connect to the gateway and report the certificate it actually serves. + * + * Every other certificate check reads a file or asks the provider's API. A certificate that was + * renewed on disk but never reached the gateway is indistinguishable from a healthy one to all + * of them, so this opens a real connection and looks at what the gateway presents. + * + * @param {Object} options + * @param {string} options.host - address the gateway listener is reachable on + * @param {number} options.port + * @param {string} options.externalIp - the address clients use, which the certificate must name + * @param {number} [options.timeout] + * @return {Promise} never rejects + */ +export default async function probeServedCertificate({ + host, + port, + externalIp, + timeout = PROBE_TIMEOUT_MS, +}) { + return new Promise((resolve) => { + let settled = false; + let deadline; + + const settle = (result) => { + if (settled) { + return; + } + + settled = true; + + clearTimeout(deadline); + + resolve(result); + }; + + let socket; + + const fail = (reason) => { + socket?.destroy(); + + settle({ + state: STATE.UNREACHABLE, + reason, + }); + }; + + deadline = setTimeout(() => fail('ETIMEDOUT'), timeout); + + try { + socket = tls.connect({ + host, + port, + // A certificate identifying a node by IP address cannot be requested by name: SNI must + // not carry an IP literal, and the gateway selects its filter chain without it. + servername: undefined, + // The handshake has to complete even when the certificate is expired or untrusted, + // otherwise the probe learns nothing in the cases it exists for. Verification still + // runs and its verdict is read from the socket below. Nothing here grants trust: the + // result is reported, never used to authorise a connection. + rejectUnauthorized: false, + // Node would otherwise check the certificate against the address being dialled, which + // is the local address the gateway happens to be reachable on rather than the one + // clients use, and a correct certificate would fail that on every healthy node. The + // check is done separately below, against the address that matters. + checkServerIdentity: () => undefined, + }); + } catch (e) { + fail(e.code ?? 'CONNECT_FAILED'); + + return; + } + + // Stays attached for the socket's lifetime. A connection can fail after the certificate has + // already been read, and settle() ignores anything that arrives once a result is decided. + socket.on('error', (e) => fail(e.code ?? 'CONNECT_FAILED')); + + socket.on('timeout', () => fail('ETIMEDOUT')); + + socket.on('secureConnect', () => { + const peerCertificate = socket.getPeerCertificate(true); + + // An absent peer certificate is reported as an empty object, which would otherwise be + // taken for a served certificate with no fields and compared against the one on disk. + if (!peerCertificate?.fingerprint256) { + fail('NO_PEER_CERTIFICATE'); + + return; + } + + const { authorized, authorizationError } = socket; + + // Identity is checked separately from the chain because the socket reports only one + // error: an expired certificate that also names the wrong address reports just the + // expiry, so a single verdict would hide the second fault until the first was fixed. + // Delegating to Node handles the common-name fallback and address normalisation that a + // hand-rolled comparison against the alternative names gets wrong. + const identityError = externalIp + ? tls.checkServerIdentity(externalIp, peerCertificate) + : undefined; + + socket.destroy(); + + settle({ + state: STATE.SERVED, + certificate: flattenCertificate(peerCertificate), + chainVerified: authorized, + chainError: authorized ? null : (authorizationError?.code ?? String(authorizationError)), + identityVerified: externalIp ? identityError === undefined : null, + identityError: identityError ? identityError.message : null, + }); + }); + }); +} diff --git a/packages/dashmate/src/ssl/readCertificateBundle.js b/packages/dashmate/src/ssl/readCertificateBundle.js new file mode 100644 index 00000000000..649cf7e9a94 --- /dev/null +++ b/packages/dashmate/src/ssl/readCertificateBundle.js @@ -0,0 +1,76 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; + +/** + * Extract IP addresses from the OpenSSL rendering of a subject alternative name extension. + * + * The value is a single string such as "IP Address:1.2.3.4, DNS:example.com", so entries are + * split out rather than substring-matched: searching the raw string for "1.2.3.4" would also + * match inside "11.2.3.44" and inside a DNS entry. + * + * @param {string|undefined} subjectAltName + * @return {string[]} + */ +function parseIpAddresses(subjectAltName) { + if (!subjectAltName) { + return []; + } + + return subjectAltName + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.startsWith('IP Address:')) + .map((entry) => entry.slice('IP Address:'.length)); +} + +/** + * Read the server certificate from a PEM bundle. + * + * The server certificate is expected first, but an operator supplying their own bundle can + * order it the other way round, so the first block is only accepted when it is not a CA. + * Comparing a served certificate against an intermediate would report a permanent mismatch. + * + * The fingerprint is the same uppercase colon-separated SHA-256 that a TLS peer certificate + * reports, so the two can be compared directly. + * + * @param {string} filePath + * @return {{fingerprint256: string, validFrom: Date, validTo: Date, subject: string, + * issuer: string, ipAddresses: string[]}|null} null when the file is missing or unparseable + */ +export default function readCertificateBundle(filePath) { + let pem; + + try { + pem = fs.readFileSync(filePath, 'utf8'); + } catch { + return null; + } + + const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) ?? []; + + for (const block of blocks) { + let certificate; + + try { + certificate = new crypto.X509Certificate(block); + } catch { + // Skip a block we cannot parse rather than failing the whole bundle + continue; + } + + if (certificate.ca) { + continue; + } + + return { + fingerprint256: certificate.fingerprint256, + validFrom: new Date(certificate.validFrom), + validTo: new Date(certificate.validTo), + subject: certificate.subject, + issuer: certificate.issuer, + ipAddresses: parseIpAddresses(certificate.subjectAltName), + }; + } + + return null; +} diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js new file mode 100644 index 00000000000..213b3fba70f --- /dev/null +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -0,0 +1,167 @@ +import getBaseConfigFactory from '../../../../configs/defaults/getBaseConfigFactory.js'; +import analyseGatewayCertificateFactory from '../../../../src/doctor/analyse/analyseGatewayCertificateFactory.js'; +import { SEVERITY } from '../../../../src/doctor/Prescription.js'; +import Samples from '../../../../src/doctor/Samples.js'; + +const EXTERNAL_IP = '198.51.100.7'; + +/** + * @param {number} days - relative to now, negative for an expired certificate + * @return {string} + */ +function validTo(days) { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString(); +} + +describe('analyseGatewayCertificateFactory', () => { + let analyseGatewayCertificate; + let config; + let samples; + + /** + * @param {Object} servedCertificate + * @return {Problem[]} + */ + function analyse(servedCertificate) { + samples.setServiceInfo('gateway', 'servedCertificate', servedCertificate); + + return analyseGatewayCertificate(samples); + } + + /** + * @param {Object} overrides + * @return {Object} + */ + function served(overrides = {}) { + return { + state: 'served', + port: 443, + certificate: { fingerprint256: 'AA:BB', validTo: validTo(30) }, + chainVerified: true, + chainError: null, + identityVerified: true, + identityError: null, + matchesOnDisk: true, + ...overrides, + }; + } + + beforeEach(() => { + config = getBaseConfigFactory()(); + + config.set('platform.enable', true); + config.set('externalIp', EXTERNAL_IP); + + samples = new Samples(); + samples.setDashmateConfig(config); + + analyseGatewayCertificate = analyseGatewayCertificateFactory(); + }); + + it('should report no problem for a healthy certificate', () => { + expect(analyse(served())).to.be.empty(); + }); + + it('should report an expired certificate that clients cannot connect to', () => { + const problems = analyse(served({ + certificate: { fingerprint256: 'AA:BB', validTo: validTo(-158) }, + })); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getDescription()).to.include('expired'); + expect(problems[0].getSeverity()).to.equal(SEVERITY.HIGH); + expect(problems[0].getSolution()).to.include('dashmate_helper'); + }); + + it('should distinguish a certificate that was renewed but never reached the gateway', () => { + const problems = analyse(served({ + certificate: { fingerprint256: 'AA:BB', validTo: validTo(-2) }, + matchesOnDisk: false, + })); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getDescription()).to.include('newer one is already present on disk'); + expect(problems[0].getSolution()).to.include('dashmate restart'); + }); + + it('should warn before the outage when a renewed certificate has not been picked up', () => { + const problems = analyse(served({ matchesOnDisk: false })); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getDescription()).to.include('older certificate than the one on disk'); + expect(problems[0].getSeverity()).to.equal(SEVERITY.HIGH); + }); + + it('should report an untrusted certificate separately from expiry', () => { + const problems = analyse(served({ + chainVerified: false, + chainError: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + })); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getDescription()).to.include('not trusted by standard clients'); + }); + + it('should treat an identity mismatch as not having reached this node and stop there', () => { + // A different node or a proxy answering on the port returns a certificate that says nothing + // about this node, so reporting it as stale or expired would send the operator the wrong way. + const problems = analyse(served({ + identityVerified: false, + identityError: 'Host: 198.51.100.7 is not in the cert\'s altnames', + matchesOnDisk: false, + certificate: { fingerprint256: 'CC:DD', validTo: validTo(-10) }, + })); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getDescription()).to.include('not valid for 198.51.100.7'); + }); + + it('should judge expiry against the time the samples were taken, not the time of analysis', () => { + // Reports are commonly opened days after collection, and a Let's Encrypt certificate for an + // IP address lives about six days, so judging at analysis time reports healthy nodes as dead. + samples.date = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + + const problems = analyse(served({ + certificate: { fingerprint256: 'AA:BB', validTo: validTo(-4) }, + })); + + expect(problems).to.be.empty(); + }); + + it('should report a closed port 80 as a likely cause when a certificate problem exists', () => { + samples.setServiceInfo('gateway', 'acmeHttpPort', 'CLOSED'); + + const problems = analyse(served({ + certificate: { fingerprint256: 'AA:BB', validTo: validTo(-3) }, + })); + + expect(problems).to.have.lengthOf(2); + expect(problems[1].getDescription()).to.include('port 80'); + }); + + it('should not report a closed port 80 on a node whose certificate is healthy', () => { + // The port is only bound for the seconds a validation takes, so an external check finds it + // closed on actively renewing nodes too. Alone it would fire far more often than it is right. + samples.setServiceInfo('gateway', 'acmeHttpPort', 'CLOSED'); + + expect(analyse(served())).to.be.empty(); + }); + + it('should report a gateway that does not answer TLS', () => { + const problems = analyse({ state: 'unreachable', reason: 'ECONNREFUSED' }); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getDescription()).to.include('did not answer'); + expect(problems[0].getSeverity()).to.equal(SEVERITY.MEDIUM); + }); + + it('should report nothing when the probe was skipped', () => { + expect(analyse({ state: 'skipped', reason: 'self-signed' })).to.be.empty(); + }); + + it('should report nothing when platform is disabled', () => { + config.set('platform.enable', false); + + expect(analyse(served({ certificate: { validTo: validTo(-100) } }))).to.be.empty(); + }); +}); diff --git a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js index cccb9554b7e..defbcdf2d0f 100644 --- a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js @@ -1,5 +1,9 @@ +import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; import fs from 'fs'; +import os from 'node:os'; import path from 'path'; +import tls from 'node:tls'; import { Listr } from 'listr2'; import getBaseConfigFactory from '../../../../../configs/defaults/getBaseConfigFactory.js'; import HomeDir from '../../../../../src/config/HomeDir.js'; @@ -167,6 +171,70 @@ describe('collectSamplesTaskFactory', () => { expect(analyseConfig(samples)).to.be.empty(); }); + it('should collect the certificate the gateway actually serves', async () => { + const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const key = privateKey.export({ type: 'pkcs8', format: 'pem' }); + + const certDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashmate-served-')); + const keyPath = path.join(certDir, 'key.pem'); + const certPath = path.join(certDir, 'cert.pem'); + + fs.writeFileSync(keyPath, key); + + execFileSync('openssl', [ + 'req', '-x509', '-new', '-key', keyPath, '-out', certPath, + '-subj', `/CN=${EXTERNAL_IP}`, + '-addext', `subjectAltName=IP:${EXTERNAL_IP}`, + '-addext', 'basicConstraints=CA:FALSE', + '-days', '30', + ], { stdio: 'ignore' }); + + const cert = fs.readFileSync(certPath, 'utf8'); + + const server = tls.createServer({ cert, key }, (socket) => socket.end()); + const liveSockets = []; + + server.on('secureConnection', (socket) => liveSockets.push(socket)); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + // The gateway's own bundle is the certificate the server presents, so disk and wire agree + fs.writeFileSync( + path.join(homeDir.joinPath(config.getName(), 'platform', 'gateway', 'ssl'), 'bundle.crt'), + cert, + 'utf8', + ); + + config.set('platform.gateway.listeners.dapiAndDrive.port', server.address().port); + + getCertificate.resolves(new Certificate({ + id: 'certificate-id', + common_name: EXTERNAL_IP, + status: 'issued', + created: toZeroSslDate(daysFromNow(-1)), + expires: toZeroSslDate(daysFromNow(89)), + })); + + try { + await collectSamples(); + } finally { + liveSockets.forEach((socket) => socket.destroy()); + await new Promise((resolve) => { + server.close(resolve); + }); + fs.rmSync(certDir, { recursive: true, force: true }); + } + + const servedCertificate = samples.getServiceInfo('gateway', 'servedCertificate'); + + expect(servedCertificate.state).to.equal('served'); + expect(servedCertificate.identityVerified).to.be.true(); + expect(servedCertificate.matchesOnDisk).to.be.true(); + expect(samples.getServiceInfo('gateway', 'acmeHttpPort')).to.equal('OPEN'); + }); + it('should collect metrics as text rather than an unresolved promise', async () => { config.set('platform.gateway.metrics.enabled', true); diff --git a/packages/dashmate/test/unit/ssl/probeServedCertificate.spec.js b/packages/dashmate/test/unit/ssl/probeServedCertificate.spec.js new file mode 100644 index 00000000000..8bdcb9ca9b1 --- /dev/null +++ b/packages/dashmate/test/unit/ssl/probeServedCertificate.spec.js @@ -0,0 +1,214 @@ +import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import tls from 'node:tls'; +import probeServedCertificate, { STATE } from '../../../src/ssl/probeServedCertificate.js'; + +const EXTERNAL_IP = '127.0.0.1'; + +/** + * Generate a certificate at test time rather than committing one: a committed certificate + * expires and fails the suite on a date nobody chose. + * + * @param {Object} options + * @return {{cert: string, key: string}} + */ +function createCertificate({ ip = EXTERNAL_IP, days = 30 } = {}) { + const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + + const key = privateKey.export({ type: 'pkcs8', format: 'pem' }); + + // Node cannot issue certificates, so shell out to the openssl that ships with the OS + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashmate-cert-')); + const keyPath = path.join(dir, 'key.pem'); + const certPath = path.join(dir, 'cert.pem'); + + fs.writeFileSync(keyPath, key); + + // notBefore is anchored to notAfter so an already-expired certificate still has a valid + // ordering rather than starting after it ends + const notAfter = new Date(Date.now() + days * 24 * 60 * 60 * 1000); + const notBefore = new Date(notAfter.getTime() - 30 * 24 * 60 * 60 * 1000); + const stamp = (date) => date.toISOString().replace(/[-:T]/g, '').replace(/\.\d+Z$/, 'Z'); + + execFileSync('openssl', [ + 'req', '-x509', '-new', '-key', keyPath, '-out', certPath, + '-subj', `/CN=${ip}`, + '-addext', `subjectAltName=IP:${ip}`, + '-addext', 'basicConstraints=CA:FALSE', + '-not_before', stamp(notBefore), + '-not_after', stamp(notAfter), + ], { stdio: 'ignore' }); + + const cert = fs.readFileSync(certPath, 'utf8'); + + fs.rmSync(dir, { recursive: true, force: true }); + + return { cert, key: key.toString() }; +} + +describe('probeServedCertificate', () => { + const servers = []; + const sockets = []; + + /** + * Track every accepted connection so a server can be closed without waiting on one that the + * test deliberately left open. + * + * @param {Server} server + * @return {Server} + */ + function track(server) { + server.on('connection', (socket) => sockets.push(socket)); + server.on('secureConnection', (socket) => sockets.push(socket)); + + servers.push(server); + + return server; + } + + /** + * @param {Object} tlsOptions + * @return {Promise} listening port + */ + async function listenTls(tlsOptions) { + const server = track(tls.createServer(tlsOptions, (socket) => socket.end())); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + return server.address().port; + } + + afterEach(async () => { + sockets.splice(0).forEach((socket) => socket.destroy()); + + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => { + server.close(resolve); + }))); + }); + + it('should report the certificate the server actually serves', async () => { + const { cert, key } = createCertificate({ days: 30 }); + const port = await listenTls({ cert, key }); + + const result = await probeServedCertificate({ host: '127.0.0.1', port, externalIp: EXTERNAL_IP }); + + expect(result.state).to.equal(STATE.SERVED); + expect(result.certificate.fingerprint256).to.match(/^[0-9A-F]{2}(:[0-9A-F]{2})+$/); + expect(new Date(result.certificate.validTo).getTime()).to.be.greaterThan(Date.now()); + }); + + it('should complete the handshake and report an expired certificate', async () => { + const { cert, key } = createCertificate({ days: -5 }); + const port = await listenTls({ cert, key }); + + const result = await probeServedCertificate({ host: '127.0.0.1', port, externalIp: EXTERNAL_IP }); + + expect(result.state).to.equal(STATE.SERVED); + expect(new Date(result.certificate.validTo).getTime()).to.be.lessThan(Date.now()); + }); + + it('should not fail identity for a certificate naming the external IP rather than the probed address', async () => { + // The gateway is reached on loopback but its certificate names the node's public address. + // Judging identity against the dialled address would fail every healthy node. + const { cert, key } = createCertificate({ ip: '198.51.100.7' }); + const port = await listenTls({ cert, key }); + + const result = await probeServedCertificate({ + host: '127.0.0.1', + port, + externalIp: '198.51.100.7', + }); + + expect(result.state).to.equal(STATE.SERVED); + expect(result.identityVerified).to.be.true(); + }); + + it('should report an identity mismatch against the external IP', async () => { + const { cert, key } = createCertificate({ ip: '203.0.113.9' }); + const port = await listenTls({ cert, key }); + + const result = await probeServedCertificate({ + host: '127.0.0.1', + port, + externalIp: '198.51.100.7', + }); + + expect(result.state).to.equal(STATE.SERVED); + expect(result.identityVerified).to.be.false(); + }); + + it('should report identity separately from the chain verdict when both fail', async () => { + // The socket surfaces only the first verification failure, so an expired certificate that + // also names the wrong address would otherwise hide the mismatch until the expiry was fixed. + const { cert, key } = createCertificate({ ip: '203.0.113.9', days: -5 }); + const port = await listenTls({ cert, key }); + + const result = await probeServedCertificate({ + host: '127.0.0.1', + port, + externalIp: '198.51.100.7', + }); + + expect(result.chainVerified).to.be.false(); + expect(result.identityVerified).to.be.false(); + }); + + it('should report unreachable when nothing is listening', async () => { + const result = await probeServedCertificate({ + host: '127.0.0.1', + // Port 1 is privileged and unused, so the connection is refused rather than answered + port: 1, + externalIp: EXTERNAL_IP, + }); + + expect(result.state).to.equal(STATE.UNREACHABLE); + expect(result.certificate).to.be.undefined(); + }); + + it('should give up on a peer that accepts the connection and never completes the handshake', async () => { + const server = track(net.createServer(() => {})); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + const result = await probeServedCertificate({ + host: '127.0.0.1', + port: server.address().port, + externalIp: EXTERNAL_IP, + timeout: 300, + }); + + expect(result.state).to.equal(STATE.UNREACHABLE); + expect(result.reason).to.equal('ETIMEDOUT'); + }); + + it('should give up on a peer that trickles data without completing the handshake', async () => { + // The socket's own timeout resets on every byte received, so a slow drip would keep the + // probe alive forever if the deadline were not independent of it. + const server = track(net.createServer((socket) => { + const interval = setInterval(() => socket.write('\0'), 50); + socket.on('close', () => clearInterval(interval)); + socket.on('error', () => clearInterval(interval)); + })); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + const result = await probeServedCertificate({ + host: '127.0.0.1', + port: server.address().port, + externalIp: EXTERNAL_IP, + timeout: 400, + }); + + expect(result.state).to.equal(STATE.UNREACHABLE); + }); +}); From d18ab8433e01c9a0308de367bc7200be2c2c2633 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 19 Aug 2026 23:30:52 +0700 Subject: [PATCH 3/9] fix(dashmate): install a renewed certificate and report when one was not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same gap: renewal could produce a certificate the gateway never used, and nothing said so. `dashmate ssl obtain` wrote the certificate files and exited. Only the helper's scheduled renewal signalled the gateway, so an operator who ran the command that doctor recommends saw it succeed while the gateway kept serving the previous certificate until the node happened to be restarted. The command now reloads the gateway, and skips that step with an explanatory message when the gateway is not running, which is normal during setup. `validateLetsEncryptCertificate` already computed whether the issued certificate pair was the pair the gateway uses — the helper exists for exactly this, and its own comment says file existence cannot distinguish a completed install from a partial one. The result was assigned and never read, so the case it was written to catch was reported as a healthy node. It is now returned as CERTIFICATE_NOT_INSTALLED with a message telling the operator to restart, which also covers a certificate that was issued but never installed at all. This is deliberately a file-based check even though the gateway is also probed directly: it works when the gateway is stopped, when platform is disabled, and when a diagnostic archive is analysed later, none of which a live probe can do. Tests would have caught this in CI: RED validateLetsEncryptCertificateFactory ✖ renewed certificate never copied to the gateway ✖ certificate issued but never installed at all -> expected undefined to equal 'CERTIFICATE_NOT_INSTALLED' SSL obtain command ✖ should reload the gateway so the new certificate is served -> expected stub to have been called with 'gateway', 'kill -SIGHUP 1' GREEN, and the suite is 326 passing. The validator assertions deliberately match the literal error name rather than the enum member: written against the enum they compared undefined to undefined and passed before the fix existed. Co-Authored-By: Claude Opus 5 --- packages/dashmate/src/commands/ssl/obtain.js | 12 ++ .../doctor/analyse/analyseConfigFactory.js | 6 + .../validateLetsEncryptCertificateFactory.js | 11 ++ .../test/unit/commands/ssl/obtain.spec.js | 62 ++++++++++ ...idateLetsEncryptCertificateFactory.spec.js | 108 ++++++++++++++++++ 5 files changed, 199 insertions(+) create mode 100644 packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js diff --git a/packages/dashmate/src/commands/ssl/obtain.js b/packages/dashmate/src/commands/ssl/obtain.js index 7fa2f37a309..18b33ff360f 100644 --- a/packages/dashmate/src/commands/ssl/obtain.js +++ b/packages/dashmate/src/commands/ssl/obtain.js @@ -40,6 +40,7 @@ Certificate will be renewed if it is about to expire (see 'expiration-days' flag * @param {obtainLetsEncryptCertificateTask} obtainLetsEncryptCertificateTask * @param {ConfigFileJsonRepository} configFileRepository * @param {ConfigFile} configFile + * @param {DockerCompose} dockerCompose * @return {Promise} */ async runWithDependencies( @@ -56,6 +57,7 @@ Certificate will be renewed if it is about to expire (see 'expiration-days' flag obtainLetsEncryptCertificateTask, configFileRepository, configFile, + dockerCompose, ) { const provider = providerFlag || config.get('platform.gateway.ssl.provider'); @@ -88,6 +90,16 @@ Certificate will be renewed if it is about to expire (see 'expiration-days' flag title: taskTitle, task: () => task(config, taskOptions), }, + { + // Writing the certificate files does not change what the gateway serves. Without + // this it keeps presenting the previous certificate until the node is restarted, + // so the command reports success while nothing changes for clients. + title: 'Reload gateway', + enabled: () => config.get('platform.enable'), + skip: async () => !(await dockerCompose.isServiceRunning(config, 'gateway')) + && 'Gateway is not running, the certificate will be used when it starts', + task: async () => dockerCompose.execCommand(config, 'gateway', 'kill -SIGHUP 1'), + }, ], { renderer: isVerbose ? 'verbose' : 'default', diff --git a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js index 57538c3cece..3c8aa60fd10 100644 --- a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js @@ -154,6 +154,12 @@ and revoke the previous certificate in the ZeroSSL dashboard`, description: chalk`Let's Encrypt certificate expires at ${ssl?.data?.certificate?.expires}.`, solution: chalk`Please run {bold.cyanBright dashmate ssl obtain --provider=letsencrypt} to renew`, }, + [LETSENCRYPT_ERRORS.CERTIFICATE_NOT_INSTALLED]: { + description: chalk`A renewed Let's Encrypt certificate has not been installed for the gateway.`, + solution: chalk`The gateway keeps serving the previous certificate until it is reloaded, +and will stop accepting clients when that one expires. +Please restart the node: {bold.cyanBright dashmate restart}`, + }, [LETSENCRYPT_ERRORS.CERTIFICATE_NOT_VALID]: { description: chalk`Let's Encrypt certificate is not valid.`, solution: chalk`Please run {bold.cyanBright dashmate ssl obtain --provider=letsencrypt --force} to get a new one.`, diff --git a/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js index 7a9effe67fb..86970dec505 100644 --- a/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js +++ b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js @@ -12,6 +12,7 @@ export const ERRORS = { CERTIFICATE_EXPIRES_SOON: 'CERTIFICATE_EXPIRES_SOON', CERTIFICATE_IP_MISMATCH: 'CERTIFICATE_IP_MISMATCH', CERTIFICATE_NOT_VALID: 'CERTIFICATE_NOT_VALID', + CERTIFICATE_NOT_INSTALLED: 'CERTIFICATE_NOT_INSTALLED', }; /** @@ -133,6 +134,16 @@ export default function validateLetsEncryptCertificateFactory(homeDir) { }; } + // The certificate is valid, but the gateway loads its own copy rather than the issued + // file. Until the two match the node keeps serving whatever was installed last, which + // stays invisible to every check that only looks at the issued certificate. + if (!data.isCertificatePairInstalled) { + return { + error: ERRORS.CERTIFICATE_NOT_INSTALLED, + data, + }; + } + // Certificate is valid return { data, diff --git a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js index c1a64b0edf1..dee2bbdf138 100644 --- a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js +++ b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js @@ -2,6 +2,67 @@ import { Listr } from 'listr2'; import ObtainCommand from '../../../../src/commands/ssl/obtain.js'; describe('SSL obtain command', () => { + it('should reload the gateway so the new certificate is served', async function it() { + // Writing the certificate files is not enough: the gateway keeps serving the previous + // certificate until it is signalled, so an operator can run this command, see it succeed, + // and find nothing changed on the wire. + const config = { + get: this.sinon.stub().callsFake((option) => (option === 'platform.enable' ? true : 'letsencrypt')), + }; + const dockerCompose = { + isServiceRunning: this.sinon.stub().resolves(true), + execCommand: this.sinon.stub().resolves(), + }; + + await new ObtainCommand().runWithDependencies( + {}, + { + verbose: false, + 'no-retry': true, + 'expiration-days': undefined, + force: false, + provider: 'letsencrypt', + }, + config, + this.sinon.stub(), + this.sinon.stub().returns(new Listr([{ task: () => {} }])), + { write: this.sinon.stub() }, + {}, + dockerCompose, + ); + + expect(dockerCompose.execCommand).to.have.been.calledOnceWith(config, 'gateway', 'kill -SIGHUP 1'); + }); + + it('should not fail when the gateway is not running yet', async function it() { + const config = { + get: this.sinon.stub().callsFake((option) => (option === 'platform.enable' ? true : 'letsencrypt')), + }; + const dockerCompose = { + isServiceRunning: this.sinon.stub().resolves(false), + execCommand: this.sinon.stub().resolves(), + }; + + await new ObtainCommand().runWithDependencies( + {}, + { + verbose: false, + 'no-retry': true, + 'expiration-days': undefined, + force: false, + provider: 'letsencrypt', + }, + config, + this.sinon.stub(), + this.sinon.stub().returns(new Listr([{ task: () => {} }])), + { write: this.sinon.stub() }, + {}, + dockerCompose, + ); + + expect(dockerCompose.execCommand).to.have.not.been.called(); + }); + it('should checkpoint a newly created ZeroSSL certificate before a later failure', async function it() { const config = { get: this.sinon.stub().returns('zerossl'), @@ -34,6 +95,7 @@ describe('SSL obtain command', () => { this.sinon.stub(), configFileRepository, configFile, + { isServiceRunning: this.sinon.stub().resolves(false), execCommand: this.sinon.stub() }, )).to.be.rejected(); expect(obtainZeroSSLCertificateTask).to.have.been.calledOnce(); diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js new file mode 100644 index 00000000000..37ace5803bc --- /dev/null +++ b/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js @@ -0,0 +1,108 @@ +import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import HomeDir from '../../../../src/config/HomeDir.js'; +import validateLetsEncryptCertificateFactory, { ERRORS } from '../../../../src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js'; + +const EXTERNAL_IP = '198.51.100.7'; +const CONFIG_NAME = 'testnet'; + +describe('validateLetsEncryptCertificateFactory', () => { + let homeDir; + let legoDir; + let sslDir; + let config; + let validateLetsEncryptCertificate; + + /** + * @return {{cert: string, key: string}} + */ + function issueCertificate() { + const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const key = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); + + const dir = fs.mkdtempSync(path.join(homeDir.getPath(), 'issue-')); + const keyPath = path.join(dir, 'key.pem'); + const certPath = path.join(dir, 'cert.pem'); + + fs.writeFileSync(keyPath, key); + + execFileSync('openssl', [ + 'req', '-x509', '-new', '-key', keyPath, '-out', certPath, + '-subj', `/CN=${EXTERNAL_IP}`, + '-addext', `subjectAltName=IP:${EXTERNAL_IP}`, + '-addext', 'basicConstraints=CA:FALSE', + '-days', '60', + ], { stdio: 'ignore' }); + + return { cert: fs.readFileSync(certPath, 'utf8'), key }; + } + + beforeEach(function beforeEach() { + homeDir = HomeDir.createTemp(); + + legoDir = homeDir.joinPath(CONFIG_NAME, 'platform', 'gateway', 'lego', 'certificates'); + sslDir = homeDir.joinPath(CONFIG_NAME, 'platform', 'gateway', 'ssl'); + + fs.mkdirSync(legoDir, { recursive: true }); + fs.mkdirSync(sslDir, { recursive: true }); + + config = { + get: this.sinon.stub().callsFake((option) => ({ + 'platform.gateway.ssl.providerConfigs.letsencrypt.email': 'operator@example.com', + externalIp: EXTERNAL_IP, + }[option])), + getName: this.sinon.stub().returns(CONFIG_NAME), + }; + + validateLetsEncryptCertificate = validateLetsEncryptCertificateFactory(homeDir); + }); + + afterEach(() => homeDir.remove()); + + it('should expose the not-installed error so callers can match on it', () => { + expect(ERRORS.CERTIFICATE_NOT_INSTALLED).to.equal('CERTIFICATE_NOT_INSTALLED'); + }); + + it('should report no problem when the issued certificate is the one the gateway uses', async () => { + const { cert, key } = issueCertificate(); + + fs.writeFileSync(path.join(legoDir, `${EXTERNAL_IP}.crt`), cert); + fs.writeFileSync(path.join(legoDir, `${EXTERNAL_IP}.key`), key); + fs.writeFileSync(path.join(sslDir, 'bundle.crt'), cert); + fs.writeFileSync(path.join(sslDir, 'private.key'), key); + + const result = await validateLetsEncryptCertificate(config); + + expect(result.error).to.be.undefined(); + }); + + it('should report a renewed certificate that was never copied to the gateway', async () => { + // Renewal writes a new certificate and then installs it for the gateway. When the second + // step does not happen the node keeps serving the previous certificate until it expires, + // and every check based on the renewed file alone still reports the node as healthy. + const renewed = issueCertificate(); + const previous = issueCertificate(); + + fs.writeFileSync(path.join(legoDir, `${EXTERNAL_IP}.crt`), renewed.cert); + fs.writeFileSync(path.join(legoDir, `${EXTERNAL_IP}.key`), renewed.key); + fs.writeFileSync(path.join(sslDir, 'bundle.crt'), previous.cert); + fs.writeFileSync(path.join(sslDir, 'private.key'), previous.key); + + const result = await validateLetsEncryptCertificate(config); + + expect(result.error).to.equal('CERTIFICATE_NOT_INSTALLED'); + }); + + it('should report a certificate that was issued but never installed at all', async () => { + const { cert, key } = issueCertificate(); + + fs.writeFileSync(path.join(legoDir, `${EXTERNAL_IP}.crt`), cert); + fs.writeFileSync(path.join(legoDir, `${EXTERNAL_IP}.key`), key); + + const result = await validateLetsEncryptCertificate(config); + + expect(result.error).to.equal('CERTIFICATE_NOT_INSTALLED'); + }); +}); From 85dff7a05bc5b961c7184d74caaef53e62e3ffa9 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 19 Aug 2026 23:36:53 +0700 Subject: [PATCH 4/9] fix(dashmate): give ZeroSSL operators a way out, without guessing their plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fifths of the ZeroSSL nodes on mainnet serve an expired certificate, and every certificate message told their operators to run `dashmate ssl obtain`. That command renews with the provider already configured, so for an operator whose account can no longer issue one it is the same failure again. It does work for an operator whose plan has certificates available, and dashmate cannot see which case it is looking at, so both routes are offered rather than one being asserted to be the answer. The Let's Encrypt route states what it costs — certificates for IP addresses last six days and renew on their own, against the ninety days a ZeroSSL certificate lasts — so switching is a choice rather than a nudge. Where ZeroSSL itself explained the failure, that explanation is now the description. Its API names an exhausted certificate limit, an unpaid invoice or a rejected key directly, which is more use than anything inferred from the certificate. An API failure that carried no message was previously dropped entirely, because the description doubles as the check for whether a problem exists. Also corrects a suggestion to run `dashmate ssl zerossl obtain`, which is not a command that exists. Only `obtain` and `cleanup` live under `dashmate ssl`. Tests would have caught this in CI: five assertions covering both routes being offered, the cost being stated, ZeroSSL's own reason being surfaced, an empty API failure still being reported, and no non-existent command being suggested. ✖ all five before, ✔ after. Suite: 331 passing. Co-Authored-By: Claude Opus 5 --- .../doctor/analyse/analyseConfigFactory.js | 35 +++++++++++-- .../analyse/analyseConfigFactory.spec.js | 52 +++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js index 3c8aa60fd10..d9bd85e16a2 100644 --- a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js @@ -5,6 +5,19 @@ import { ERRORS as ZEROSSL_ERRORS } from '../../ssl/zerossl/validateZeroSslCerti import { SEVERITY } from '../Prescription.js'; import Problem from '../Problem.js'; +/** + * Whether a ZeroSSL certificate can be renewed depends on the operator's plan, which dashmate + * cannot see. Both routes are offered rather than assuming which one applies, and the cost of + * switching is stated so the choice is an informed one. + */ +const LETSENCRYPT_ALTERNATIVE = chalk`Or switch to Let's Encrypt, which issues certificates for IP addresses free of +charge and renews them automatically: + {bold.cyanBright dashmate config set platform.gateway.ssl.provider letsencrypt} + {bold.cyanBright dashmate config set platform.gateway.ssl.providerConfigs.letsencrypt.email EMAIL} + {bold.cyanBright dashmate ssl obtain} +Its certificates for IP addresses are valid for 6 days and renew every few days on +their own, rather than the 90 days a ZeroSSL certificate lasts.`; + export default function analyseConfigFactory() { /** * @typedef analyseConfig @@ -113,7 +126,10 @@ and revoke the previous certificate in the ZeroSSL dashboard`, }, [ZEROSSL_ERRORS.CERTIFICATE_EXPIRES_SOON]: { description: chalk`ZeroSSL certificate expires at ${ssl?.data?.certificate?.expires}.`, - solution: chalk`Please run {bold.cyanBright dashmate ssl obtain} to get a new one`, + solution: chalk`Please run {bold.cyanBright dashmate ssl obtain} to get a new one, which needs an +available certificate on your ZeroSSL plan. + +${LETSENCRYPT_ALTERNATIVE}`, }, [ZEROSSL_ERRORS.CERTIFICATE_IS_NOT_VALIDATED]: { description: chalk`ZeroSSL certificate is not approved.`, @@ -121,11 +137,22 @@ and revoke the previous certificate in the ZeroSSL dashboard`, }, [ZEROSSL_ERRORS.CERTIFICATE_IS_NOT_VALID]: { description: chalk`ZeroSSL certificate is not valid.`, - solution: chalk`Please run {bold.cyanBright dashmate ssl zerossl obtain} to get a new one.`, + solution: chalk`Please run {bold.cyanBright dashmate ssl obtain} to get a new one. + +${LETSENCRYPT_ALTERNATIVE}`, }, [ZEROSSL_ERRORS.ZERO_SSL_API_ERROR]: { - description: ssl?.data?.error?.message, - solution: chalk`Please contact ZeroSSL support if needed.`, + // ZeroSSL's own wording is the most accurate account of what went wrong - it + // names an exhausted certificate limit, an unpaid invoice or a rejected key + // directly. The fallback keeps the problem reported when it sends none, since + // an empty description would otherwise drop it silently. + description: ssl?.data?.error?.message + ? chalk`ZeroSSL rejected the request: ${ssl.data.error.message}` + : chalk`The ZeroSSL API could not be reached, so the certificate cannot be checked or renewed.`, + solution: chalk`If this is something you can resolve with ZeroSSL, such as an expired plan or a +rejected API key, fix it there and run {bold.cyanBright dashmate ssl obtain}. + +${LETSENCRYPT_ALTERNATIVE}`, }, }; diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js index 4a24a84d23e..1e6fb01c6fc 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js @@ -96,6 +96,58 @@ describe('analyseConfigFactory', () => { expect(problems[0].getDescription()).to.include('SSL certificate files are not found'); }); + describe('ZeroSSL remediation', () => { + it('should offer both renewing with ZeroSSL and switching to Let\'s Encrypt', () => { + // Whether renewing works depends on the operator's ZeroSSL plan, which dashmate cannot + // see, so both routes are offered rather than one being asserted to be the answer. + const [problem] = analyseSslSample({ + error: ZEROSSL_ERRORS.CERTIFICATE_EXPIRES_SOON, + data: { certificate: { expires: '2026-01-01' } }, + }); + + expect(problem.getSolution()).to.include('dashmate ssl obtain'); + expect(problem.getSolution()).to.include('platform.gateway.ssl.provider letsencrypt'); + }); + + it('should state the cost of switching so the choice is informed', () => { + const [problem] = analyseSslSample({ + error: ZEROSSL_ERRORS.CERTIFICATE_EXPIRES_SOON, + data: { certificate: { expires: '2026-01-01' } }, + }); + + expect(problem.getSolution()).to.include('6 days'); + }); + + it('should surface the reason ZeroSSL itself gave', () => { + const [problem] = analyseSslSample({ + error: ZEROSSL_ERRORS.ZERO_SSL_API_ERROR, + data: { error: { message: 'Limit of certificates on your ZeroSSL account was reached' } }, + }); + + expect(problem.getDescription()).to.include('Limit of certificates'); + expect(problem.getSolution()).to.include('platform.gateway.ssl.provider letsencrypt'); + }); + + it('should still report an API failure that carried no message', () => { + // The description doubles as the presence check, so an empty one dropped the problem + const problems = analyseSslSample({ + error: ZEROSSL_ERRORS.ZERO_SSL_API_ERROR, + data: {}, + }); + + expect(problems).to.have.lengthOf(1); + }); + + it('should not suggest a command that does not exist', () => { + const [problem] = analyseSslSample({ + error: ZEROSSL_ERRORS.CERTIFICATE_IS_NOT_VALID, + data: {}, + }); + + expect(problem.getSolution()).to.not.include('ssl zerossl obtain'); + }); + }); + it('should not report a problem for a valid certificate', () => { const problems = analyseSslSample({ data: {} }); From 3c5a1f6b8f271ad072463b317c9a079807c4389a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 19 Aug 2026 23:54:22 +0700 Subject: [PATCH 5/9] fix(dashmate): do not probe the gateway from inside the helper container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The served-certificate probe assumed doctor only ever runs from the host, on the grounds that the helper's API exposes status and nothing else. That is true of the API and not of the command: dashmate is installed in the helper image and DASHMATE_HELPER is set there, so running the CLI in that container is possible and reports isHelper. The gateway's listener is published to the host. Inside the helper the same address is that container's own loopback, nothing answers, and the probe returned unreachable — which the analysis reports as "the gateway did not answer a TLS connection" on a node whose gateway is perfectly healthy. Exactly the kind of false alarm the check exists to avoid. The helper and self-signed cases are now recorded as explicitly skipped, with a reason, rather than being dropped by an enabled() guard. An absent sample and a deliberately skipped one are indistinguishable to a reader of a diagnostic archive, and only one of them is a statement about the node. Test would have caught this in CI: ✖ before: expected 'unreachable' to equal 'skipped' ✔ after Suite: 332 passing. Co-Authored-By: Claude Opus 5 --- .../tasks/doctor/collectSamplesTaskFactory.js | 34 ++++++++++++++++--- .../doctor/collectSamplesTaskFactory.spec.js | 28 ++++++++++++++- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js index 3d6ca81d88f..12f69309bf9 100644 --- a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js @@ -39,6 +39,7 @@ async function fetchTextOrError(url) { * @param {HomeDir} homeDir * @param {validateZeroSslCertificate} validateZeroSslCertificate * @param {validateLetsEncryptCertificate} validateLetsEncryptCertificate + * @param {boolean} isHelper * @return {collectSamplesTask} */ export default function collectSamplesTaskFactory( @@ -51,6 +52,7 @@ export default function collectSamplesTaskFactory( homeDir, validateZeroSslCertificate, validateLetsEncryptCertificate, + isHelper, ) { /** * @typedef {function} collectSamplesTask @@ -196,12 +198,36 @@ export default function collectSamplesTaskFactory( // Every other certificate check reads a file or the provider's API, so a // certificate that was renewed on disk but never reached the gateway looks // healthy to all of them. This connects to the gateway and records what it - // actually serves. Doctor only ever runs from the CLI - the helper exposes - // status and nothing else - so the listener is reached on its published port. - enabled: () => config.get('platform.enable') - && config.get('platform.gateway.ssl.provider') !== 'self-signed', + // actually serves. + enabled: () => config.get('platform.enable'), title: 'Gateway served certificate', task: async () => { + // dashmate is installed in the helper image, so the CLI can be run there as + // well as on the host. The gateway's listener is published to the host, and + // inside the helper the same address is that container's own, so probing it + // would report a healthy gateway as unreachable. Recorded rather than + // silently dropped, so it reads as unknown instead of as a healthy node. + if (isHelper) { + ctx.samples.setServiceInfo('gateway', 'servedCertificate', { + state: PROBE_STATE.SKIPPED, + reason: 'helper-context', + }); + + return; + } + + // A self-signed certificate is not trusted by design, so what it serves says + // nothing a check could act on. It is already reported by the configuration + // analysis when it is used on a network where it does not belong. + if (config.get('platform.gateway.ssl.provider') === 'self-signed') { + ctx.samples.setServiceInfo('gateway', 'servedCertificate', { + state: PROBE_STATE.SKIPPED, + reason: 'self-signed', + }); + + return; + } + const listenerHost = config.get('platform.gateway.listeners.dapiAndDrive.host'); const port = config.get('platform.gateway.listeners.dapiAndDrive.port'); diff --git a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js index defbcdf2d0f..d1ed4f356b8 100644 --- a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js @@ -47,6 +47,7 @@ describe('collectSamplesTaskFactory', () => { let config; let getCertificate; let collectSamplesTask; + let createCollectSamplesTask; let analyseConfig; let samples; @@ -109,7 +110,7 @@ describe('collectSamplesTaskFactory', () => { masternode: this.sinon.stub().resolves({ result: {} }), }; - collectSamplesTask = collectSamplesTaskFactory( + createCollectSamplesTask = (isHelper = false) => collectSamplesTaskFactory( dockerCompose, this.sinon.stub().returns(rpcClient), this.sinon.stub().resolves('127.0.0.1'), @@ -119,8 +120,11 @@ describe('collectSamplesTaskFactory', () => { homeDir, validateZeroSslCertificateFactory(homeDir, getCertificate), this.sinon.stub().resolves({}), + isHelper, ); + collectSamplesTask = createCollectSamplesTask(); + analyseConfig = analyseConfigFactory(); samples = new Samples(); @@ -235,6 +239,28 @@ describe('collectSamplesTaskFactory', () => { expect(samples.getServiceInfo('gateway', 'acmeHttpPort')).to.equal('OPEN'); }); + it('should not probe the gateway from inside the helper container', async () => { + // dashmate is installed in the helper image, so the CLI can be run there. Loopback is the + // helper's own there, not the host's, and probing it would report a healthy gateway as + // unreachable. Recorded as skipped so it reads as unknown rather than as a problem. + collectSamplesTask = createCollectSamplesTask(true); + + getCertificate.resolves(new Certificate({ + id: 'certificate-id', + common_name: EXTERNAL_IP, + status: 'issued', + created: toZeroSslDate(daysFromNow(-1)), + expires: toZeroSslDate(daysFromNow(89)), + })); + + await collectSamples(); + + const servedCertificate = samples.getServiceInfo('gateway', 'servedCertificate'); + + expect(servedCertificate.state).to.equal('skipped'); + expect(servedCertificate.reason).to.equal('helper-context'); + }); + it('should collect metrics as text rather than an unresolved promise', async () => { config.set('platform.gateway.metrics.enabled', true); From ec9f208e0b1b7cc4d6ee7071f0f02a904e775789 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 19 Aug 2026 23:59:12 +0700 Subject: [PATCH 6/9] fix(dashmate): state why the gateway is probed at its published address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment on the served-certificate probe justified using the published address by asserting that doctor only ever runs from the CLI because the helper's API exposes status and nothing else. That reasoning was wrong: it describes the API surface, not where the command can run. The conclusion it supported is right for the reason that was never stated — doctor is a diagnostic an operator runs on the node, so the gateway's listener is reached where it is published. Also drops the helper guard added in the previous commit. It was written for `docker exec dashmate_helper dashmate doctor`, which nothing does: the helper's entrypoint is its own script, its API accepts only status, and neither scripts/helper.js nor src/helper mentions doctor. Guarding a path that does not exist is speculation, and it cost a constructor dependency and a test asserting behaviour nothing reaches. Suite: 331 passing. Co-Authored-By: Claude Opus 5 --- .../tasks/doctor/collectSamplesTaskFactory.js | 34 +++---------------- .../doctor/collectSamplesTaskFactory.spec.js | 28 +-------------- 2 files changed, 5 insertions(+), 57 deletions(-) diff --git a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js index 12f69309bf9..2b0ab75a443 100644 --- a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js @@ -39,7 +39,6 @@ async function fetchTextOrError(url) { * @param {HomeDir} homeDir * @param {validateZeroSslCertificate} validateZeroSslCertificate * @param {validateLetsEncryptCertificate} validateLetsEncryptCertificate - * @param {boolean} isHelper * @return {collectSamplesTask} */ export default function collectSamplesTaskFactory( @@ -52,7 +51,6 @@ export default function collectSamplesTaskFactory( homeDir, validateZeroSslCertificate, validateLetsEncryptCertificate, - isHelper, ) { /** * @typedef {function} collectSamplesTask @@ -198,36 +196,12 @@ export default function collectSamplesTaskFactory( // Every other certificate check reads a file or the provider's API, so a // certificate that was renewed on disk but never reached the gateway looks // healthy to all of them. This connects to the gateway and records what it - // actually serves. - enabled: () => config.get('platform.enable'), + // actually serves. Doctor is run by an operator on the node, so the gateway's + // listener is reached at the address it is published on. + enabled: () => config.get('platform.enable') + && config.get('platform.gateway.ssl.provider') !== 'self-signed', title: 'Gateway served certificate', task: async () => { - // dashmate is installed in the helper image, so the CLI can be run there as - // well as on the host. The gateway's listener is published to the host, and - // inside the helper the same address is that container's own, so probing it - // would report a healthy gateway as unreachable. Recorded rather than - // silently dropped, so it reads as unknown instead of as a healthy node. - if (isHelper) { - ctx.samples.setServiceInfo('gateway', 'servedCertificate', { - state: PROBE_STATE.SKIPPED, - reason: 'helper-context', - }); - - return; - } - - // A self-signed certificate is not trusted by design, so what it serves says - // nothing a check could act on. It is already reported by the configuration - // analysis when it is used on a network where it does not belong. - if (config.get('platform.gateway.ssl.provider') === 'self-signed') { - ctx.samples.setServiceInfo('gateway', 'servedCertificate', { - state: PROBE_STATE.SKIPPED, - reason: 'self-signed', - }); - - return; - } - const listenerHost = config.get('platform.gateway.listeners.dapiAndDrive.host'); const port = config.get('platform.gateway.listeners.dapiAndDrive.port'); diff --git a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js index d1ed4f356b8..defbcdf2d0f 100644 --- a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js @@ -47,7 +47,6 @@ describe('collectSamplesTaskFactory', () => { let config; let getCertificate; let collectSamplesTask; - let createCollectSamplesTask; let analyseConfig; let samples; @@ -110,7 +109,7 @@ describe('collectSamplesTaskFactory', () => { masternode: this.sinon.stub().resolves({ result: {} }), }; - createCollectSamplesTask = (isHelper = false) => collectSamplesTaskFactory( + collectSamplesTask = collectSamplesTaskFactory( dockerCompose, this.sinon.stub().returns(rpcClient), this.sinon.stub().resolves('127.0.0.1'), @@ -120,11 +119,8 @@ describe('collectSamplesTaskFactory', () => { homeDir, validateZeroSslCertificateFactory(homeDir, getCertificate), this.sinon.stub().resolves({}), - isHelper, ); - collectSamplesTask = createCollectSamplesTask(); - analyseConfig = analyseConfigFactory(); samples = new Samples(); @@ -239,28 +235,6 @@ describe('collectSamplesTaskFactory', () => { expect(samples.getServiceInfo('gateway', 'acmeHttpPort')).to.equal('OPEN'); }); - it('should not probe the gateway from inside the helper container', async () => { - // dashmate is installed in the helper image, so the CLI can be run there. Loopback is the - // helper's own there, not the host's, and probing it would report a healthy gateway as - // unreachable. Recorded as skipped so it reads as unknown rather than as a problem. - collectSamplesTask = createCollectSamplesTask(true); - - getCertificate.resolves(new Certificate({ - id: 'certificate-id', - common_name: EXTERNAL_IP, - status: 'issued', - created: toZeroSslDate(daysFromNow(-1)), - expires: toZeroSslDate(daysFromNow(89)), - })); - - await collectSamples(); - - const servedCertificate = samples.getServiceInfo('gateway', 'servedCertificate'); - - expect(servedCertificate.state).to.equal('skipped'); - expect(servedCertificate.reason).to.equal('helper-context'); - }); - it('should collect metrics as text rather than an unresolved promise', async () => { config.set('platform.gateway.metrics.enabled', true); From d97f22eadc1fdc91783640f5d6d22c94d6f0a2cd Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 20 Aug 2026 00:21:03 +0700 Subject: [PATCH 7/9] fix(dashmate): address review of the certificate checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The port 80 check was named for ACME and ran for every provider. Only Let's Encrypt validates over ACME; ZeroSSL reaches the node through its own verification server (VerificationServer.js binds the same port for a different protocol). The check is now called what it is, and runs only for the two providers that validate at all — a self-signed or operator-supplied certificate is never validated, so the port says nothing about it. The sample is renamed to match. Dropped the username obfuscation pass over the probe result. It exists to keep local paths out of a diagnostic archive, and the probe records certificate fields and socket error codes, none of which can contain one. Trimmed the Let's Encrypt suggestion. Both providers renew on their own, so saying so does not separate them, and the certificate lifetimes it went on to compare are not something an operator has to weigh. What matters is that Let's Encrypt is free. Suite: 331 passing. Co-Authored-By: Claude Opus 5 --- .../src/doctor/analyse/analyseConfigFactory.js | 11 ++++------- .../analyse/analyseGatewayCertificateFactory.js | 11 ++++++----- .../tasks/doctor/collectSamplesTaskFactory.js | 17 ++++++++--------- .../doctor/analyse/analyseConfigFactory.spec.js | 5 +++-- .../analyseGatewayCertificateFactory.spec.js | 4 ++-- .../doctor/collectSamplesTaskFactory.spec.js | 2 +- 6 files changed, 24 insertions(+), 26 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js index d9bd85e16a2..ea70c7ef8de 100644 --- a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js @@ -7,16 +7,13 @@ import Problem from '../Problem.js'; /** * Whether a ZeroSSL certificate can be renewed depends on the operator's plan, which dashmate - * cannot see. Both routes are offered rather than assuming which one applies, and the cost of - * switching is stated so the choice is an informed one. + * cannot see, so both routes are offered rather than assuming which one applies. */ -const LETSENCRYPT_ALTERNATIVE = chalk`Or switch to Let's Encrypt, which issues certificates for IP addresses free of -charge and renews them automatically: +const LETSENCRYPT_ALTERNATIVE = chalk`Or switch to Let's Encrypt, which issues certificates for IP addresses free +of charge: {bold.cyanBright dashmate config set platform.gateway.ssl.provider letsencrypt} {bold.cyanBright dashmate config set platform.gateway.ssl.providerConfigs.letsencrypt.email EMAIL} - {bold.cyanBright dashmate ssl obtain} -Its certificates for IP addresses are valid for 6 days and renew every few days on -their own, rather than the 90 days a ZeroSSL certificate lasts.`; + {bold.cyanBright dashmate ssl obtain}`; export default function analyseConfigFactory() { /** diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index c11d7e78c15..5817ce7818f 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -119,12 +119,13 @@ ${RESTART_HINT}`, )); } - // Both providers validate over inbound port 80. Being closed is only reported alongside a - // certificate problem: the port is bound just for the seconds a validation takes, so an - // external check finds it closed on healthy nodes too and on its own would be noise. - const acmeHttpPort = samples.getServiceInfo('gateway', 'acmeHttpPort'); + // Both obtainable providers reach this node on port 80 to validate it. Being closed is + // only reported alongside a certificate problem: the port is bound just for the seconds a + // validation takes, so an external check finds it closed on healthy nodes too and on its + // own would be noise. + const validationHttpPort = samples.getServiceInfo('gateway', 'validationHttpPort'); - if (problems.length > 0 && acmeHttpPort && acmeHttpPort !== 'OPEN') { + if (problems.length > 0 && validationHttpPort && validationHttpPort !== 'OPEN') { problems.push(new Problem( 'Inbound port 80 is not reachable, which is how certificates are validated. ' + 'This may be why renewal is failing', diff --git a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js index 2b0ab75a443..632a57371a0 100644 --- a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js @@ -233,23 +233,22 @@ export default function collectSamplesTaskFactory( : null; } - obfuscateObjectRecursive(result, (_field, value) => (typeof value === 'string' ? value.replaceAll( - process.env.USER, - hideString(process.env.USER), - ) : value)); - ctx.samples.setServiceInfo('gateway', 'servedCertificate', result); }, }, { - // Both certificate providers validate this node over inbound port 80. - enabled: () => config.get('platform.enable'), - title: 'ACME HTTP validation port', + // Both obtainable providers reach this node on port 80 to prove it controls + // its address before issuing: Let's Encrypt over ACME, ZeroSSL over its own + // verification server. A self-signed or operator-supplied certificate is + // never validated, so the port means nothing for those. + enabled: () => config.get('platform.enable') + && ['zerossl', 'letsencrypt'].includes(config.get('platform.gateway.ssl.provider')), + title: 'Certificate validation port', task: async () => { const response = await providers.mnowatch.checkPortStatus(80, config.get('externalIp')) .catch((e) => e.toString()); - ctx.samples.setServiceInfo('gateway', 'acmeHttpPort', response); + ctx.samples.setServiceInfo('gateway', 'validationHttpPort', response); }, }, { diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js index 1e6fb01c6fc..602fc78c3ae 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js @@ -109,13 +109,14 @@ describe('analyseConfigFactory', () => { expect(problem.getSolution()).to.include('platform.gateway.ssl.provider letsencrypt'); }); - it('should state the cost of switching so the choice is informed', () => { + it('should name what makes the alternative worth taking', () => { + // Both providers renew on their own, so that is not what separates them const [problem] = analyseSslSample({ error: ZEROSSL_ERRORS.CERTIFICATE_EXPIRES_SOON, data: { certificate: { expires: '2026-01-01' } }, }); - expect(problem.getSolution()).to.include('6 days'); + expect(problem.getSolution()).to.include('free'); }); it('should surface the reason ZeroSSL itself gave', () => { diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index 213b3fba70f..62d6f419de7 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -129,7 +129,7 @@ describe('analyseGatewayCertificateFactory', () => { }); it('should report a closed port 80 as a likely cause when a certificate problem exists', () => { - samples.setServiceInfo('gateway', 'acmeHttpPort', 'CLOSED'); + samples.setServiceInfo('gateway', 'validationHttpPort', 'CLOSED'); const problems = analyse(served({ certificate: { fingerprint256: 'AA:BB', validTo: validTo(-3) }, @@ -142,7 +142,7 @@ describe('analyseGatewayCertificateFactory', () => { it('should not report a closed port 80 on a node whose certificate is healthy', () => { // The port is only bound for the seconds a validation takes, so an external check finds it // closed on actively renewing nodes too. Alone it would fire far more often than it is right. - samples.setServiceInfo('gateway', 'acmeHttpPort', 'CLOSED'); + samples.setServiceInfo('gateway', 'validationHttpPort', 'CLOSED'); expect(analyse(served())).to.be.empty(); }); diff --git a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js index defbcdf2d0f..c1c02bedb7f 100644 --- a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js @@ -232,7 +232,7 @@ describe('collectSamplesTaskFactory', () => { expect(servedCertificate.state).to.equal('served'); expect(servedCertificate.identityVerified).to.be.true(); expect(servedCertificate.matchesOnDisk).to.be.true(); - expect(samples.getServiceInfo('gateway', 'acmeHttpPort')).to.equal('OPEN'); + expect(samples.getServiceInfo('gateway', 'validationHttpPort')).to.equal('OPEN'); }); it('should collect metrics as text rather than an unresolved promise', async () => { From deaceaef03a1a6f7752f32f78677f177f5b16f5d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 20 Aug 2026 00:26:41 +0700 Subject: [PATCH 8/9] fix(dashmate): restart only Platform to pick up a certificate Every certificate message told the operator to run `dashmate restart`, which stops Core as well. Only the gateway has to reload, and it sits in the platform profile, so `--platform` is enough. On a masternode the difference is not just scope. A full restart takes the stop path that waits for a DKG window, or skips it and risks a ban; the platform-only path avoids the question entirely by leaving Core running. Suite: 331 passing. Co-Authored-By: Claude Opus 5 --- .../dashmate/src/doctor/analyse/analyseConfigFactory.js | 2 +- .../src/doctor/analyse/analyseGatewayCertificateFactory.js | 6 +++--- .../doctor/analyse/analyseGatewayCertificateFactory.spec.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js index ea70c7ef8de..67e897bd736 100644 --- a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js @@ -182,7 +182,7 @@ ${LETSENCRYPT_ALTERNATIVE}`, description: chalk`A renewed Let's Encrypt certificate has not been installed for the gateway.`, solution: chalk`The gateway keeps serving the previous certificate until it is reloaded, and will stop accepting clients when that one expires. -Please restart the node: {bold.cyanBright dashmate restart}`, +Please restart Platform: {bold.cyanBright dashmate restart --platform}`, }, [LETSENCRYPT_ERRORS.CERTIFICATE_NOT_VALID]: { description: chalk`Let's Encrypt certificate is not valid.`, diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 5817ce7818f..23d77a4e270 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -7,7 +7,7 @@ import Problem from '../Problem.js'; * operator following the advice can succeed and see no change on the wire. Every message about * a certificate the gateway has not picked up has to say this. */ -const RESTART_HINT = chalk`Then restart the node so the gateway picks it up: {bold.cyanBright dashmate restart}`; +const RESTART_HINT = chalk`Then restart Platform so the gateway picks it up: {bold.cyanBright dashmate restart --platform}`; export default function analyseGatewayCertificateFactory() { /** @@ -80,7 +80,7 @@ ${RESTART_HINT}`, `The gateway is serving a certificate that expired on ${served.certificate.validTo}, ` + 'while a newer one is already present on disk', chalk`The certificate was renewed but never reached the gateway. -{bold.cyanBright dashmate restart}`, +{bold.cyanBright dashmate restart --platform}`, SEVERITY.HIGH, )); } else if (isServedExpired) { @@ -100,7 +100,7 @@ ${RESTART_HINT}`, 'The gateway is serving an older certificate than the one on disk. ' + `It will stop accepting clients on ${served.certificate.validTo}`, chalk`The certificate was renewed but never reached the gateway. -{bold.cyanBright dashmate restart}`, +{bold.cyanBright dashmate restart --platform}`, SEVERITY.HIGH, )); } diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index 62d6f419de7..f7f7a022b47 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -81,7 +81,7 @@ describe('analyseGatewayCertificateFactory', () => { expect(problems).to.have.lengthOf(1); expect(problems[0].getDescription()).to.include('newer one is already present on disk'); - expect(problems[0].getSolution()).to.include('dashmate restart'); + expect(problems[0].getSolution()).to.include('dashmate restart --platform'); }); it('should warn before the outage when a renewed certificate has not been picked up', () => { From e9774bb39a975fade1286d6e5c06a988e5e37045 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 20 Aug 2026 00:40:22 +0700 Subject: [PATCH 9/9] test(dashmate): generate certificates with node-forge instead of openssl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe tests placed a certificate in the past with `openssl req -not_before` /`-not_after`. Those options arrived in OpenSSL 3.5; the CI image ships 3.0, so the suite passed locally and would have failed there. Certificates are now built with node-forge, already a dependency and already used to read them, which has no such constraint and needs no subprocess. All three specs share one helper. Also removes a race in the gateway reload. It asked whether the gateway was running and then signalled it, but execCommand makes that same check itself and throws when it fails, so a gateway that stopped in between turned a completed certificate acquisition into a failed command — sending the operator back to a provider that may have nothing left to issue. The pre-check is gone and the error is treated as nothing to reload. Suite: 331 passing. Co-Authored-By: Claude Opus 5 --- packages/dashmate/src/commands/ssl/obtain.js | 20 ++++++- .../src/test/createCertificateForTest.js | 45 +++++++++++++++ .../test/unit/commands/ssl/obtain.spec.js | 9 +-- .../doctor/collectSamplesTaskFactory.spec.js | 24 +------- ...idateLetsEncryptCertificateFactory.spec.js | 27 +-------- .../unit/ssl/probeServedCertificate.spec.js | 57 ++----------------- 6 files changed, 77 insertions(+), 105 deletions(-) create mode 100644 packages/dashmate/src/test/createCertificateForTest.js diff --git a/packages/dashmate/src/commands/ssl/obtain.js b/packages/dashmate/src/commands/ssl/obtain.js index 18b33ff360f..310cca22f46 100644 --- a/packages/dashmate/src/commands/ssl/obtain.js +++ b/packages/dashmate/src/commands/ssl/obtain.js @@ -1,5 +1,6 @@ import { Listr } from 'listr2'; import { Flags } from '@oclif/core'; +import ServiceIsNotRunningError from '../../docker/errors/ServiceIsNotRunningError.js'; import ConfigBaseCommand from '../../oclif/command/ConfigBaseCommand.js'; import MuteOneLineError from '../../oclif/errors/MuteOneLineError.js'; import Certificate from '../../ssl/zerossl/Certificate.js'; @@ -96,9 +97,22 @@ Certificate will be renewed if it is about to expire (see 'expiration-days' flag // so the command reports success while nothing changes for clients. title: 'Reload gateway', enabled: () => config.get('platform.enable'), - skip: async () => !(await dockerCompose.isServiceRunning(config, 'gateway')) - && 'Gateway is not running, the certificate will be used when it starts', - task: async () => dockerCompose.execCommand(config, 'gateway', 'kill -SIGHUP 1'), + // Asking whether the gateway is running before signalling it would answer a question + // that can stop being true before the signal is sent, and the certificate has already + // been obtained by this point - failing here would send the operator back to a + // provider that has nothing left to give. execCommand makes the same check itself, + // so a gateway that is down is taken as nothing to reload. + task: async (ctx, listrTask) => { + try { + await dockerCompose.execCommand(config, 'gateway', 'kill -SIGHUP 1'); + } catch (e) { + if (!(e instanceof ServiceIsNotRunningError)) { + throw e; + } + + listrTask.skip('Gateway is not running, the certificate will be used when it starts'); + } + }, }, ], { diff --git a/packages/dashmate/src/test/createCertificateForTest.js b/packages/dashmate/src/test/createCertificateForTest.js new file mode 100644 index 00000000000..ec78288605a --- /dev/null +++ b/packages/dashmate/src/test/createCertificateForTest.js @@ -0,0 +1,45 @@ +import forge from 'node-forge'; + +/** + * Create a self-signed certificate with a chosen validity window and IP address. + * + * Certificates are generated when a test runs rather than committed, so a fixture cannot + * expire and fail the suite on a date nobody chose. Built with node-forge rather than the + * openssl binary because the flags needed to place a certificate in the past arrived in + * OpenSSL 3.5, which is newer than the version on the CI image. + * + * @param {Object} [options] + * @param {string} [options.ip] - placed in the subject alternative name and common name + * @param {number} [options.days] - days from now the certificate expires, negative for expired + * @return {{cert: string, key: string}} PEM encoded + */ +export default function createCertificateForTest({ ip = '127.0.0.1', days = 30 } = {}) { + const keys = forge.pki.rsa.generateKeyPair(2048); + const certificate = forge.pki.createCertificate(); + + certificate.publicKey = keys.publicKey; + certificate.serialNumber = '01'; + + // Anchored to the expiry so an already-expired certificate still starts before it ends + certificate.validity.notAfter = new Date(Date.now() + days * 24 * 60 * 60 * 1000); + certificate.validity.notBefore = new Date( + certificate.validity.notAfter.getTime() - 30 * 24 * 60 * 60 * 1000, + ); + + const attributes = [{ name: 'commonName', value: ip }]; + + certificate.setSubject(attributes); + certificate.setIssuer(attributes); + certificate.setExtensions([ + { name: 'basicConstraints', cA: false }, + // Type 7 is an IP address. An evonode is identified by its address, not by a name. + { name: 'subjectAltName', altNames: [{ type: 7, ip }] }, + ]); + + certificate.sign(keys.privateKey, forge.md.sha256.create()); + + return { + cert: forge.pki.certificateToPem(certificate), + key: forge.pki.privateKeyToPem(keys.privateKey), + }; +} diff --git a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js index dee2bbdf138..ce2a577291f 100644 --- a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js +++ b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js @@ -1,5 +1,6 @@ import { Listr } from 'listr2'; import ObtainCommand from '../../../../src/commands/ssl/obtain.js'; +import ServiceIsNotRunningError from '../../../../src/docker/errors/ServiceIsNotRunningError.js'; describe('SSL obtain command', () => { it('should reload the gateway so the new certificate is served', async function it() { @@ -34,13 +35,13 @@ describe('SSL obtain command', () => { expect(dockerCompose.execCommand).to.have.been.calledOnceWith(config, 'gateway', 'kill -SIGHUP 1'); }); - it('should not fail when the gateway is not running yet', async function it() { + it('should not fail when the gateway stops before it can be signalled', async function it() { const config = { get: this.sinon.stub().callsFake((option) => (option === 'platform.enable' ? true : 'letsencrypt')), }; const dockerCompose = { - isServiceRunning: this.sinon.stub().resolves(false), - execCommand: this.sinon.stub().resolves(), + isServiceRunning: this.sinon.stub().resolves(true), + execCommand: this.sinon.stub().rejects(new ServiceIsNotRunningError('testnet', 'gateway')), }; await new ObtainCommand().runWithDependencies( @@ -60,7 +61,7 @@ describe('SSL obtain command', () => { dockerCompose, ); - expect(dockerCompose.execCommand).to.have.not.been.called(); + expect(dockerCompose.execCommand).to.have.been.calledOnce(); }); it('should checkpoint a newly created ZeroSSL certificate before a later failure', async function it() { diff --git a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js index c1c02bedb7f..7de4901d627 100644 --- a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js @@ -1,12 +1,10 @@ -import { execFileSync } from 'node:child_process'; -import crypto from 'node:crypto'; import fs from 'fs'; -import os from 'node:os'; import path from 'path'; import tls from 'node:tls'; import { Listr } from 'listr2'; import getBaseConfigFactory from '../../../../../configs/defaults/getBaseConfigFactory.js'; import HomeDir from '../../../../../src/config/HomeDir.js'; +import createCertificateForTest from '../../../../../src/test/createCertificateForTest.js'; import analyseConfigFactory from '../../../../../src/doctor/analyse/analyseConfigFactory.js'; import { SEVERITY } from '../../../../../src/doctor/Prescription.js'; import Samples from '../../../../../src/doctor/Samples.js'; @@ -172,24 +170,7 @@ describe('collectSamplesTaskFactory', () => { }); it('should collect the certificate the gateway actually serves', async () => { - const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); - const key = privateKey.export({ type: 'pkcs8', format: 'pem' }); - - const certDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashmate-served-')); - const keyPath = path.join(certDir, 'key.pem'); - const certPath = path.join(certDir, 'cert.pem'); - - fs.writeFileSync(keyPath, key); - - execFileSync('openssl', [ - 'req', '-x509', '-new', '-key', keyPath, '-out', certPath, - '-subj', `/CN=${EXTERNAL_IP}`, - '-addext', `subjectAltName=IP:${EXTERNAL_IP}`, - '-addext', 'basicConstraints=CA:FALSE', - '-days', '30', - ], { stdio: 'ignore' }); - - const cert = fs.readFileSync(certPath, 'utf8'); + const { cert, key } = createCertificateForTest({ ip: EXTERNAL_IP, days: 30 }); const server = tls.createServer({ cert, key }, (socket) => socket.end()); const liveSockets = []; @@ -224,7 +205,6 @@ describe('collectSamplesTaskFactory', () => { await new Promise((resolve) => { server.close(resolve); }); - fs.rmSync(certDir, { recursive: true, force: true }); } const servedCertificate = samples.getServiceInfo('gateway', 'servedCertificate'); diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js index 37ace5803bc..a933d62595a 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js @@ -1,8 +1,7 @@ -import { execFileSync } from 'node:child_process'; -import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import HomeDir from '../../../../src/config/HomeDir.js'; +import createCertificateForTest from '../../../../src/test/createCertificateForTest.js'; import validateLetsEncryptCertificateFactory, { ERRORS } from '../../../../src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js'; const EXTERNAL_IP = '198.51.100.7'; @@ -15,29 +14,7 @@ describe('validateLetsEncryptCertificateFactory', () => { let config; let validateLetsEncryptCertificate; - /** - * @return {{cert: string, key: string}} - */ - function issueCertificate() { - const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); - const key = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); - - const dir = fs.mkdtempSync(path.join(homeDir.getPath(), 'issue-')); - const keyPath = path.join(dir, 'key.pem'); - const certPath = path.join(dir, 'cert.pem'); - - fs.writeFileSync(keyPath, key); - - execFileSync('openssl', [ - 'req', '-x509', '-new', '-key', keyPath, '-out', certPath, - '-subj', `/CN=${EXTERNAL_IP}`, - '-addext', `subjectAltName=IP:${EXTERNAL_IP}`, - '-addext', 'basicConstraints=CA:FALSE', - '-days', '60', - ], { stdio: 'ignore' }); - - return { cert: fs.readFileSync(certPath, 'utf8'), key }; - } + const issueCertificate = () => createCertificateForTest({ ip: EXTERNAL_IP, days: 60 }); beforeEach(function beforeEach() { homeDir = HomeDir.createTemp(); diff --git a/packages/dashmate/test/unit/ssl/probeServedCertificate.spec.js b/packages/dashmate/test/unit/ssl/probeServedCertificate.spec.js index 8bdcb9ca9b1..5fbba336b42 100644 --- a/packages/dashmate/test/unit/ssl/probeServedCertificate.spec.js +++ b/packages/dashmate/test/unit/ssl/probeServedCertificate.spec.js @@ -1,55 +1,10 @@ -import { execFileSync } from 'node:child_process'; -import crypto from 'node:crypto'; -import fs from 'node:fs'; import net from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; import tls from 'node:tls'; import probeServedCertificate, { STATE } from '../../../src/ssl/probeServedCertificate.js'; +import createCertificateForTest from '../../../src/test/createCertificateForTest.js'; const EXTERNAL_IP = '127.0.0.1'; -/** - * Generate a certificate at test time rather than committing one: a committed certificate - * expires and fails the suite on a date nobody chose. - * - * @param {Object} options - * @return {{cert: string, key: string}} - */ -function createCertificate({ ip = EXTERNAL_IP, days = 30 } = {}) { - const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); - - const key = privateKey.export({ type: 'pkcs8', format: 'pem' }); - - // Node cannot issue certificates, so shell out to the openssl that ships with the OS - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashmate-cert-')); - const keyPath = path.join(dir, 'key.pem'); - const certPath = path.join(dir, 'cert.pem'); - - fs.writeFileSync(keyPath, key); - - // notBefore is anchored to notAfter so an already-expired certificate still has a valid - // ordering rather than starting after it ends - const notAfter = new Date(Date.now() + days * 24 * 60 * 60 * 1000); - const notBefore = new Date(notAfter.getTime() - 30 * 24 * 60 * 60 * 1000); - const stamp = (date) => date.toISOString().replace(/[-:T]/g, '').replace(/\.\d+Z$/, 'Z'); - - execFileSync('openssl', [ - 'req', '-x509', '-new', '-key', keyPath, '-out', certPath, - '-subj', `/CN=${ip}`, - '-addext', `subjectAltName=IP:${ip}`, - '-addext', 'basicConstraints=CA:FALSE', - '-not_before', stamp(notBefore), - '-not_after', stamp(notAfter), - ], { stdio: 'ignore' }); - - const cert = fs.readFileSync(certPath, 'utf8'); - - fs.rmSync(dir, { recursive: true, force: true }); - - return { cert, key: key.toString() }; -} - describe('probeServedCertificate', () => { const servers = []; const sockets = []; @@ -93,7 +48,7 @@ describe('probeServedCertificate', () => { }); it('should report the certificate the server actually serves', async () => { - const { cert, key } = createCertificate({ days: 30 }); + const { cert, key } = createCertificateForTest({ days: 30 }); const port = await listenTls({ cert, key }); const result = await probeServedCertificate({ host: '127.0.0.1', port, externalIp: EXTERNAL_IP }); @@ -104,7 +59,7 @@ describe('probeServedCertificate', () => { }); it('should complete the handshake and report an expired certificate', async () => { - const { cert, key } = createCertificate({ days: -5 }); + const { cert, key } = createCertificateForTest({ days: -5 }); const port = await listenTls({ cert, key }); const result = await probeServedCertificate({ host: '127.0.0.1', port, externalIp: EXTERNAL_IP }); @@ -116,7 +71,7 @@ describe('probeServedCertificate', () => { it('should not fail identity for a certificate naming the external IP rather than the probed address', async () => { // The gateway is reached on loopback but its certificate names the node's public address. // Judging identity against the dialled address would fail every healthy node. - const { cert, key } = createCertificate({ ip: '198.51.100.7' }); + const { cert, key } = createCertificateForTest({ ip: '198.51.100.7' }); const port = await listenTls({ cert, key }); const result = await probeServedCertificate({ @@ -130,7 +85,7 @@ describe('probeServedCertificate', () => { }); it('should report an identity mismatch against the external IP', async () => { - const { cert, key } = createCertificate({ ip: '203.0.113.9' }); + const { cert, key } = createCertificateForTest({ ip: '203.0.113.9' }); const port = await listenTls({ cert, key }); const result = await probeServedCertificate({ @@ -146,7 +101,7 @@ describe('probeServedCertificate', () => { it('should report identity separately from the chain verdict when both fail', async () => { // The socket surfaces only the first verification failure, so an expired certificate that // also names the wrong address would otherwise hide the mismatch until the expiry was fixed. - const { cert, key } = createCertificate({ ip: '203.0.113.9', days: -5 }); + const { cert, key } = createCertificateForTest({ ip: '203.0.113.9', days: -5 }); const port = await listenTls({ cert, key }); const result = await probeServedCertificate({