From a00c5ac81b694d5884378c7e497352996be3a28b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 4 Jun 2026 12:31:51 +0000 Subject: [PATCH 01/63] chore: update gRPC queries cache [skip ci] --- .github/grpc-queries-cache.json | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/grpc-queries-cache.json b/.github/grpc-queries-cache.json index ed3e6d2954a..7f9f5837d7a 100644 --- a/.github/grpc-queries-cache.json +++ b/.github/grpc-queries-cache.json @@ -141,7 +141,25 @@ }, "getConsensusParams": { "status": "not_implemented" + }, + "getAddressInfo": { + "status": "implemented" + }, + "getAddressesBranchState": { + "status": "implemented" + }, + "getAddressesInfos": { + "status": "implemented" + }, + "getAddressesTrunkState": { + "status": "implemented" + }, + "getRecentAddressBalanceChanges": { + "status": "implemented" + }, + "getRecentCompactedAddressBalanceChanges": { + "status": "implemented" } }, - "last_updated": "2025-07-14T03:27:11.465612" + "last_updated": "2026-06-04T12:31:51.100644" } \ No newline at end of file From 29276af9404b45c0cef99d6cccc70a478560a32f Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 09:42:59 +0700 Subject: [PATCH 02/63] feat(dashmate): detect whether a session can answer a prompt Dashmate has had no terminal detection anywhere: zero hits for isTTY, process.stdin or env.CI across src/. Adding a prompt to `update` needs one, and getting it wrong is expensive in both directions - a wrong "non-interactive" answer breaks CI and Ansible, a wrong "interactive" answer waits for a keystroke that never arrives on a node the documented upgrade procedure has already stopped. isInteractiveSession resolves fail-closed in six rules: an explicit --non-interactive flag or DASHMATE_NON_INTERACTIVE outranks everything (a playbook cannot carry a flag the installed binary would reject, so the variable can be armed before the upgrade), then JSON output, then CI, then both streams having to be terminals. Streams are read at call time because oclif replaces the ones it manages, and every test is `!== true` because Node reports a non-terminal stream as `undefined`. promptOrThrow is the second half. listr2 5.0.7 has no terminal check on createPrompt and enquirer's guard does not fire on the default stdin, so a prompt reached unattended never throws and never settles: measured, it drains the event loop and the process exits 0 with nothing done. Every prompt goes through this helper so a leak is an error with a name rather than a silent success. Interactivity is a positive opt-in so a caller that forgets it - the helper's unattended renewal - cannot enable prompting by omission. Tests: 25 new, all red before this commit (both modules absent), green after. The truth table covers all twelve environments in the design's survey, plus flag/env precedence, case-folded CI parsing, CI=0 as the documented escape for a human on a CI box, and a regression pin on `undefined` isTTY so nobody tidies `!== true` into `=== false`. Co-Authored-By: Claude Opus 5 --- .../util/errors/NonInteractivePromptError.js | 21 ++++ .../dashmate/src/util/isInteractiveSession.js | 85 +++++++++++++ packages/dashmate/src/util/promptOrThrow.js | 29 +++++ .../unit/util/isInteractiveSession.spec.js | 113 ++++++++++++++++++ .../test/unit/util/promptOrThrow.spec.js | 47 ++++++++ 5 files changed, 295 insertions(+) create mode 100644 packages/dashmate/src/util/errors/NonInteractivePromptError.js create mode 100644 packages/dashmate/src/util/isInteractiveSession.js create mode 100644 packages/dashmate/src/util/promptOrThrow.js create mode 100644 packages/dashmate/test/unit/util/isInteractiveSession.spec.js create mode 100644 packages/dashmate/test/unit/util/promptOrThrow.spec.js diff --git a/packages/dashmate/src/util/errors/NonInteractivePromptError.js b/packages/dashmate/src/util/errors/NonInteractivePromptError.js new file mode 100644 index 00000000000..537ba47b51b --- /dev/null +++ b/packages/dashmate/src/util/errors/NonInteractivePromptError.js @@ -0,0 +1,21 @@ +import AbstractError from '../../errors/AbstractError.js'; + +/** + * A prompt was reached on a path that cannot receive an answer. + * + * This is a programming error rather than an operator error: a code path that + * asks a question has to be gated on interactivity before it gets here. It + * exists so the mistake surfaces as a reported failure instead of a process + * that waits forever, or drains its event loop and exits successfully with + * nothing done. + */ +export default class NonInteractivePromptError extends AbstractError { + /** + * @param {string} [question] + */ + constructor(question) { + super(`Tried to ask "${question ?? 'a question'}" without a terminal to answer it`); + + this.question = question; + } +} diff --git a/packages/dashmate/src/util/isInteractiveSession.js b/packages/dashmate/src/util/isInteractiveSession.js new file mode 100644 index 00000000000..339fe89e3c7 --- /dev/null +++ b/packages/dashmate/src/util/isInteractiveSession.js @@ -0,0 +1,85 @@ +/** + * Whether an environment variable carrying a boolean is switched on. + * + * An unset variable, "0", "false" and an empty value all mean off. The + * comparison is case-folded because CI systems write TRUE, True and true + * interchangeably and all three mean the same thing. + * + * @param {string|undefined} value + * @return {boolean} + */ +function isEnvironmentFlagSet(value) { + if (value === undefined || value === null) { + return false; + } + + const normalized = String(value).trim().toLowerCase(); + + return normalized !== '' && normalized !== '0' && normalized !== 'false'; +} + +/** + * Decide whether this process may ask the operator a question. + * + * The answer is fail-closed: everything that is not demonstrably a human at a + * terminal is treated as automation. A wrong "non-interactive" answer reports a + * problem and exits without changing anything, while a wrong "interactive" + * answer waits for a keystroke that never arrives - and the documented upgrade + * procedure stops the node before this runs, so that wait happens with the node + * already down. + * + * The streams are read on every call rather than captured when the module + * loads: oclif replaces the streams it manages, so a captured value can + * describe a state that no longer holds. `isTTY` is `undefined` rather than + * `false` on a stream that is not a terminal, which is why every test here is + * `!== true`. + * + * @param {Object} [options] + * @param {Object} [options.flags] - parsed command flags + * @param {Object} [options.env] + * @param {Object} [options.stdin] + * @param {Object} [options.stdout] + * @return {boolean} + */ +export default function isInteractiveSession({ + flags = {}, + env = process.env, + stdin = process.stdin, + stdout = process.stdout, +} = {}) { + // An explicit instruction from the operator outranks every heuristic below, + // including CI. The environment variable exists because a playbook cannot + // carry a flag the currently installed binary would reject, so automation can + // be armed before the upgrade rather than after it. + if (flags?.['non-interactive'] === true + || isEnvironmentFlagSet(env?.DASHMATE_NON_INTERACTIVE)) { + return false; + } + + // Prompt chrome is written to stdout, so prompting here would corrupt the + // single parseable document the caller asked for. + if (flags?.format === 'json') { + return false; + } + + // Every major CI system sets this, and some of them allocate a pty, which + // defeats the stream checks below. A human debugging on a box that exports it + // gets back to prompts with CI=0. + if (isEnvironmentFlagSet(env?.CI)) { + return false; + } + + // No keystroke can ever arrive. + if (stdin?.isTTY !== true) { + return false; + } + + // A prompt nobody can see is indistinguishable from a hang. This also + // classifies `| tee` as automation, which is the conservative side of a real + // trade-off: keying only on stdin would let `> log` hang in silence. + if (stdout?.isTTY !== true) { + return false; + } + + return true; +} diff --git a/packages/dashmate/src/util/promptOrThrow.js b/packages/dashmate/src/util/promptOrThrow.js new file mode 100644 index 00000000000..c5c1035d2c4 --- /dev/null +++ b/packages/dashmate/src/util/promptOrThrow.js @@ -0,0 +1,29 @@ +import NonInteractivePromptError from './errors/NonInteractivePromptError.js'; + +/** + * Ask the operator a question, or fail loudly when there is nobody to ask. + * + * Every prompt goes through here. Neither listr2 nor enquirer refuses to build + * a prompt on a stream that cannot answer - listr2 has no terminal check at all + * and enquirer's guard does not fire on the default stdin - so a prompt reached + * unattended waits for the writer's lifetime and then leaves the process to + * exit with nothing done. Refusing up front turns that silence into an error + * with a name. + * + * Interactivity must be stated positively. A guard phrased the other way round + * lets any caller that forgets to pass it enable prompting by omission, and one + * of those callers renews certificates unattended inside a container. + * + * @param {Object} task - the listr2 task the prompt is rendered by + * @param {Object} options - enquirer prompt options + * @param {Object} context + * @param {boolean} [context.interactive] + * @return {Promise<*>} + */ +export default function promptOrThrow(task, options, { interactive } = {}) { + if (interactive !== true) { + throw new NonInteractivePromptError(options?.message); + } + + return task.prompt(options); +} diff --git a/packages/dashmate/test/unit/util/isInteractiveSession.spec.js b/packages/dashmate/test/unit/util/isInteractiveSession.spec.js new file mode 100644 index 00000000000..488427638aa --- /dev/null +++ b/packages/dashmate/test/unit/util/isInteractiveSession.spec.js @@ -0,0 +1,113 @@ +import isInteractiveSession from '../../../src/util/isInteractiveSession.js'; + +describe('isInteractiveSession', () => { + const TTY = { isTTY: true }; + // Node reports a stream that is not a terminal as `undefined`, never `false`, + // so every rule has to survive the absent property rather than a boolean. + const NOT_TTY = {}; + + /** + * @param {Object} [overrides] + * @return {boolean} + */ + function detect(overrides = {}) { + return isInteractiveSession({ + flags: {}, + env: {}, + stdin: TTY, + stdout: TTY, + ...overrides, + }); + } + + // The table of environments the detection has to place correctly. A wrong + // answer one way breaks CI and Ansible; a wrong answer the other way hangs an + // unattended upgrade on a node the documented flow has already stopped. + const environments = [ + ['operator at a terminal', { stdin: TTY, stdout: TTY, env: {} }, true], + ['dashmate update > log 2>&1', { stdin: TTY, stdout: NOT_TTY, env: {} }, false], + ['dashmate update | tee log', { stdin: TTY, stdout: NOT_TTY, env: {} }, false], + ['dashmate update < /dev/null', { stdin: NOT_TTY, stdout: TTY, env: {} }, false], + ['cron', { stdin: NOT_TTY, stdout: NOT_TTY, env: {} }, false], + ['systemd with StandardInput=null', { stdin: NOT_TTY, stdout: NOT_TTY, env: {} }, false], + ['Ansible command/shell', { stdin: NOT_TTY, stdout: NOT_TTY, env: {} }, false], + ['Ansible become with a pty', { stdin: TTY, stdout: TTY, env: {} }, true], + ['GitHub Actions', { stdin: NOT_TTY, stdout: NOT_TTY, env: { CI: 'true' } }, false], + ['docker exec', { stdin: NOT_TTY, stdout: NOT_TTY, env: {} }, false], + ['docker exec -it', { stdin: TTY, stdout: TTY, env: {} }, true], + // Resolves interactive with the explanation in a file nobody is watching, + // which is why every prompt header has to carry its own context. + ['dashmate update 2> log', { stdin: TTY, stdout: TTY, env: {} }, true], + ]; + + environments.forEach(([name, streams, expected]) => { + it(`should report ${name} as ${expected ? 'interactive' : 'non-interactive'}`, () => { + expect(detect(streams)).to.equal(expected); + }); + }); + + // An operator who says "never prompt" has to be obeyed even at a terminal: + // it is the only thing that saves a playbook that allocates a pty, which is + // otherwise indistinguishable from a human. + it('should never prompt when the operator asked for it, even at a terminal', () => { + expect(detect({ flags: { 'non-interactive': true } })).to.be.false(); + }); + + // The environment variable exists so automation can be armed before the + // binary that understands the flag is installed. + it('should never prompt when the environment asks for it', () => { + expect(detect({ env: { DASHMATE_NON_INTERACTIVE: '1' } })).to.be.false(); + }); + + it('should ignore the environment variable when it is switched off', () => { + expect(detect({ env: { DASHMATE_NON_INTERACTIVE: '0' } })).to.be.true(); + expect(detect({ env: { DASHMATE_NON_INTERACTIVE: 'false' } })).to.be.true(); + expect(detect({ env: { DASHMATE_NON_INTERACTIVE: '' } })).to.be.true(); + }); + + // Prompt chrome is written to stdout, so prompting under JSON output would + // corrupt the one parseable document the caller asked for. + it('should never prompt when the output is meant for a machine', () => { + expect(detect({ flags: { format: 'json' } })).to.be.false(); + }); + + it('should treat any CI value that is not switched off as a machine', () => { + ['true', 'TRUE', 'True', '1', 'yes'].forEach((value) => { + expect(detect({ env: { CI: value } }), value).to.be.false(); + }); + }); + + // A human debugging on a box that exports CI needs a way back, and this is + // the documented one. + it('should let CI=0 hand the terminal back to a human', () => { + ['0', 'false', 'FALSE', ''].forEach((value) => { + expect(detect({ env: { CI: value } }), value).to.be.true(); + }); + }); + + // The explicit instruction outranks the heuristic, not the other way round. + it('should let an explicit flag outrank CI', () => { + expect(detect({ flags: { 'non-interactive': true }, env: { CI: '0' } })).to.be.false(); + }); + + // A stream that is not a terminal reports `undefined`. Tidying the check into + // `=== false` would classify every pipe and every cron run as interactive. + it('should treat an absent isTTY as not a terminal', () => { + expect(NOT_TTY.isTTY).to.be.undefined(); + expect(detect({ stdin: NOT_TTY })).to.be.false(); + expect(detect({ stdout: NOT_TTY })).to.be.false(); + }); + + // oclif replaces the streams it manages, so a value captured when the module + // loaded describes the wrong process by the time the gate asks. + it('should read the streams at call time', () => { + const stdin = { isTTY: true }; + const stdout = { isTTY: true }; + + expect(isInteractiveSession({ flags: {}, env: {}, stdin, stdout })).to.be.true(); + + stdin.isTTY = undefined; + + expect(isInteractiveSession({ flags: {}, env: {}, stdin, stdout })).to.be.false(); + }); +}); diff --git a/packages/dashmate/test/unit/util/promptOrThrow.spec.js b/packages/dashmate/test/unit/util/promptOrThrow.spec.js new file mode 100644 index 00000000000..e12b5e1f8f2 --- /dev/null +++ b/packages/dashmate/test/unit/util/promptOrThrow.spec.js @@ -0,0 +1,47 @@ +import promptOrThrow from '../../../src/util/promptOrThrow.js'; +import NonInteractivePromptError from '../../../src/util/errors/NonInteractivePromptError.js'; + +describe('promptOrThrow', () => { + it('should ask the operator when a human is there to answer', async function it() { + const task = { prompt: this.sinon.stub().resolves('yes') }; + + const answer = await promptOrThrow(task, { message: 'Continue?' }, { interactive: true }); + + expect(answer).to.equal('yes'); + expect(task.prompt).to.have.been.calledOnceWithExactly({ message: 'Continue?' }); + }); + + // A prompt built on a stream nobody is reading never throws and never + // settles: listr2 has no TTY check and enquirer's guard does not fire on the + // default stdin, so the process drains its event loop and exits 0 with + // nothing done. Refusing to construct the prompt is what turns that silence + // into a reported failure. + it('should refuse to prompt when nobody can answer', function it() { + const task = { prompt: this.sinon.stub() }; + + expect(() => promptOrThrow(task, { message: 'Continue?' }, { interactive: false })) + .to.throw(NonInteractivePromptError); + + expect(task.prompt).to.not.have.been.called(); + }); + + // Prompting has to be opted into positively. A guard phrased as "prompt + // unless told otherwise" lets any caller that forgets the flag - the helper's + // unattended renewal, for one - enable prompting by omission. + it('should refuse to prompt when interactivity was never stated', function it() { + const task = { prompt: this.sinon.stub() }; + + expect(() => promptOrThrow(task, {}, {})).to.throw(NonInteractivePromptError); + expect(() => promptOrThrow(task, {}, { interactive: 'yes' })).to.throw(NonInteractivePromptError); + expect(() => promptOrThrow(task, {}, { interactive: 1 })).to.throw(NonInteractivePromptError); + + expect(task.prompt).to.not.have.been.called(); + }); + + it('should name the question it refused to ask', function it() { + const task = { prompt: this.sinon.stub() }; + + expect(() => promptOrThrow(task, { message: 'Switch to Let\'s Encrypt?' }, {})) + .to.throw('Switch to Let\'s Encrypt?'); + }); +}); From 4a525bd07283564986cb1dd6f2b5fa55e6e12818 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 09:48:52 +0700 Subject: [PATCH 03/63] feat(dashmate): judge the certificate installed for the gateway A live scan of all 353 registered mainnet evonodes found 88 serving an expired certificate and 6 serving no TLS at all - 26.6% of the network unreachable to a standards-compliant client, with no economic pressure correcting it because PoSe probes Core and Tenderdash p2p, not port 443. Nothing in dashmate looks at a certificate today. checkGatewayCertificate is the verdict function that will back that check. It derives its answer from the bundle the gateway actually loads rather than from the configured provider: the provider-derived design would call ZeroSSL's REST API, which fails for exactly the free-tier operators this exists for, and would miss a self-signed bundle installed under any other provider. The leaf is identified by matching the installed private key's SPKI against each block. That single rule does three jobs - it finds the leaf whichever way round the bundle is written, it *is* the key-pairing check, and comparing key material rather than running an RSA-only signature test works for any key type. Only that leaf is self-sign tested, via leaf.verify(leaf.publicKey): testing every block would reject any chain carrying its own root, which every ordinary public chain does, and subject-equals-issuer is a naming convention with false answers in both directions. The status is CHECKS_PASSED, not VALID, and the name is load-bearing. Nothing here validates the chain to a public root, checks revocation or opens a connection, so nothing may call the result valid, trusted, usable or reachable. Blocking: BUNDLE_MISSING, BUNDLE_UNREADABLE, KEY_MISSING, KEY_UNUSABLE, KEY_MISMATCH, EXPIRED, SELF_SIGNED, IP_MISMATCH, SWITCH_INCOMPLETE, SSL_DISABLED. Two of those deserve a note. An unloadable key blocks rather than warns because the gateway template passes the key file with no passphrase field anywhere, so a key dashmate cannot load is a key Envoy cannot load - warning would pass a node that is already dark; the encrypted case is detected from the PEM rather than by asking OpenSSL, which can go looking for a terminal to prompt on. SWITCH_INCOMPLETE - an installed pair byte-identical to the lego pair while the configuration still names another provider - blocks because it is the state a kill between installing the pair and writing the provider leaves behind, and as a warning it never repairs itself: the helper keeps renewing the old provider while the installed six-day certificate runs out. SELF_SIGNED blocks only on a registered masternode. Dashmate's own setup wizard offers self-signed to a mainnet evolution fullnode, so blocking it unconditionally would break update for a configuration dashmate created; the warning still says self-signed TLS is not publicly trusted. parseIpAddresses is exported from readCertificateBundle rather than duplicated. readCertificateBundle itself cannot be reused for selection: it takes the first non-CA block, which is the wrong leaf in a root-first bundle and no leaf at all for an operator's CA:TRUE self-signed cert. Tests: 25 new, all red before this commit (module absent), green after. They pin the two defects an earlier design carried - a root-first bundle and a valid three-certificate chain both had to pass, and an operator's CA:TRUE self-signed certificate had to read as SELF_SIGNED rather than BUNDLE_UNREADABLE - plus Ed25519 pairing, an encrypted key, EACCES wording that never says "expired", and the one-day expiry threshold chosen so it cannot fire inside the window the helper's own renewal clears. Co-Authored-By: Claude Opus 5 --- .../src/ssl/checkGatewayCertificateFactory.js | 401 +++++++++++++++++ .../dashmate/src/ssl/readCertificateBundle.js | 2 +- .../dashmate/src/test/certificateFixtures.js | 198 +++++++++ .../checkGatewayCertificateFactory.spec.js | 407 ++++++++++++++++++ 4 files changed, 1007 insertions(+), 1 deletion(-) create mode 100644 packages/dashmate/src/ssl/checkGatewayCertificateFactory.js create mode 100644 packages/dashmate/src/test/certificateFixtures.js create mode 100644 packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js diff --git a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js new file mode 100644 index 00000000000..5a9065bd23a --- /dev/null +++ b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js @@ -0,0 +1,401 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { SSL_PROVIDERS } from '../constants.js'; +import { parseIpAddresses } from './readCertificateBundle.js'; +import isCertificatePairInstalled from './letsencrypt/isCertificatePairInstalled.js'; + +export const CERTIFICATE_STATUS = { + // Deliberately not VALID. This function runs a fixed list of local checks; it + // does not validate the chain to a public root, does not check revocation and + // never opens a connection, so it cannot establish that a certificate is + // trusted, usable, or that the node is reachable. Passing means exactly that + // the checks below found no problem. + CHECKS_PASSED: 'CHECKS_PASSED', + WARN: 'WARN', + INVALID: 'INVALID', +}; + +export const CERTIFICATE_REASONS = { + BUNDLE_MISSING: 'BUNDLE_MISSING', + BUNDLE_UNREADABLE: 'BUNDLE_UNREADABLE', + KEY_MISSING: 'KEY_MISSING', + KEY_UNUSABLE: 'KEY_UNUSABLE', + KEY_MISMATCH: 'KEY_MISMATCH', + EXPIRED: 'EXPIRED', + EXPIRING_SOON: 'EXPIRING_SOON', + SELF_SIGNED: 'SELF_SIGNED', + IP_MISMATCH: 'IP_MISMATCH', + SWITCH_INCOMPLETE: 'SWITCH_INCOMPLETE', + PROVIDER_MISMATCH: 'PROVIDER_MISMATCH', + SSL_UNMANAGED: 'SSL_UNMANAGED', + SSL_DISABLED: 'SSL_DISABLED', +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * A certificate this close to expiry has already passed the point where the + * helper renews it, so anything further out is a window renewal clears by + * itself. + */ +const EXPIRING_SOON_DAYS = 1; + +const PEM_CERTIFICATE = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; + +/** + * A key protected by a passphrase is detected from the PEM rather than by + * asking OpenSSL, which can go looking for a terminal to ask on. + */ +const ENCRYPTED_KEY = /-----BEGIN ENCRYPTED PRIVATE KEY-----|^\s*Proc-Type:\s*4,ENCRYPTED/m; + +/** + * @param {string} distinguishedName - as rendered by X509Certificate + * @return {string|undefined} + */ +function commonNameOf(distinguishedName) { + const line = (distinguishedName ?? '') + .split('\n') + .map((entry) => entry.trim()) + .find((entry) => entry.startsWith('CN=')); + + return line?.slice('CN='.length); +} + +/** + * Which provider the issuer of an installed leaf points at. + * + * Only issuers dashmate can obtain from are recognised. An unrecognised issuer + * returns null rather than a guess, so a certificate from a paid CA is never + * reported as disagreeing with anything. + * + * @param {crypto.X509Certificate} leaf + * @param {boolean} isSelfSigned + * @return {string|null} + */ +function identifyIssuer(leaf, isSelfSigned) { + if (isSelfSigned) { + return SSL_PROVIDERS.SELF_SIGNED; + } + + const issuer = (leaf.issuer ?? '').toLowerCase(); + + if (issuer.includes("let's encrypt") || issuer.includes('letsencrypt') || issuer.includes('isrg')) { + return SSL_PROVIDERS.LETSENCRYPT; + } + + if (issuer.includes('zerossl') || issuer.includes('sectigo')) { + return SSL_PROVIDERS.ZEROSSL; + } + + return null; +} + +/** + * @param {crypto.KeyObject|crypto.X509Certificate['publicKey']} publicKey + * @return {Buffer|null} + */ +function exportSubjectPublicKeyInfo(publicKey) { + try { + return publicKey.export({ type: 'spki', format: 'der' }); + } catch { + return null; + } +} + +/** + * @param {HomeDir} homeDir + * @return {checkGatewayCertificate} + */ +export default function checkGatewayCertificateFactory(homeDir) { + /** + * Judge the certificate bundle installed for the gateway. + * + * The verdict is derived from the files the gateway loads rather than from + * the configured provider, so it is provider-independent and works offline. + * Switching on the provider instead would mean calling ZeroSSL's REST API - + * which fails for exactly the free-tier operators this check exists for - and + * would miss a self-signed bundle installed under any other provider. + * + * No side effects, no prompts, no network. + * + * @typedef {checkGatewayCertificate} + * @param {Config} config + * @return {{status: string, reasons: Object[], warnings: Object[], skipped: string[], + * provider: string, installed: Object|null, expiresInDays: number|null, + * bundleFilePath: string, privateKeyFilePath: string}} + */ + function checkGatewayCertificate(config) { + const provider = config.get('platform.gateway.ssl.provider'); + const externalIp = config.get('externalIp'); + + const sslDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'ssl'); + const bundleFilePath = path.join(sslDir, 'bundle.crt'); + const privateKeyFilePath = path.join(sslDir, 'private.key'); + + const reasons = []; + const warnings = []; + const skipped = []; + + /** + * @param {Object|null} installed + * @param {number|null} expiresInDays + * @return {Object} + */ + const verdict = (installed = null, expiresInDays = null) => { + // A certificate the checks cleared is not a certificate dashmate is + // managing, so an unmanaged node is reported either way - as a warning on + // its own, and as part of the failure when something else is wrong too. + if (config.get('platform.gateway.ssl.enabled') === false) { + (reasons.length > 0 ? reasons : warnings).push(reasons.length > 0 + ? { + code: CERTIFICATE_REASONS.SSL_DISABLED, + message: 'Dashmate is not managing this certificate, so nothing will renew it', + } + : { + code: CERTIFICATE_REASONS.SSL_UNMANAGED, + message: 'Dashmate is not managing this certificate. It will not be renewed automatically', + }); + } + + let status = CERTIFICATE_STATUS.CHECKS_PASSED; + + if (reasons.length > 0) { + status = CERTIFICATE_STATUS.INVALID; + } else if (warnings.length > 0) { + status = CERTIFICATE_STATUS.WARN; + } + + return { + status, + reasons, + warnings, + skipped, + provider, + installed, + expiresInDays, + bundleFilePath, + privateKeyFilePath, + }; + }; + + let bundlePem; + try { + bundlePem = fs.readFileSync(bundleFilePath, 'utf8'); + } catch (e) { + reasons.push(e.code === 'ENOENT' + ? { + code: CERTIFICATE_REASONS.BUNDLE_MISSING, + message: `dashmate could not find the certificate bundle at ${bundleFilePath}`, + } + : { + code: CERTIFICATE_REASONS.BUNDLE_UNREADABLE, + message: `dashmate could not read the certificate bundle at ${bundleFilePath}: ${e.message}`, + }); + } + + let privateKeyPem; + try { + privateKeyPem = fs.readFileSync(privateKeyFilePath, 'utf8'); + } catch (e) { + reasons.push(e.code === 'ENOENT' + ? { + code: CERTIFICATE_REASONS.KEY_MISSING, + message: `dashmate could not find the private key at ${privateKeyFilePath}`, + } + : { + code: CERTIFICATE_REASONS.KEY_UNUSABLE, + message: `dashmate could not read the private key at ${privateKeyFilePath}: ${e.message}`, + }); + } + + if (bundlePem === undefined || privateKeyPem === undefined) { + return verdict(); + } + + // The gateway is handed this file with no password or passphrase field + // anywhere in its configuration, so a key dashmate cannot load is a key the + // gateway cannot load either and the node serves no TLS at all. Warning + // here would pass a node that is already dark. + let subjectPublicKeyInfo = null; + if (ENCRYPTED_KEY.test(privateKeyPem)) { + reasons.push({ + code: CERTIFICATE_REASONS.KEY_UNUSABLE, + message: `dashmate could not read the private key at ${privateKeyFilePath}:` + + ' it is protected by a passphrase, and the gateway has no way to be given one', + }); + } else { + try { + subjectPublicKeyInfo = exportSubjectPublicKeyInfo( + crypto.createPublicKey(crypto.createPrivateKey(privateKeyPem)), + ); + } catch (e) { + reasons.push({ + code: CERTIFICATE_REASONS.KEY_UNUSABLE, + message: `dashmate could not read the private key at ${privateKeyFilePath}: ${e.message}`, + }); + } + + if (subjectPublicKeyInfo === null && reasons.length === 0) { + reasons.push({ + code: CERTIFICATE_REASONS.KEY_UNUSABLE, + message: `dashmate could not read the private key at ${privateKeyFilePath}`, + }); + } + } + + if (subjectPublicKeyInfo === null) { + return verdict(); + } + + const certificates = (bundlePem.match(PEM_CERTIFICATE) ?? []) + .map((block) => { + try { + return new crypto.X509Certificate(block); + } catch { + // A block that will not parse is skipped rather than failing the + // whole bundle, which may hold comments or a stray key. + return null; + } + }) + .filter(Boolean); + + if (certificates.length === 0) { + reasons.push({ + code: CERTIFICATE_REASONS.BUNDLE_UNREADABLE, + message: `dashmate could not read any certificate from ${bundleFilePath}`, + }); + + return verdict(); + } + + // The leaf is the block whose public key belongs to the installed private + // key. Selecting by position gets one bundle order wrong, and self-sign + // testing every block rejects any chain carrying its root - which an + // ordinary public chain does. + const leaf = certificates.find((certificate) => { + const spki = exportSubjectPublicKeyInfo(certificate.publicKey); + + return spki !== null && spki.equals(subjectPublicKeyInfo); + }); + + if (!leaf) { + reasons.push({ + code: CERTIFICATE_REASONS.KEY_MISMATCH, + message: `No certificate in ${bundleFilePath} belongs to the private key` + + ` at ${privateKeyFilePath}`, + }); + + return verdict(); + } + + // A certificate that verifies under its own public key is self-signed by + // definition. Subject equal to issuer is only a naming convention: a + // self-signed certificate may name any issuer it likes, and a private-CA + // certificate can render the two identically. + let isSelfSigned = false; + try { + isSelfSigned = leaf.verify(leaf.publicKey); + } catch { + isSelfSigned = false; + } + + const validTo = new Date(leaf.validTo); + const expiresInDays = (validTo.getTime() - Date.now()) / DAY_MS; + + const installed = { + subject: leaf.subject, + issuer: leaf.issuer, + validFrom: new Date(leaf.validFrom), + validTo, + ipAddresses: parseIpAddresses(leaf.subjectAltName), + fingerprint256: leaf.fingerprint256, + selfSigned: isSelfSigned, + }; + + if (isSelfSigned) { + const message = 'The installed certificate is self-signed. Self-signed TLS is not' + + ' publicly trusted and standards-compliant clients will reject it'; + + // Dashmate's own setup wizard offers self-signed to a mainnet evolution + // fullnode, so blocking it unconditionally would break update for a + // configuration dashmate created. Enforcement is scoped to registered + // masternodes, which is the population the wizard's own rule scopes to. + const isEnforced = config.get('core.masternode.enable') === true; + + (isEnforced ? reasons : warnings).push({ + code: CERTIFICATE_REASONS.SELF_SIGNED, + message, + }); + } + + if (expiresInDays <= 0) { + reasons.push({ + code: CERTIFICATE_REASONS.EXPIRED, + message: `The installed certificate expired on ${validTo.toISOString().slice(0, 10)}` + + ` - ${Math.floor(-expiresInDays)} days ago`, + }); + } else if (expiresInDays < EXPIRING_SOON_DAYS) { + warnings.push({ + code: CERTIFICATE_REASONS.EXPIRING_SOON, + message: `The installed certificate expires on ${validTo.toISOString().slice(0, 10)}` + + ` - in less than ${EXPIRING_SOON_DAYS} day`, + }); + } + + if (!externalIp) { + skipped.push('IDENTITY'); + } else { + // Dashmate identifies a node by its address, and lego passes --disable-cn + // for an IP certificate, so the address is normally only in the SAN. The + // common name is the fallback for a certificate issued without one. + const namesExternalIp = installed.ipAddresses.length > 0 + ? installed.ipAddresses.includes(externalIp) + : commonNameOf(leaf.subject) === externalIp; + + if (!namesExternalIp) { + reasons.push({ + code: CERTIFICATE_REASONS.IP_MISMATCH, + message: `The installed certificate does not name this node's address ${externalIp}`, + }); + } + } + + const legoDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'lego'); + const isLegoPairInstalled = Boolean(externalIp) && isCertificatePairInstalled( + path.join(legoDir, 'certificates', `${externalIp}.crt`), + path.join(legoDir, 'certificates', `${externalIp}.key`), + bundleFilePath, + privateKeyFilePath, + ); + + if (isLegoPairInstalled && provider !== SSL_PROVIDERS.LETSENCRYPT) { + // The pair is installed before the provider is written, so a kill between + // the two leaves exactly this. Left as a warning the helper keeps + // renewing the old provider while the installed certificate runs out and + // the state never repairs itself. + reasons.push({ + code: CERTIFICATE_REASONS.SWITCH_INCOMPLETE, + message: "A Let's Encrypt certificate is installed for the gateway, but the" + + ` configuration still names ${provider}. A switch was interrupted before it finished`, + }); + } else if (provider !== SSL_PROVIDERS.FILE) { + // A certificate the operator supplied themselves can come from any + // authority, so its issuer says nothing about the configuration. + const issuedBy = identifyIssuer(leaf, isSelfSigned); + + if (issuedBy !== null && issuedBy !== provider) { + warnings.push({ + code: CERTIFICATE_REASONS.PROVIDER_MISMATCH, + message: `The installed certificate was issued by ${issuedBy}, but the configuration` + + ` names ${provider}`, + }); + } + } + + return verdict(installed, expiresInDays); + } + + return checkGatewayCertificate; +} diff --git a/packages/dashmate/src/ssl/readCertificateBundle.js b/packages/dashmate/src/ssl/readCertificateBundle.js index 649cf7e9a94..e42a8770e10 100644 --- a/packages/dashmate/src/ssl/readCertificateBundle.js +++ b/packages/dashmate/src/ssl/readCertificateBundle.js @@ -11,7 +11,7 @@ import fs from 'node:fs'; * @param {string|undefined} subjectAltName * @return {string[]} */ -function parseIpAddresses(subjectAltName) { +export function parseIpAddresses(subjectAltName) { if (!subjectAltName) { return []; } diff --git a/packages/dashmate/src/test/certificateFixtures.js b/packages/dashmate/src/test/certificateFixtures.js new file mode 100644 index 00000000000..9afc678eda5 --- /dev/null +++ b/packages/dashmate/src/test/certificateFixtures.js @@ -0,0 +1,198 @@ +import crypto from 'node:crypto'; +import forge from 'node-forge'; + +/** + * Build the certificate shapes the gateway certificate checks have to tell apart. + * + * Certificates are generated when a test runs rather than committed, so a + * fixture cannot expire and fail the suite on a date nobody chose. node-forge + * rather than the openssl binary because placing a certificate in the past + * needs flags that arrived in OpenSSL 3.5, which is newer than the CI image. + */ + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * @param {Object} name + * @return {Object[]} node-forge subject attributes + */ +function toAttributes({ commonName, organizationName } = {}) { + const attributes = []; + + if (organizationName !== undefined) { + attributes.push({ name: 'organizationName', value: organizationName }); + } + + if (commonName !== undefined) { + attributes.push({ name: 'commonName', value: commonName }); + } + + return attributes; +} + +/** + * @param {Buffer} der + * @return {string} + */ +function toPem(der) { + const body = der.toString('base64').match(/.{1,64}/g).join('\n'); + + return `-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`; +} + +/** + * Issue a certificate, optionally signed by another one. + * + * @param {Object} [options] + * @param {Object} [options.subject] - {commonName, organizationName}, both optional + * @param {Object} [options.issuer] - the issuing authority, self-signed when absent + * @param {string} [options.ip] - placed in the subject alternative name + * @param {number} [options.days] - days from now it expires, negative for expired + * @param {boolean} [options.ca] + * @param {Object} [options.keys] - reuse an existing node-forge key pair + * @return {{pem: string, keyPem: string, keys: Object, certificate: Object, + * subject: Object}} + */ +export function issueCertificate({ + subject = { commonName: '1.2.3.4' }, + issuer, + ip, + days = 30, + ca = false, + 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 * DAY_MS); + certificate.validity.notBefore = new Date( + certificate.validity.notAfter.getTime() - 90 * DAY_MS, + ); + + certificate.setSubject(toAttributes(subject)); + certificate.setIssuer(toAttributes(issuer ? issuer.subject : subject)); + + const extensions = [{ name: 'basicConstraints', cA: ca }]; + + if (ip) { + // Type 7 is an IP address. An evonode is identified by its address. + extensions.push({ name: 'subjectAltName', altNames: [{ type: 7, ip }] }); + } + + certificate.setExtensions(extensions); + + certificate.sign( + issuer ? issuer.keys.privateKey : keys.privateKey, + forge.md.sha256.create(), + ); + + return { + pem: forge.pki.certificateToPem(certificate), + keyPem: forge.pki.privateKeyToPem(keys.privateKey), + keys, + certificate, + subject, + }; +} + +/** + * A leaf, the intermediate that signed it and the self-signed root above that - + * the ordinary shape of a publicly trusted bundle. + * + * @param {Object} [options] + * @param {string} [options.ip] + * @param {number} [options.days] + * @param {string} [options.organizationName] - the issuing CA's organisation + * @return {{leaf: Object, intermediate: Object, root: Object}} + */ +export function issueChain({ + ip = '1.2.3.4', + days = 6, + organizationName = "Let's Encrypt", +} = {}) { + const root = issueCertificate({ + subject: { organizationName, commonName: 'Test Root X1' }, + days: 3650, + ca: true, + }); + + const intermediate = issueCertificate({ + subject: { organizationName, commonName: 'R11' }, + issuer: root, + days: 1800, + ca: true, + }); + + // lego passes --disable-cn for an IP certificate, so the address is only ever + // in the subject alternative name. + const leaf = issueCertificate({ + subject: {}, + issuer: intermediate, + ip, + days, + }); + + return { leaf, intermediate, root }; +} + +/** + * Replace a certificate's public key with an Ed25519 one and re-sign it. + * + * node-forge cannot build a certificate around a key type it does not + * implement, but the certificate only has to carry the key - the signature over + * it is still the issuer's RSA one, which is a legitimate combination. This is + * what proves the leaf is selected by comparing key material rather than by an + * RSA-only signature test. + * + * @param {Object} issuer - the authority whose key signs the result + * @param {Object} [options] + * @param {string} [options.ip] + * @param {number} [options.days] + * @return {{pem: string, keyPem: string}} + */ +export function issueEd25519Certificate(issuer, { ip = '1.2.3.4', days = 6 } = {}) { + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519'); + const spki = publicKey.export({ type: 'spki', format: 'der' }); + + const template = issueCertificate({ + subject: {}, issuer, ip, days, + }); + + const asn1 = forge.pki.certificateToAsn1(template.certificate); + const tbsCertificate = asn1.value[0]; + + // TBSCertificate ::= SEQUENCE { version [0], serialNumber, signature, + // issuer, validity, subject, subjectPublicKeyInfo, ... } + tbsCertificate.value[6] = forge.asn1.fromDer( + forge.util.createBuffer(Buffer.from(spki).toString('binary')), + ); + + const tbsDer = Buffer.from(forge.asn1.toDer(tbsCertificate).getBytes(), 'binary'); + const digest = forge.md.sha256.create(); + digest.update(tbsDer.toString('binary')); + + asn1.value[2] = forge.asn1.create( + forge.asn1.Class.UNIVERSAL, + forge.asn1.Type.BITSTRING, + false, + String.fromCharCode(0) + issuer.keys.privateKey.sign(digest), + ); + + return { + pem: toPem(Buffer.from(forge.asn1.toDer(asn1).getBytes(), 'binary')), + keyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }), + }; +} + +/** + * @param {Object} keys - a node-forge key pair + * @param {string} passphrase + * @return {string} PEM + */ +export function encryptPrivateKey(keys, passphrase) { + return forge.pki.encryptRsaPrivateKey(keys.privateKey, passphrase); +} diff --git a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js new file mode 100644 index 00000000000..5a9749c485c --- /dev/null +++ b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js @@ -0,0 +1,407 @@ +import fs from 'fs'; +import path from 'path'; +import HomeDir from '../../../src/config/HomeDir.js'; +import getBaseConfigFactory from '../../../configs/defaults/getBaseConfigFactory.js'; +import checkGatewayCertificateFactory, { + CERTIFICATE_REASONS, + CERTIFICATE_STATUS, +} from '../../../src/ssl/checkGatewayCertificateFactory.js'; +import { + encryptPrivateKey, + issueCertificate, + issueChain, + issueEd25519Certificate, +} from '../../../src/test/certificateFixtures.js'; + +const EXTERNAL_IP = '1.2.3.4'; + +describe('checkGatewayCertificateFactory', () => { + let homeDir; + let config; + let sslDir; + let legoDir; + let checkGatewayCertificate; + + beforeEach(() => { + homeDir = HomeDir.createTemp(); + config = getBaseConfigFactory(homeDir)(); + config.set('externalIp', EXTERNAL_IP); + config.set('platform.gateway.ssl.enabled', true); + config.set('platform.gateway.ssl.provider', 'letsencrypt'); + config.set('core.masternode.enable', true); + + sslDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'ssl'); + legoDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'lego'); + fs.mkdirSync(sslDir, { recursive: true }); + fs.mkdirSync(path.join(legoDir, 'certificates'), { recursive: true }); + + checkGatewayCertificate = checkGatewayCertificateFactory(homeDir); + }); + + afterEach(() => homeDir.remove()); + + /** + * @param {string} bundle + * @param {string} [key] + */ + function install(bundle, key) { + if (bundle !== undefined) { + fs.writeFileSync(path.join(sslDir, 'bundle.crt'), bundle, 'utf8'); + } + + if (key !== undefined) { + fs.writeFileSync(path.join(sslDir, 'private.key'), key, { encoding: 'utf8', mode: 0o600 }); + } + } + + /** + * @param {Object} verdict + * @return {string[]} + */ + const codes = (list) => list.map(({ code }) => code); + + describe('leaf identification', () => { + // A bundle can be written either way round, and an operator supplying their + // own routinely writes the root first. Selecting the leaf by position gets + // one of the two orders wrong every time. + ['leaf-first', 'root-first'].forEach((order) => { + it(`should identify the leaf by its key in a ${order} bundle`, () => { + const { leaf, intermediate, root } = issueChain({ ip: EXTERNAL_IP }); + const blocks = [leaf.pem, intermediate.pem, root.pem]; + + install((order === 'leaf-first' ? blocks : [...blocks].reverse()).join(''), leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.CHECKS_PASSED); + expect(verdict.installed.fingerprint256).to.be.a('string'); + }); + + // An ordinary public chain contains a self-signed root. Testing every + // block for self-signature rejects every valid paid chain outright. + it(`should accept a chain containing a self-signed root, ${order}`, () => { + const { leaf, intermediate, root } = issueChain({ ip: EXTERNAL_IP }); + const blocks = [leaf.pem, intermediate.pem, root.pem]; + + install((order === 'leaf-first' ? blocks : [...blocks].reverse()).join(''), leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(codes(verdict.reasons)).to.deep.equal([]); + expect(verdict.status).to.equal(CERTIFICATE_STATUS.CHECKS_PASSED); + }); + }); + + // An operator's own self-signed certificate is usually marked as a CA. + // Skipping CA blocks while looking for the leaf leaves nothing to judge, + // and the node would be reported as having an unreadable bundle rather + // than an untrusted one. + it('should recognise a self-signed CA leaf as self-signed, not unreadable', () => { + const selfSigned = issueCertificate({ + subject: { commonName: EXTERNAL_IP }, ip: EXTERNAL_IP, ca: true, + }); + + install(selfSigned.pem, selfSigned.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(codes(verdict.reasons)).to.include(CERTIFICATE_REASONS.SELF_SIGNED); + expect(codes(verdict.reasons)).to.not.include(CERTIFICATE_REASONS.BUNDLE_UNREADABLE); + }); + + // lego passes --disable-cn for an IP certificate, so the leaf carries no + // common name at all. Comparing subject to issuer would call that a match. + it('should not call an empty-subject leaf self-signed', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + + install(leaf.pem + intermediate.pem, leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(codes(verdict.reasons)).to.not.include(CERTIFICATE_REASONS.SELF_SIGNED); + expect(verdict.installed.selfSigned).to.be.false(); + }); + + // Comparing key material rather than running an RSA-only signature test is + // what makes the check work for every key type a CA might issue. + it('should pair an Ed25519 leaf with its key', () => { + const { intermediate, root } = issueChain({ ip: EXTERNAL_IP }); + const leaf = issueEd25519Certificate(intermediate, { ip: EXTERNAL_IP }); + + install(leaf.pem + intermediate.pem + root.pem, leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(codes(verdict.reasons)).to.deep.equal([]); + expect(verdict.status).to.equal(CERTIFICATE_STATUS.CHECKS_PASSED); + }); + + it('should report a key that matches no block in the bundle', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + const other = issueCertificate({ ip: EXTERNAL_IP }); + + install(leaf.pem + intermediate.pem, other.keyPem); + + expect(codes(checkGatewayCertificate(config).reasons)) + .to.deep.equal([CERTIFICATE_REASONS.KEY_MISMATCH]); + }); + + it('should report a missing bundle and a missing key separately', () => { + const { leaf } = issueChain({ ip: EXTERNAL_IP }); + + install(undefined, leaf.keyPem); + expect(codes(checkGatewayCertificate(config).reasons)) + .to.deep.equal([CERTIFICATE_REASONS.BUNDLE_MISSING]); + + fs.rmSync(path.join(sslDir, 'private.key')); + install(leaf.pem, undefined); + expect(codes(checkGatewayCertificate(config).reasons)) + .to.deep.equal([CERTIFICATE_REASONS.KEY_MISSING]); + }); + + it('should report a bundle that holds no certificate', () => { + const { leaf } = issueChain({ ip: EXTERNAL_IP }); + + install('-----BEGIN CERTIFICATE-----\nnot base64 at all\n-----END CERTIFICATE-----\n', leaf.keyPem); + + expect(codes(checkGatewayCertificate(config).reasons)) + .to.deep.equal([CERTIFICATE_REASONS.BUNDLE_UNREADABLE]); + }); + }); + + describe('unusable private key', () => { + // The gateway is handed the key file with no password or passphrase field + // anywhere in its configuration, so a key dashmate cannot load is a key + // Envoy cannot load either. Warning here would pass a node that serves no + // TLS at all. + it('should block on an encrypted key', () => { + const certificate = issueCertificate({ ip: EXTERNAL_IP }); + + install(certificate.pem, encryptPrivateKey(certificate.keys, 'passphrase')); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.deep.equal([CERTIFICATE_REASONS.KEY_UNUSABLE]); + }); + + // An operator whose problem is a permission bit must not be told their + // certificate expired. + it('should block on an unreadable key and say so without mentioning expiry', function it() { + const certificate = issueCertificate({ ip: EXTERNAL_IP }); + + install(certificate.pem, certificate.keyPem); + + const denied = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const readFileSync = this.sinon.stub(fs, 'readFileSync'); + readFileSync.callThrough(); + readFileSync.withArgs(path.join(sslDir, 'private.key')).throws(denied); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.deep.equal([CERTIFICATE_REASONS.KEY_UNUSABLE]); + expect(verdict.reasons[0].message).to.contain('could not read'); + expect(verdict.reasons[0].message).to.contain(path.join(sslDir, 'private.key')); + expect(verdict.reasons[0].message).to.not.contain('expired'); + }); + }); + + describe('expiry', () => { + it('should block on an expired leaf', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP, days: -3 }); + + install(leaf.pem + intermediate.pem, leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.deep.equal([CERTIFICATE_REASONS.EXPIRED]); + expect(verdict.expiresInDays).to.be.below(0); + }); + + // Renewal runs at two days remaining, so a two-day threshold would fire + // through the whole window where renewal is routine and self-clearing. + it('should warn only inside the last day', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + install(leaf.pem + intermediate.pem, leaf.keyPem); + + expect(codes(checkGatewayCertificate(config).warnings)) + .to.not.include(CERTIFICATE_REASONS.EXPIRING_SOON); + + const soon = issueChain({ ip: EXTERNAL_IP, days: 0.5 }); + install(soon.leaf.pem + soon.intermediate.pem, soon.leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + expect(verdict.status).to.equal(CERTIFICATE_STATUS.WARN); + expect(codes(verdict.warnings)).to.deep.equal([CERTIFICATE_REASONS.EXPIRING_SOON]); + }); + }); + + describe('identity', () => { + // A current, key-matched certificate naming the wrong address is rejected + // by every standards-compliant client, so passing it would report no + // problem on a node that is dark to the network. + it('should block on a leaf that names another address', () => { + const { leaf, intermediate } = issueChain({ ip: '9.9.9.9' }); + + install(leaf.pem + intermediate.pem, leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.deep.equal([CERTIFICATE_REASONS.IP_MISMATCH]); + }); + + it('should record the identity check as skipped when no address is configured', () => { + const { leaf, intermediate } = issueChain({ ip: '9.9.9.9' }); + + config.set('externalIp', null); + install(leaf.pem + intermediate.pem, leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.skipped).to.deep.equal(['IDENTITY']); + expect(codes(verdict.reasons)).to.not.include(CERTIFICATE_REASONS.IP_MISMATCH); + }); + + it('should fall back to the common name when the leaf carries no IP SAN', () => { + const certificate = issueCertificate({ subject: { commonName: EXTERNAL_IP } }); + + install(certificate.pem, certificate.keyPem); + + expect(codes(checkGatewayCertificate(config).reasons)) + .to.not.include(CERTIFICATE_REASONS.IP_MISMATCH); + }); + }); + + describe('provider agreement', () => { + /** + * @param {Object} pair + */ + function installAsLegoPair(pair) { + install(pair.pem, pair.keyPem); + fs.writeFileSync(path.join(legoDir, 'certificates', `${EXTERNAL_IP}.crt`), pair.pem); + fs.writeFileSync(path.join(legoDir, 'certificates', `${EXTERNAL_IP}.key`), pair.keyPem); + } + + // A kill between installing the pair and writing the provider leaves this + // behind. Warning about it lets the helper keep renewing the old provider + // while the installed six-day certificate runs out, so it never repairs + // itself - blocking is what makes the next run converge. + it('should block when the installed pair is the lego pair but the provider is not', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + + config.set('platform.gateway.ssl.provider', 'zerossl'); + installAsLegoPair({ pem: leaf.pem + intermediate.pem, keyPem: leaf.keyPem }); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.deep.equal([CERTIFICATE_REASONS.SWITCH_INCOMPLETE]); + }); + + it('should only warn when the issuer disagrees and the pair is not the lego pair', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + + config.set('platform.gateway.ssl.provider', 'zerossl'); + install(leaf.pem + intermediate.pem, leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.WARN); + expect(codes(verdict.warnings)).to.deep.equal([CERTIFICATE_REASONS.PROVIDER_MISMATCH]); + }); + + it('should not judge the issuer of a certificate the operator supplied', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + + config.set('platform.gateway.ssl.provider', 'file'); + install(leaf.pem + intermediate.pem, leaf.keyPem); + + expect(codes(checkGatewayCertificate(config).warnings)) + .to.not.include(CERTIFICATE_REASONS.PROVIDER_MISMATCH); + }); + }); + + describe('self-signed enforcement', () => { + // Dashmate's own setup wizard offers self-signed to a mainnet evolution + // fullnode, so blocking it unconditionally would break update for a + // configuration dashmate created. + it('should warn rather than block on a node that is not a masternode', () => { + const certificate = issueCertificate({ ip: EXTERNAL_IP }); + + config.set('core.masternode.enable', false); + config.set('platform.gateway.ssl.provider', 'self-signed'); + install(certificate.pem, certificate.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.WARN); + expect(codes(verdict.warnings)).to.deep.equal([CERTIFICATE_REASONS.SELF_SIGNED]); + expect(verdict.warnings[0].message).to.contain('not publicly trusted'); + }); + + it('should block on a registered masternode', () => { + const certificate = issueCertificate({ ip: EXTERNAL_IP }); + + config.set('platform.gateway.ssl.provider', 'self-signed'); + install(certificate.pem, certificate.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.deep.equal([CERTIFICATE_REASONS.SELF_SIGNED]); + }); + }); + + describe('unmanaged SSL', () => { + // The flag appears in no template - the gateway terminates TLS from the + // bundle unconditionally - so on its own it means "dashmate is not managing + // renewal", not "this node serves plaintext". + it('should warn when the checks pass but dashmate is not managing renewal', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + + config.set('platform.gateway.ssl.enabled', false); + install(leaf.pem + intermediate.pem, leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.WARN); + expect(codes(verdict.warnings)).to.deep.equal([CERTIFICATE_REASONS.SSL_UNMANAGED]); + }); + + it('should block when nothing is managing renewal and the certificate is broken', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP, days: -3 }); + + config.set('platform.gateway.ssl.enabled', false); + install(leaf.pem + intermediate.pem, leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.deep.equal([ + CERTIFICATE_REASONS.EXPIRED, + CERTIFICATE_REASONS.SSL_DISABLED, + ]); + }); + }); + + // The check reads local files. It does not validate the chain to a public + // root, does not check revocation and never opens a connection, so nothing + // it returns may be called valid, trusted, usable or reachable. + it('should never report a certificate as valid', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + + install(leaf.pem + intermediate.pem, leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal('CHECKS_PASSED'); + expect(Object.values(CERTIFICATE_STATUS)).to.not.include('VALID'); + expect(verdict.reasons).to.be.an('array'); + expect(verdict.warnings).to.be.an('array'); + expect(verdict.skipped).to.be.an('array'); + expect(verdict.provider).to.equal('letsencrypt'); + }); +}); From 9b2b71012a8b804c15d00f27925cea3ea5309d79 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 10:06:28 +0700 Subject: [PATCH 04/63] feat(dashmate): stop asking for an email, retry port 80, never prompt unattended Three changes to the certificate obtain path that the update gate needs in place before it can drive it. REMOVE THE LET'S ENCRYPT EMAIL. Nothing prompts for a contact address any more, and four hard throws that required one are gone: the obtain task's init guard, its EMAIL_IS_NOT_SET re-throw, the validator's early return (which fired ahead of every other check, so a node without an address reported it whatever else was wrong), and doctor's HIGH-severity problem - downgraded to LOW rather than deleted, so the information stays available and the check is still there if contactless issuance ever stops working. An address is optional under RFC 8555, Let's Encrypt ended expiry notifications in June 2025 and does not store an address supplied through ACME, and lego does not require --email. The field itself stays, and no migration touches it. lego keys its on-disk ACME account directory by the address string, so nulling it would silently re-register a brand new account on every node that has one, at its next renewal - new account key, reset failed-authorization budget, and a new registration spent against the per-address limit, network wide, in one release. When unset the argument is omitted entirely rather than passed empty, because empty is a different account directory from absent. A PORT-80 RETRY LOOP FOR LET'S ENCRYPT. ZeroSSL has had one for years; Let's Encrypt threw a static string. lego's own output is shown - Boulder answers "why did port 80 fail" better than any classifier dashmate could keep current - and the retry defaults to No, because an immediate retry cannot succeed when the operator has not left the terminal to change a firewall rule. Capped at three attempts, since each spends one of five failed authorizations per hour that this node shares with its own automatic renewal. The give-up text names the paused-identifier case and Let's Encrypt's rate-limit page, and deliberately makes no claim about when to come back: waiting never clears a pause, which is the state a node dark for months is most likely in. PROMPTING IS NOW A POSITIVE OPT-IN. ZeroSSL's loop was gated on noRetry alone - prompt unless told otherwise - and the helper is safe today only because renewCertificate happens to pass noRetry: true. That is one refactor away from a background renewal that hangs forever: the helper's event loop is held open by an interval that is never unref'd, it holds the config lock, and proper-lockfile keeps refreshing the lock's mtime so it never goes stale. Renewal would stop permanently and every command that mutates config would fail on a lock timeout until someone restarted the container. Both loops now require ctx.interactive === true, sourced per entry point: update and ssl obtain detect it, setup states it, the helper never sets it. Also handles CERTIFICATE_NOT_INSTALLED, which existed as an error but had no case in the obtain switch and fell through to "Unknown error". The helper schedules exactly that path whenever the pair is not installed, so an affected node retried hourly and threw every time, forever. It now installs the certificate it already has rather than issuing another. And saveCertificateTask verifies what it wrote. Its two writes are separate and in place - in place because the bind mount follows the inode - so a full disk, a failed chmod or a power loss between them leaves a new certificate paired with the old key. With the gateway stopped, which is where the documented upgrade procedure leaves it, nothing would notice: the command reports success and the node fails to come back up at the next `dashmate start`, displaced from whatever caused it. The pairing rule is extracted to selectLeafCertificate so the checker and the writer share one implementation. Tests: 27 new. Red before this commit, green after - - validator: EMAIL_IS_NOT_SET returned ahead of everything (2 red) - doctor: severity HIGH(3) where LOW(1) is required (1 red) - obtain: threw "email is not set" with no email; passed --email always; threw "Unknown error: CERTIFICATE_NOT_INSTALLED"; no retry loop existed (9 red) - zerossl: prompt constructed with no way to answer it (1 red) - setup: prompted for an email, never set ctx.interactive (2 red) - ssl obtain: never passed interactive (2 red) - saveCertificateTask: reported success on a mismatched pair (3 red) The two helper-path tests and the email-migration guard pass on both sides, so each was proved capable of failing by temporarily breaking the code it guards - opting renewCertificate into prompting, and nulling the email in the 4.2.0 migration - and both went red as intended. Co-Authored-By: Claude Opus 5 --- packages/dashmate/src/commands/ssl/obtain.js | 21 +- .../doctor/analyse/analyseConfigFactory.js | 13 +- .../configureSSLCertificateTaskFactory.js | 25 +- ...obtainLetsEncryptCertificateTaskFactory.js | 241 ++++++++++----- .../listr/tasks/ssl/saveCertificateTask.js | 23 ++ .../obtainZeroSSLCertificateTaskFactory.js | 15 +- .../src/ssl/checkGatewayCertificateFactory.js | 80 +---- .../validateLetsEncryptCertificateFactory.js | 11 +- .../dashmate/src/ssl/selectLeafCertificate.js | 97 ++++++ .../dashmate/src/test/mock/getEnquirerMock.js | 25 ++ .../dashmate/src/util/renderConfigFlag.js | 21 ++ .../test/unit/commands/ssl/obtain.spec.js | 64 +++- .../migrateConfigFileFactory.spec.js | 23 ++ .../analyse/analyseConfigFactory.spec.js | 15 + .../test/unit/helper/renewCertificate.spec.js | 145 +++++++++ ...configureSSLCertificateTaskFactory.spec.js | 59 ++++ ...nLetsEncryptCertificateTaskFactory.spec.js | 291 ++++++++++++++++++ ...idateLetsEncryptCertificateFactory.spec.js | 27 ++ .../test/unit/ssl/saveCertificateTask.spec.js | 53 +++- ...btainZeroSSLCertificateTaskFactory.spec.js | 79 +++++ 20 files changed, 1137 insertions(+), 191 deletions(-) create mode 100644 packages/dashmate/src/ssl/selectLeafCertificate.js create mode 100644 packages/dashmate/src/test/mock/getEnquirerMock.js create mode 100644 packages/dashmate/src/util/renderConfigFlag.js diff --git a/packages/dashmate/src/commands/ssl/obtain.js b/packages/dashmate/src/commands/ssl/obtain.js index d23a1b29b3b..270d31c656d 100644 --- a/packages/dashmate/src/commands/ssl/obtain.js +++ b/packages/dashmate/src/commands/ssl/obtain.js @@ -2,6 +2,7 @@ import { Listr } from 'listr2'; import { Flags } from '@oclif/core'; import ServiceIsNotRunningError from '../../docker/errors/ServiceIsNotRunningError.js'; import ConfigBaseCommand from '../../oclif/command/ConfigBaseCommand.js'; +import isInteractiveSession from '../../util/isInteractiveSession.js'; import MuteOneLineError from '../../oclif/errors/MuteOneLineError.js'; import Certificate from '../../ssl/zerossl/Certificate.js'; import LegoCertificate from '../../ssl/letsencrypt/LegoCertificate.js'; @@ -46,13 +47,7 @@ Certificate will be renewed if it is about to expire (see 'expiration-days' flag */ async runWithDependencies( args, - { - verbose: isVerbose, - 'no-retry': noRetry, - 'expiration-days': expirationDaysFlag, - force, - provider: providerFlag, - }, + flags, config, obtainZeroSSLCertificateTask, obtainLetsEncryptCertificateTask, @@ -60,6 +55,14 @@ Certificate will be renewed if it is about to expire (see 'expiration-days' flag configFile, dockerCompose, ) { + const { + verbose: isVerbose, + 'no-retry': noRetry, + 'expiration-days': expirationDaysFlag, + force, + provider: providerFlag, + } = flags; + const provider = providerFlag || config.get('platform.gateway.ssl.provider'); let task; @@ -140,6 +143,10 @@ Certificate will be renewed if it is about to expire (see 'expiration-days' flag noRetry, force, expirationDays, + // Whether the obtain may ask a question is decided here rather than + // inside the shared task, so a caller that never opts in - the helper's + // unattended renewal - cannot enable prompting by omission. + interactive: isInteractiveSession({ flags }), }); } catch (e) { throw new MuteOneLineError(e); diff --git a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js index 67e897bd736..4169eddbacc 100644 --- a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js @@ -155,8 +155,14 @@ ${LETSENCRYPT_ALTERNATIVE}`, 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]}`, + // A contact address is optional under RFC 8555, Let's Encrypt + // stopped sending expiry notifications in 2025, and nothing in + // dashmate asks for one. Worth knowing, not worth fixing. + severity: SEVERITY.LOW, + description: 'No contact is registered with the certificate authority.', + solution: chalk`Nothing needs to be done. If you would like one on file: +{bold.cyanBright dashmate config set platform.gateway.ssl.providerConfigs.letsencrypt.email [EMAIL]} +Note that changing it makes renewal register a new account with the authority.`, }, [LETSENCRYPT_ERRORS.EXTERNAL_IP_IS_NOT_SET]: { description: 'External IP is not set.', @@ -200,6 +206,7 @@ Please restart Platform: {bold.cyanBright dashmate restart --platform}`, const { description, solution, + severity = SEVERITY.HIGH, } = { ...fileProblems, ...providerProblems, @@ -209,7 +216,7 @@ Please restart Platform: {bold.cyanBright dashmate restart --platform}`, const problem = new Problem( description, solution, - SEVERITY.HIGH, + severity, ); problems.push(problem); diff --git a/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js index 90858703a44..6d4e10bf716 100644 --- a/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js @@ -126,20 +126,10 @@ export default function configureSSLCertificateTaskFactory( }, [SSL_PROVIDERS.LETSENCRYPT]: { title: 'Obtain Let\'s Encrypt certificate', - task: async (ctx, task) => { - const email = await task.prompt({ - type: 'input', - message: 'Enter email address for Let\'s Encrypt notifications', - validate: (input) => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(input) || 'Please enter a valid email address'; - }, - }); - - ctx.config.set('platform.gateway.ssl.providerConfigs.letsencrypt.email', email); - - return obtainLetsEncryptCertificateTask(ctx.config); - }, + // No contact address is asked for. It is optional under RFC 8555, + // Let's Encrypt ended expiry notifications in 2025 and does not keep an + // address supplied through ACME, so the question bought nothing. + task: async (ctx) => obtainLetsEncryptCertificateTask(ctx.config), }, }; @@ -147,6 +137,11 @@ export default function configureSSLCertificateTaskFactory( { title: 'Configure SSL certificate', task: async (ctx, task) => { + // Setup asks the operator a question at every step, so it cannot run + // unattended and states this rather than detecting it. The obtain + // tasks read it to decide whether they may prompt. + ctx.interactive = true; + const choices = [ { name: SSL_PROVIDERS.ZEROSSL, message: 'ZeroSSL' }, { name: SSL_PROVIDERS.LETSENCRYPT, message: "Let's Encrypt" }, @@ -165,7 +160,7 @@ export default function configureSSLCertificateTaskFactory( ZeroSSL - Provide a ZeroSSL API key and let dashmate configure the certificate https://zerossl.com/documentation/api/ ("Access key" section) - Let's Encrypt - Free certificates using Let's Encrypt (requires email) + Let's Encrypt - Free certificates for your IP address, no account needed File on disk - Provide your own certificate to dashmate\n`; if (isSelfSignedEnabled) { diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index 8da44dd8724..66e2d7128a6 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -5,9 +5,47 @@ import os from 'os'; import { ERRORS } from '../../../../ssl/letsencrypt/validateLetsEncryptCertificateFactory.js'; import LegoCertificate from '../../../../ssl/letsencrypt/LegoCertificate.js'; +import promptOrThrow from '../../../../util/promptOrThrow.js'; +import renderConfigFlag from '../../../../util/renderConfigFlag.js'; const LEGO_IMAGE = 'goacme/lego:v4.31.0'; +/** + * Let's Encrypt allows five failed authorizations per address per account per + * hour, and that budget is shared with the helper's renewal of a still-valid + * certificate, so an attempt is not free. Three is enough for an operator who + * is fixing a firewall rule between attempts and few enough to leave the + * helper room. + */ +const MAX_OBTAIN_ATTEMPTS = 3; + +/** + * What to tell an operator who has run out of attempts. + * + * No claim about when to come back: a long-failing address may be paused rather + * than rate-limited, and waiting never clears a pause - which is exactly the + * state a node that has been dark for months is likely to be in. + * + * @param {Config} config + * @param {number} attempts + * @return {string} + */ +function renderGiveUpGuidance(config, attempts) { + return `dashmate did not obtain a certificate after ${attempts} ` + + `attempt${attempts === 1 ? '' : 's'}. + +Retrying now also blocks this node's automatic renewal: dashmate's helper +renews under the same Let's Encrypt account, and failed attempts are shared. + +If this node has been failing for a long time, the address may be PAUSED +rather than rate-limited - waiting does not clear a pause, and you may need +Let's Encrypt's Self-Service Portal to unpause it: + https://letsencrypt.org/docs/rate-limits/ + +Fix inbound port 80 first, then: ` + + `dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider letsencrypt`; +} + const LEGO_CA_CERTIFICATE_MOUNT_PATH = '/acme-ca.pem'; /** @@ -53,10 +91,6 @@ export default function obtainLetsEncryptCertificateTaskFactory( ctx.configurationUpdateRequired = !config.get('platform.gateway.ssl.enabled') || config.get('platform.gateway.ssl.provider') !== 'letsencrypt'; - if (!ctx.email) { - throw new Error("Let's Encrypt email is not set. Please set it in the config file"); - } - if (!ctx.externalIp) { throw new Error('External IP is not set. Please set it in the config file'); } @@ -97,8 +131,6 @@ export default function obtainLetsEncryptCertificateTaskFactory( // eslint-disable-next-line no-param-reassign task.output = `Certificate is valid and expires at ${ctx.certificate.expires}`; break; - case ERRORS.EMAIL_IS_NOT_SET: - throw new Error('Let\'s Encrypt email is not set. Please set it in the config file'); case ERRORS.EXTERNAL_IP_IS_NOT_SET: throw new Error('External IP is not set. Please set it in the config file'); case ERRORS.CERTIFICATE_NOT_FOUND: @@ -128,6 +160,16 @@ export default function obtainLetsEncryptCertificateTaskFactory( ctx.certificateValid = false; ctx.isRenewal = false; break; + case ERRORS.CERTIFICATE_NOT_INSTALLED: + // The certificate itself is fine; it just never reached the + // files the gateway loads. Issuing another one would spend an + // issuance to fix a copy, and the helper schedules this same + // path whenever the pair is not installed - so without this case + // an affected node retries hourly and throws every time. + // eslint-disable-next-line no-param-reassign + task.output = 'Certificate is valid but not installed for the gateway'; + ctx.certificateValid = true; + break; default: throw new Error(`Unknown error: ${error}`); } @@ -163,9 +205,14 @@ export default function obtainLetsEncryptCertificateTaskFactory( // Build lego command arguments // --disable-cn is needed for IP address certificates // --key-type rsa2048 is needed because node-forge doesn't support ECDSA + // lego keys its on-disk ACME account directory by the contact + // address, so an empty --email is a different account from no + // --email at all. Nothing asks for one any more, and RFC 8555 makes + // the contact optional, so an unset value omits the argument rather + // than passing it empty. const legoArgs = [ `--server=${acmeDirectoryUrl.toString()}`, - '--email', ctx.email, + ...(ctx.email ? ['--email', ctx.email] : []), '--accept-tos', '--http', '--http.port', ':80', @@ -185,88 +232,130 @@ export default function obtainLetsEncryptCertificateTaskFactory( const containerName = 'dashmate-letsencrypt-lego'; - // Remove any existing container with the same name - try { - const existingContainer = await docker.getContainer(containerName); - await existingContainer.remove({ force: true }); - + const runLego = async () => { + // Remove any existing container with the same name try { - await existingContainer.wait(); - } catch (waitError) { - // Skip error if container is already removed - if (waitError.statusCode !== 404) { - throw waitError; + const existingContainer = await docker.getContainer(containerName); + await existingContainer.remove({ force: true }); + + try { + await existingContainer.wait(); + } catch (waitError) { + // Skip error if container is already removed + if (waitError.statusCode !== 404) { + throw waitError; + } + } + } catch (e) { + // Container doesn't exist, that's fine + if (e.statusCode !== 404) { + throw e; } } - } catch (e) { - // Container doesn't exist, that's fine - if (e.statusCode !== 404) { - throw e; - } - } - const binds = [`${ctx.legoDir}:/data`]; - const env = []; + const binds = [`${ctx.legoDir}:/data`]; + const env = []; - // An ACME directory that is not publicly trusted - a staging or local - // server - presents a certificate lego rejects unless told which CA - // signed it. - if (legoCaCertificatePath) { - binds.push(`${legoCaCertificatePath}:${LEGO_CA_CERTIFICATE_MOUNT_PATH}:ro`); - env.push(`LEGO_CA_CERTIFICATES=${LEGO_CA_CERTIFICATE_MOUNT_PATH}`); - } - - const container = await docker.createContainer({ - name: containerName, - Image: LEGO_IMAGE, - Cmd: legoArgs, - Env: env, - User: `${uid}:${gid}`, - ExposedPorts: { '80/tcp': {} }, - ...legoContainerOptions, - HostConfig: { - AutoRemove: true, - Binds: binds, - PortBindings: { '80/tcp': [{ HostPort: '80' }] }, - ...legoContainerOptions.HostConfig, - }, - }); + // An ACME directory that is not publicly trusted - a staging or local + // server - presents a certificate lego rejects unless told which CA + // signed it. + if (legoCaCertificatePath) { + binds.push(`${legoCaCertificatePath}:${LEGO_CA_CERTIFICATE_MOUNT_PATH}:ro`); + env.push(`LEGO_CA_CERTIFICATES=${LEGO_CA_CERTIFICATE_MOUNT_PATH}`); + } - startedContainers.addContainer(containerName); + const container = await docker.createContainer({ + name: containerName, + Image: LEGO_IMAGE, + Cmd: legoArgs, + Env: env, + User: `${uid}:${gid}`, + ExposedPorts: { '80/tcp': {} }, + ...legoContainerOptions, + HostConfig: { + AutoRemove: true, + Binds: binds, + PortBindings: { '80/tcp': [{ HostPort: '80' }] }, + ...legoContainerOptions.HostConfig, + }, + }); + + startedContainers.addContainer(containerName); + + // eslint-disable-next-line no-param-reassign + task.output = `Running lego ${command}...`; + + await container.start(); + + // Wait for container to finish + const result = await container.wait(); + + if (result.StatusCode !== 0) { + // lego's own output is the best account of what went wrong - + // Boulder answers "why did port 80 fail" in prose better than any + // classifier dashmate could keep current. + let errorMessage = `Lego exited with code ${result.StatusCode}`; + try { + const logs = await container.logs({ + stdout: true, + stderr: true, + }); + errorMessage += `\n${logs.toString()}`; + } catch (e) { + // Container may have been auto-removed + } - // eslint-disable-next-line no-param-reassign - task.output = `Running lego ${command}...`; + throw new Error(`Failed to obtain Let's Encrypt certificate: ${errorMessage}`); + } - await container.start(); + // Verify certificate and key were created + if (!fs.existsSync(ctx.legoCertPath)) { + throw new Error('Certificate file was not created by lego'); + } - // Wait for container to finish - const result = await container.wait(); + if (!fs.existsSync(ctx.legoKeyPath)) { + throw new Error('Private key file was not created by lego'); + } + }; - if (result.StatusCode !== 0) { - // Try to get logs for error message - let errorMessage = `Lego exited with code ${result.StatusCode}`; + for (let attempt = 1; attempt <= MAX_OBTAIN_ATTEMPTS; attempt += 1) { try { - const logs = await container.logs({ - stdout: true, - stderr: true, - }); - errorMessage += `\n${logs.toString()}`; + // eslint-disable-next-line no-await-in-loop + await runLego(); + + break; } catch (e) { - // Container may have been auto-removed + // Prompting needs a positive opt-in from the entry point. The + // helper renews inside a container with no terminal, where a + // prompt would never settle and would hold the config lock - + // and its event loop never drains, so it would hang forever. + const canRetry = attempt < MAX_OBTAIN_ATTEMPTS + && ctx.noRetry !== true + && ctx.interactive === true; + + // Default No: an immediate retry cannot succeed, because the + // operator has not left the terminal to change a firewall rule, + // and each attempt spends one of the five failed authorizations + // per hour this node shares with its own automatic renewal. + // eslint-disable-next-line no-await-in-loop + const retry = canRetry && await promptOrThrow(task, { + type: 'toggle', + header: ` Let's Encrypt could not reach ${ctx.externalIp} on port 80: + + ${e.message} + + Retrying without changing anything will fail again. Fix the port first, then + answer Yes - or answer No and try again once port 80 is open.`, + message: `Try again? [attempt ${attempt + 1} of ${MAX_OBTAIN_ATTEMPTS}]`, + enabled: 'Yes', + disabled: 'No', + initial: false, + }, { interactive: ctx.interactive }); + + if (!retry) { + throw new Error(`${e.message}\n\n${renderGiveUpGuidance(config, attempt)}`); + } } - - throw new Error(`Failed to obtain Let's Encrypt certificate: ${errorMessage}\n` - + `Please ensure port 80 on your public IP address ${ctx.externalIp} is open\n` - + 'for incoming HTTP connections.'); - } - - // Verify certificate and key were created - if (!fs.existsSync(ctx.legoCertPath)) { - throw new Error('Certificate file was not created by lego'); - } - - if (!fs.existsSync(ctx.legoKeyPath)) { - throw new Error('Private key file was not created by lego'); } ctx.configurationUpdateRequired = true; diff --git a/packages/dashmate/src/listr/tasks/ssl/saveCertificateTask.js b/packages/dashmate/src/listr/tasks/ssl/saveCertificateTask.js index 31e93a14a31..d9a29de7014 100644 --- a/packages/dashmate/src/listr/tasks/ssl/saveCertificateTask.js +++ b/packages/dashmate/src/listr/tasks/ssl/saveCertificateTask.js @@ -2,6 +2,9 @@ import { Listr } from 'listr2'; import path from 'path'; import fs from 'fs'; +import selectLeafCertificate from '../../../ssl/selectLeafCertificate.js'; +import renderConfigFlag from '../../../util/renderConfigFlag.js'; + /** * @param {HomeDir} homeDir * @return {saveCertificateTask} @@ -64,6 +67,26 @@ export default function saveCertificateTaskFactory(homeDir) { } } + // The two writes above are separate and in place, so a full disk, a + // failed chmod or a power loss between them can leave a new + // certificate paired with the old key, or a truncated bundle. With + // the gateway stopped - the state the documented upgrade procedure + // leaves it in - nothing else would notice: the command would report + // success and the node would simply fail to come back up at the next + // start, a step removed from whatever caused it. + const { error, detail } = selectLeafCertificate( + fs.readFileSync(crtFile, 'utf8'), + fs.readFileSync(keyFile, 'utf8'), + ); + + if (error) { + throw new Error(`The certificate and private key written for the gateway do not match:` + + ` ${detail}.\n` + + `Certificate: ${crtFile}\nPrivate key: ${keyFile}\n` + + 'The gateway will not start with these files. Obtain the certificate again:\n' + + ` dashmate ssl obtain ${renderConfigFlag(config.getName())} --force`); + } + config.set('platform.gateway.ssl.enabled', true); }, }]); diff --git a/packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js index 504f8c4a67d..f01a7e1d3b7 100644 --- a/packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js @@ -3,6 +3,7 @@ import { Listr } from 'listr2'; import chalk from 'chalk'; import fs from 'fs'; import lodash from 'lodash'; +import promptOrThrow from '../../../../util/promptOrThrow.js'; import wait from '../../../../util/wait.js'; import { ERRORS } from '../../../../ssl/zerossl/validateZeroSslCertificateFactory.js'; @@ -204,9 +205,13 @@ and all Dash service ports listed above.`); } } - // If retry is disabled, throw the error - // or prompt the user to retry - if (ctx.noRetry !== true) { + // Prompting needs a positive opt-in from the entry point rather + // than the absence of noRetry. Gating on noRetry alone prompts + // unless a caller remembers to say otherwise, and the caller + // most likely to forget renews certificates unattended inside a + // container, where a prompt never settles and never releases the + // config lock it holds. + if (ctx.noRetry !== true && ctx.interactive === true) { let errorMessage = e.message; // Get the error message from details if it exists @@ -217,7 +222,7 @@ and all Dash service ports listed above.`); } } - retry = await task.prompt({ + retry = await promptOrThrow(task, { type: 'toggle', header: chalk` An error occurred during verification: {red ${errorMessage}} @@ -230,7 +235,7 @@ and all Dash service ports listed above.`); enabled: 'Yes', disabled: 'No', initial: true, - }); + }, { interactive: ctx.interactive }); } if (!retry) { diff --git a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js index 5a9065bd23a..27bc6a153f7 100644 --- a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js +++ b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js @@ -1,10 +1,10 @@ -import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { SSL_PROVIDERS } from '../constants.js'; import { parseIpAddresses } from './readCertificateBundle.js'; import isCertificatePairInstalled from './letsencrypt/isCertificatePairInstalled.js'; +import selectLeafCertificate, { LEAF_SELECTION_ERRORS } from './selectLeafCertificate.js'; export const CERTIFICATE_STATUS = { // Deliberately not VALID. This function runs a fixed list of local checks; it @@ -42,14 +42,6 @@ const DAY_MS = 24 * 60 * 60 * 1000; */ const EXPIRING_SOON_DAYS = 1; -const PEM_CERTIFICATE = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; - -/** - * A key protected by a passphrase is detected from the PEM rather than by - * asking OpenSSL, which can go looking for a terminal to ask on. - */ -const ENCRYPTED_KEY = /-----BEGIN ENCRYPTED PRIVATE KEY-----|^\s*Proc-Type:\s*4,ENCRYPTED/m; - /** * @param {string} distinguishedName - as rendered by X509Certificate * @return {string|undefined} @@ -70,7 +62,7 @@ function commonNameOf(distinguishedName) { * returns null rather than a guess, so a certificate from a paid CA is never * reported as disagreeing with anything. * - * @param {crypto.X509Certificate} leaf + * @param {X509Certificate} leaf * @param {boolean} isSelfSigned * @return {string|null} */ @@ -92,18 +84,6 @@ function identifyIssuer(leaf, isSelfSigned) { return null; } -/** - * @param {crypto.KeyObject|crypto.X509Certificate['publicKey']} publicKey - * @return {Buffer|null} - */ -function exportSubjectPublicKeyInfo(publicKey) { - try { - return publicKey.export({ type: 'spki', format: 'der' }); - } catch { - return null; - } -} - /** * @param {HomeDir} homeDir * @return {checkGatewayCertificate} @@ -214,54 +194,22 @@ export default function checkGatewayCertificateFactory(homeDir) { return verdict(); } - // The gateway is handed this file with no password or passphrase field + // The gateway is handed the key file with no password or passphrase field // anywhere in its configuration, so a key dashmate cannot load is a key the // gateway cannot load either and the node serves no TLS at all. Warning // here would pass a node that is already dark. - let subjectPublicKeyInfo = null; - if (ENCRYPTED_KEY.test(privateKeyPem)) { + const { leaf, error, detail } = selectLeafCertificate(bundlePem, privateKeyPem); + + if (error === LEAF_SELECTION_ERRORS.KEY_UNUSABLE) { reasons.push({ code: CERTIFICATE_REASONS.KEY_UNUSABLE, - message: `dashmate could not read the private key at ${privateKeyFilePath}:` - + ' it is protected by a passphrase, and the gateway has no way to be given one', + message: `dashmate could not read the private key at ${privateKeyFilePath}: ${detail}`, }); - } else { - try { - subjectPublicKeyInfo = exportSubjectPublicKeyInfo( - crypto.createPublicKey(crypto.createPrivateKey(privateKeyPem)), - ); - } catch (e) { - reasons.push({ - code: CERTIFICATE_REASONS.KEY_UNUSABLE, - message: `dashmate could not read the private key at ${privateKeyFilePath}: ${e.message}`, - }); - } - if (subjectPublicKeyInfo === null && reasons.length === 0) { - reasons.push({ - code: CERTIFICATE_REASONS.KEY_UNUSABLE, - message: `dashmate could not read the private key at ${privateKeyFilePath}`, - }); - } - } - - if (subjectPublicKeyInfo === null) { return verdict(); } - const certificates = (bundlePem.match(PEM_CERTIFICATE) ?? []) - .map((block) => { - try { - return new crypto.X509Certificate(block); - } catch { - // A block that will not parse is skipped rather than failing the - // whole bundle, which may hold comments or a stray key. - return null; - } - }) - .filter(Boolean); - - if (certificates.length === 0) { + if (error === LEAF_SELECTION_ERRORS.BUNDLE_UNREADABLE) { reasons.push({ code: CERTIFICATE_REASONS.BUNDLE_UNREADABLE, message: `dashmate could not read any certificate from ${bundleFilePath}`, @@ -270,17 +218,7 @@ export default function checkGatewayCertificateFactory(homeDir) { return verdict(); } - // The leaf is the block whose public key belongs to the installed private - // key. Selecting by position gets one bundle order wrong, and self-sign - // testing every block rejects any chain carrying its root - which an - // ordinary public chain does. - const leaf = certificates.find((certificate) => { - const spki = exportSubjectPublicKeyInfo(certificate.publicKey); - - return spki !== null && spki.equals(subjectPublicKeyInfo); - }); - - if (!leaf) { + if (error === LEAF_SELECTION_ERRORS.KEY_MISMATCH) { reasons.push({ code: CERTIFICATE_REASONS.KEY_MISMATCH, message: `No certificate in ${bundleFilePath} belongs to the private key` diff --git a/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js index 86970dec505..e525f96044e 100644 --- a/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js +++ b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js @@ -40,15 +40,12 @@ export default function validateLetsEncryptCertificateFactory(homeDir) { // Lego data directory (where lego stores its state) data.legoDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'lego'); + // Reported for the caller's information only. A contact address is optional + // under RFC 8555, Let's Encrypt stopped sending expiry notifications in + // 2025, and nothing in dashmate asks for one - so no new node has one and + // refusing here would fail every check on every new node. data.email = config.get('platform.gateway.ssl.providerConfigs.letsencrypt.email'); - if (!data.email) { - return { - error: ERRORS.EMAIL_IS_NOT_SET, - data, - }; - } - data.externalIp = config.get('externalIp'); if (!data.externalIp) { diff --git a/packages/dashmate/src/ssl/selectLeafCertificate.js b/packages/dashmate/src/ssl/selectLeafCertificate.js new file mode 100644 index 00000000000..ba200772ad2 --- /dev/null +++ b/packages/dashmate/src/ssl/selectLeafCertificate.js @@ -0,0 +1,97 @@ +import crypto from 'node:crypto'; + +export const LEAF_SELECTION_ERRORS = { + KEY_UNUSABLE: 'KEY_UNUSABLE', + BUNDLE_UNREADABLE: 'BUNDLE_UNREADABLE', + KEY_MISMATCH: 'KEY_MISMATCH', +}; + +const PEM_CERTIFICATE = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; + +/** + * A key protected by a passphrase is detected from the PEM rather than by + * asking OpenSSL, which can go looking for a terminal to ask on. + */ +const ENCRYPTED_KEY = /-----BEGIN ENCRYPTED PRIVATE KEY-----|^\s*Proc-Type:\s*4,ENCRYPTED/m; + +/** + * @param {crypto.KeyObject} publicKey + * @return {Buffer|null} + */ +function exportSubjectPublicKeyInfo(publicKey) { + try { + return publicKey.export({ type: 'spki', format: 'der' }); + } catch { + return null; + } +} + +/** + * Find the certificate in a bundle that belongs to a private key. + * + * The leaf is the block whose public key material is the key's own. That single + * rule does three jobs: it finds the leaf whichever way round the bundle is + * written, it is itself the pairing check, and comparing key material rather + * than verifying an RSA signature works for every key type an authority might + * issue. + * + * Selecting by position gets one bundle order wrong, and self-sign testing + * every block to find the leaf rejects any chain that carries its own root - + * which an ordinary publicly trusted bundle does. + * + * @param {string} bundlePem + * @param {string} privateKeyPem + * @return {{leaf: crypto.X509Certificate}|{error: string, detail: string}} + */ +export default function selectLeafCertificate(bundlePem, privateKeyPem) { + if (ENCRYPTED_KEY.test(privateKeyPem)) { + return { + error: LEAF_SELECTION_ERRORS.KEY_UNUSABLE, + detail: 'it is protected by a passphrase, and the gateway has no way to be given one', + }; + } + + let subjectPublicKeyInfo; + try { + subjectPublicKeyInfo = exportSubjectPublicKeyInfo( + crypto.createPublicKey(crypto.createPrivateKey(privateKeyPem)), + ); + } catch (e) { + return { error: LEAF_SELECTION_ERRORS.KEY_UNUSABLE, detail: e.message }; + } + + if (subjectPublicKeyInfo === null) { + return { error: LEAF_SELECTION_ERRORS.KEY_UNUSABLE, detail: 'its key material could not be read' }; + } + + const certificates = (bundlePem.match(PEM_CERTIFICATE) ?? []) + .map((block) => { + try { + return new crypto.X509Certificate(block); + } catch { + // A block that will not parse is skipped rather than failing the whole + // bundle, which may hold comments or a stray key. + return null; + } + }) + .filter(Boolean); + + if (certificates.length === 0) { + return { error: LEAF_SELECTION_ERRORS.BUNDLE_UNREADABLE, detail: 'it holds no certificate' }; + } + + const leaf = certificates.find((certificate) => { + const spki = exportSubjectPublicKeyInfo(certificate.publicKey); + + return spki !== null && spki.equals(subjectPublicKeyInfo); + }); + + if (!leaf) { + return { + error: LEAF_SELECTION_ERRORS.KEY_MISMATCH, + detail: 'no certificate in the bundle belongs to the private key', + }; + } + + return { leaf }; +} diff --git a/packages/dashmate/src/test/mock/getEnquirerMock.js b/packages/dashmate/src/test/mock/getEnquirerMock.js new file mode 100644 index 00000000000..32782d4958d --- /dev/null +++ b/packages/dashmate/src/test/mock/getEnquirerMock.js @@ -0,0 +1,25 @@ +/** + * Stand in for the enquirer instance listr2 builds for a prompt. + * + * listr2 takes the instance from `injectWrapper.enquirer` when one is present, + * so a test can answer a prompt without a terminal - and, more importantly, can + * assert that a prompt was never constructed at all on paths that must not ask. + * + * @param {Object} sinon + * @param {...*} answers - one per prompt, in order + * @return {{on: Function, prompt: Function, options: Object[]}} + */ +export default function getEnquirerMock(sinon, ...answers) { + const options = []; + const remaining = [...answers]; + + return { + options, + on: sinon.stub(), + prompt: sinon.stub().callsFake(async (promptOptions) => { + options.push(...[].concat(promptOptions)); + + return { default: remaining.length > 0 ? remaining.shift() : undefined }; + }), + }; +} diff --git a/packages/dashmate/src/util/renderConfigFlag.js b/packages/dashmate/src/util/renderConfigFlag.js new file mode 100644 index 00000000000..3f0ed4b2a59 --- /dev/null +++ b/packages/dashmate/src/util/renderConfigFlag.js @@ -0,0 +1,21 @@ +/** + * Values a POSIX shell passes through untouched. + */ +const SHELL_SAFE = /^[A-Za-z0-9._@%+=:,/-]+$/; + +/** + * Render the --config flag for a command an operator is meant to copy and run. + * + * Every command dashmate prints carries this. Without it an operator running + * several configs who pastes a bare command obtains a certificate for, + * restarts, or bypasses a check on a different node - the command falls back to + * the default config when --config is absent. + * + * @param {string} configName + * @return {string} + */ +export default function renderConfigFlag(configName) { + const name = String(configName); + + return `--config ${SHELL_SAFE.test(name) ? name : `'${name.replace(/'/g, "'\\''")}'`}`; +} diff --git a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js index c142f820496..1931e458df8 100644 --- a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js +++ b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js @@ -11,6 +11,7 @@ describe('SSL obtain command', () => { function obtainDependencies(sinon, provider = 'letsencrypt') { return { provider, + observed: {}, config: { get: sinon.stub().returns(provider), }, @@ -22,12 +23,29 @@ describe('SSL obtain command', () => { }; } + /** + * Capture the context the obtain task is run with. + * + * @param {Object} dependencies + * @return {Object} + */ + function captureContext(sinon, dependencies) { + const observed = {}; + + // eslint-disable-next-line no-param-reassign + dependencies.obtainTask = sinon.stub().callsFake(() => new Listr([{ + task: (ctx) => Object.assign(observed, ctx), + }])); + + return observed; + } + /** * @param {Object} dependencies * @return {Promise} */ function runObtain({ - provider, config, dockerCompose, obtainTask, + provider, config, dockerCompose, obtainTask, 'no-retry': noRetry = true, }) { const noop = () => new Listr([]); @@ -35,7 +53,7 @@ describe('SSL obtain command', () => { {}, { verbose: false, - 'no-retry': true, + 'no-retry': noRetry, 'expiration-days': undefined, force: false, provider, @@ -150,4 +168,46 @@ describe('SSL obtain command', () => { expect(obtainZeroSSLCertificateTask).to.have.been.calledOnce(); expect(configFileRepository.write).to.have.been.calledOnceWith(configFile); }); + + // The retry loop lives in the shared obtain task, so `ssl obtain` gains it + // too. Its --no-retry defaults to false, which would turn an obtain run from + // cron into a hang if the flag were what decided whether to prompt. + it('should not offer to prompt when run without a terminal', async function it() { + const dependencies = obtainDependencies(this.sinon); + const context = captureContext(this.sinon, dependencies); + + await runObtain({ ...dependencies, 'no-retry': false }); + + expect(context.interactive).to.equal(false); + }); + + it('should offer to prompt an operator at a terminal', async function it() { + // A stream that is not a terminal has no isTTY property at all - not a + // false one - so it is assigned rather than stubbed. + const restore = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY, ci: process.env.CI }; + process.stdin.isTTY = true; + process.stdout.isTTY = true; + process.env.CI = '0'; + + this.restoreStreams = () => { + process.stdin.isTTY = restore.stdin; + process.stdout.isTTY = restore.stdout; + if (restore.ci === undefined) { + delete process.env.CI; + } else { + process.env.CI = restore.ci; + } + }; + + const dependencies = obtainDependencies(this.sinon); + const context = captureContext(this.sinon, dependencies); + + try { + await runObtain(dependencies); + } finally { + this.restoreStreams(); + } + + expect(context.interactive).to.equal(true); + }); }); diff --git a/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js index 7e405f2b543..020b2454f1a 100644 --- a/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js +++ b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js @@ -23,6 +23,29 @@ describe('migrateConfigFileFactory', () => { mockConfigFileData = getConfigFileDataV0250(); }); + // lego keys its on-disk ACME account directory by the contact address, so + // that string decides which account a renewal runs under. A migration that + // nulled, normalised or removed it would silently register a brand new + // account on every node that has one, at its next renewal - a different + // account key, a reset failed-authorization budget, and a new registration + // spent against the per-address limit, network wide, in one release. + // + // 3.0.0 is where the provider config was introduced, so it is the oldest + // format a stored address can have come from. + it('should carry a configured contact address through every migration', async () => { + const { version } = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT_DIR, 'package.json'), 'utf8')); + + const configFileData = createConfigFile().toObject(); + const [name] = Object.keys(configFileData.configs); + configFileData.configs[name].platform.gateway.ssl.providerConfigs.letsencrypt.email = 'someone@example.org'; + + const migrated = migrateConfigFile(configFileData, '3.0.0', version); + + expect( + migrated.configs[name].platform.gateway.ssl.providerConfigs.letsencrypt.email, + ).to.equal('someone@example.org'); + }); + it('should migrate v0.25.0 config file to the latest one', async () => { const currentConfigFile = createConfigFile(); const currentConfigFileData = currentConfigFile.toObject(); diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js index 602fc78c3ae..0ecdf91e457 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js @@ -50,6 +50,21 @@ describe('analyseConfigFactory', () => { expect(problems[0].getSeverity()).to.equal(SEVERITY.HIGH); }); + // Nothing prompts for a contact address any more, so a node without one is + // ordinary rather than broken. The check is kept rather than deleted so the + // information stays available - and so it is still there if contactless + // issuance ever stops working - but it must not read as an error. + it('should report a missing contact address as information, not a fault', () => { + const problems = analyseSslSample({ + error: LETSENCRYPT_ERRORS.EMAIL_IS_NOT_SET, + data: {}, + }, 'letsencrypt'); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getSeverity()).to.equal(SEVERITY.LOW); + expect(problems[0].getDescription()).to.include('No contact is registered'); + }); + it('should report a problem for a ZeroSSL certificate that expires soon', () => { const problems = analyseSslSample({ error: ZEROSSL_ERRORS.CERTIFICATE_EXPIRES_SOON, diff --git a/packages/dashmate/test/unit/helper/renewCertificate.spec.js b/packages/dashmate/test/unit/helper/renewCertificate.spec.js index 0a1074d21f7..546cb32fd9b 100644 --- a/packages/dashmate/test/unit/helper/renewCertificate.spec.js +++ b/packages/dashmate/test/unit/helper/renewCertificate.spec.js @@ -4,6 +4,9 @@ import ConfigFileJsonRepository from '../../../src/config/configFile/ConfigFileJ import HomeDir from '../../../src/config/HomeDir.js'; import renewCertificate from '../../../src/helper/renewCertificate.js'; import getBaseConfigFactory from '../../../configs/defaults/getBaseConfigFactory.js'; +import obtainLetsEncryptCertificateTaskFactory from '../../../src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js'; +import obtainZeroSSLCertificateTaskFactory from '../../../src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js'; +import getEnquirerMock from '../../../src/test/mock/getEnquirerMock.js'; describe('renewCertificate', () => { let homeDir; @@ -231,4 +234,146 @@ describe('renewCertificate', () => { expect(writeConfigTemplates).to.not.have.been.called(); expect(fs.existsSync(homeDir.joinPath('.config.json.lock'))).to.be.false(); }); + + // The helper renews inside a container with no terminal, and its event loop + // is kept alive by an interval that is never unref'd - so an unsettled prompt + // there hangs forever rather than draining. It also holds the config lock, + // whose mtime keeps being refreshed, so it never goes stale: renewal would + // stop permanently and every command that changes configuration would fail on + // a lock timeout until someone restarted the container. + // + // This drives the real obtain tasks through the context renewCertificate + // actually builds, rather than asserting on the arguments it passes. + describe('unattended renewal', () => { + /** + * @param {Object} tasks + * @param {Object} enquirer + * @return {Object} + */ + function inject(tasks, enquirer) { + // eslint-disable-next-line no-param-reassign + tasks.options.injectWrapper = { enquirer }; + + return tasks; + } + + // noRetry is an operator control, not the interactivity guard. The helper + // must be safe because it never opts in, so that dropping noRetry from this + // call - or any other refactor - cannot make a background renewal prompt. + it('should never opt the renewal context into prompting', async function it() { + let context; + + await renewCertificate({ + configName, + provider: 'zerossl', + expirationDays: 2, + obtainCertificateTask: () => ({ + run: async (ctx) => { + context = ctx; + }, + }), + configFileRepository: repository, + writeConfigTemplates: this.sinon.stub(), + }); + + expect(context.interactive).to.not.equal(true); + expect(context.noRetry).to.be.true(); + }); + + it('should construct no prompt on the Let\'s Encrypt renewal path', async function it() { + repository.update((configFile) => { + const config = configFile.getConfig(configName); + config.set('platform.gateway.ssl.provider', 'letsencrypt'); + config.set('externalIp', '1.2.3.4'); + }); + + const enquirer = getEnquirerMock(this.sinon, true); + const missing = Object.assign(new Error('container not found'), { statusCode: 404 }); + const obtainLetsEncryptCertificateTask = obtainLetsEncryptCertificateTaskFactory( + { + getContainer: this.sinon.stub().rejects(missing), + createContainer: this.sinon.stub().resolves({ + start: this.sinon.stub().resolves(), + logs: this.sinon.stub().resolves(Buffer.from('Timeout during connect')), + wait: this.sinon.stub().resolves({ StatusCode: 1 }), + }), + }, + this.sinon.stub().resolves(), + { addContainer: this.sinon.stub() }, + homeDir, + this.sinon.stub().resolves({ error: 'CERTIFICATE_NOT_FOUND', data: {} }), + this.sinon.stub(), + null, + {}, + ); + + await expect(renewCertificate({ + configName, + provider: 'letsencrypt', + expirationDays: 2, + obtainCertificateTask: (config) => inject( + obtainLetsEncryptCertificateTask(config), + enquirer, + ), + configFileRepository: repository, + writeConfigTemplates: this.sinon.stub(), + })).to.be.rejected(); + + expect(enquirer.prompt).to.not.have.been.called(); + }); + + it('should construct no prompt on the ZeroSSL renewal path', async function it() { + const enquirer = getEnquirerMock(this.sinon, true); + const obtainZeroSSLCertificateTask = obtainZeroSSLCertificateTaskFactory( + this.sinon.stub().resolves('csr'), + this.sinon.stub().resolves({ privateKey: 'private', publicKey: 'public' }), + this.sinon.stub().resolves({ + id: 'certificate-id', + status: 'pending_validation', + validation: { + other_methods: { + '1.2.3.4': { + file_validation_url_http: 'http://1.2.3.4/.well-known/x', + file_validation_content: 'content', + }, + }, + }, + }), + this.sinon.stub().rejects(new Error('domain control validation failed')), + this.sinon.stub(), + this.sinon.stub(), + this.sinon.stub(), + this.sinon.stub(), + { + setup: this.sinon.stub().resolves(), + start: this.sinon.stub().resolves(), + stop: this.sinon.stub().resolves(), + destroy: this.sinon.stub().resolves(), + waitForServerIsResponding: this.sinon.stub().resolves(true), + }, + homeDir, + this.sinon.stub(), + ); + + await expect(renewCertificate({ + configName, + provider: 'zerossl', + expirationDays: 2, + obtainCertificateTask: (config, options) => { + const tasks = inject(obtainZeroSSLCertificateTask(config, options), enquirer); + + const run = tasks.run.bind(tasks); + tasks.run = (context) => run({ + ...context, force: true, externalIp: '1.2.3.4', apiKey: 'api-key', + }); + + return tasks; + }, + configFileRepository: repository, + writeConfigTemplates: this.sinon.stub(), + })).to.be.rejected(); + + expect(enquirer.prompt).to.not.have.been.called(); + }); + }); }); diff --git a/packages/dashmate/test/unit/ssl/configureSSLCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/configureSSLCertificateTaskFactory.spec.js index 6b2d8acedcb..5e4cfb841f8 100644 --- a/packages/dashmate/test/unit/ssl/configureSSLCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/configureSSLCertificateTaskFactory.spec.js @@ -45,4 +45,63 @@ describe('configureSSLCertificateTaskFactory', () => { expect(configFile.setConfig).to.have.been.calledOnceWith(config); expect(configFileRepository.write).to.have.been.calledOnceWith(configFile); }); + + // Setup is prompt-driven from end to end - it cannot run unattended - so it + // is the one entry point that states interactivity outright rather than + // detecting it. + it('should mark the session as interactive for the obtain it starts', async function it() { + const config = { set: this.sinon.stub() }; + const configureSSLCertificateTask = configureSSLCertificateTaskFactory( + this.sinon.stub(), + this.sinon.stub(), + this.sinon.stub(), + this.sinon.stub(), + { setConfig: this.sinon.stub() }, + { write: this.sinon.stub() }, + ); + const context = { + certificateProvider: SSL_PROVIDERS.SELF_SIGNED, + config, + nodeType: 'fullnode', + preset: 'testnet', + }; + + await configureSSLCertificateTask().tasks[0].task(context, { prompt: this.sinon.stub() }); + + expect(context.interactive).to.be.true(); + }); + + // Nothing asks for a contact address any more. Let's Encrypt stopped sending + // expiry notifications in 2025 and does not keep an address supplied through + // ACME, so the question bought nothing and cost every new operator a step. + it('should not ask for an email address', async function it() { + const config = { set: this.sinon.stub() }; + const obtainLetsEncryptCertificateTask = this.sinon.stub().returns('obtain-task'); + const configureSSLCertificateTask = configureSSLCertificateTaskFactory( + this.sinon.stub(), + this.sinon.stub(), + this.sinon.stub(), + obtainLetsEncryptCertificateTask, + { setConfig: this.sinon.stub() }, + { write: this.sinon.stub() }, + ); + const context = { + certificateProvider: SSL_PROVIDERS.LETSENCRYPT, + config, + nodeType: 'fullnode', + preset: 'testnet', + }; + + const providerTasks = await configureSSLCertificateTask().tasks[0] + .task(context, { prompt: this.sinon.stub() }); + + const prompt = this.sinon.stub(); + const result = await providerTasks.tasks[0].task(context, { prompt }); + + expect(result).to.equal('obtain-task'); + expect(prompt).to.not.have.been.called(); + expect(config.set).to.not.have.been.calledWith( + 'platform.gateway.ssl.providerConfigs.letsencrypt.email', + ); + }); }); diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js index 5fe7b95e497..0fbc2aa3cea 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js @@ -4,6 +4,8 @@ import { Listr } from 'listr2'; import HomeDir from '../../../../src/config/HomeDir.js'; import obtainLetsEncryptCertificateTaskFactory from '../../../../src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js'; import getBaseConfigFactory from '../../../../configs/defaults/getBaseConfigFactory.js'; +import getEnquirerMock from '../../../../src/test/mock/getEnquirerMock.js'; +import { ERRORS } from '../../../../src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js'; describe('obtainLetsEncryptCertificateTaskFactory', () => { it('should reject a plaintext ACME directory before lego starts', async function it() { @@ -275,4 +277,293 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { homeDir.remove(); } }); + + describe('contactless issuance', () => { + let homeDir; + let config; + let legoDir; + + beforeEach(() => { + homeDir = HomeDir.createTemp(); + config = getBaseConfigFactory(homeDir)(); + config.set('externalIp', '1.2.3.4'); + config.set('platform.gateway.ssl.providerConfigs.letsencrypt.email', null); + legoDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'lego'); + }); + + afterEach(() => homeDir.remove()); + + /** + * A lego container that succeeds and leaves behind the files lego would. + * + * @param {Object} sinon + * @param {number} [statusCode] + * @return {Object} + */ + function getDockerMock(sinon, statusCode = 0) { + const missing = Object.assign(new Error('container not found'), { statusCode: 404 }); + + return { + getContainer: sinon.stub().rejects(missing), + createContainer: sinon.stub().resolves({ + start: sinon.stub().resolves(), + logs: sinon.stub().resolves(Buffer.from('Timeout during connect (likely firewall problem)')), + wait: sinon.stub().callsFake(async () => { + if (statusCode === 0) { + const certificates = path.join(legoDir, 'certificates'); + fs.mkdirSync(certificates, { recursive: true }); + fs.writeFileSync(path.join(certificates, '1.2.3.4.crt'), 'certificate'); + fs.writeFileSync(path.join(certificates, '1.2.3.4.key'), 'key'); + } + + return { StatusCode: statusCode }; + }), + }), + }; + } + + /** + * @param {Object} sinon + * @param {Object} [options] + * @return {Object} + */ + function buildTask(sinon, { docker, validate, save } = {}) { + return obtainLetsEncryptCertificateTaskFactory( + docker ?? getDockerMock(sinon), + sinon.stub().resolves(), + { addContainer: sinon.stub() }, + homeDir, + validate ?? sinon.stub().resolves({ error: ERRORS.CERTIFICATE_NOT_FOUND, data: {} }), + save ?? sinon.stub().callsFake(() => new Listr([{ task: () => {} }])), + null, + {}, + ); + } + + // No new node will have an email: nothing prompts for one any more. A + // throw left anywhere on this path breaks every fresh setup. + it('should obtain a certificate with no email configured', async function it() { + const docker = getDockerMock(this.sinon); + const saveCertificateTask = this.sinon.stub().callsFake(() => new Listr([{ task: () => {} }])); + + await buildTask(this.sinon, { docker, save: saveCertificateTask })(config).run({ force: true }); + + expect(saveCertificateTask).to.have.been.calledOnce(); + expect(config.get('platform.gateway.ssl.provider')).to.equal('letsencrypt'); + }); + + // lego keys its on-disk ACME account directory by the email string, so an + // empty --email is a different account from no --email at all. Passing one + // would silently register a new account. + it('should pass no --email argument when none is configured', async function it() { + const docker = getDockerMock(this.sinon); + + await buildTask(this.sinon, { docker })(config).run({ force: true }); + + const { Cmd } = docker.createContainer.firstCall.firstArg; + expect(Cmd).to.not.include('--email'); + expect(Cmd).to.not.include(''); + }); + + it('should still pass an email that is configured', async function it() { + const docker = getDockerMock(this.sinon); + config.set('platform.gateway.ssl.providerConfigs.letsencrypt.email', 'operator@example.com'); + + await buildTask(this.sinon, { docker })(config).run({ force: true }); + + const { Cmd } = docker.createContainer.firstCall.firstArg; + expect(Cmd).to.include('--email'); + expect(Cmd).to.include('operator@example.com'); + }); + + // The helper schedules this exact path whenever the pair is not installed, + // so falling through to the default case makes an affected node throw + // "Unknown error" hourly, forever, with no route out. + it('should install a valid certificate that never reached the gateway', async function it() { + const docker = getDockerMock(this.sinon); + const saveCertificateTask = this.sinon.stub().callsFake(() => new Listr([{ task: () => {} }])); + const validate = this.sinon.stub().resolves({ + error: ERRORS.CERTIFICATE_NOT_INSTALLED, + data: { certificate: { expires: new Date() }, isCertificatePairInstalled: false }, + }); + + // The pair lego already issued is on disk; only the gateway's copy of it + // is missing, which is the whole point of this case. + const certificates = path.join(legoDir, 'certificates'); + fs.mkdirSync(certificates, { recursive: true }); + fs.writeFileSync(path.join(certificates, '1.2.3.4.crt'), 'certificate'); + fs.writeFileSync(path.join(certificates, '1.2.3.4.key'), 'key'); + + await buildTask(this.sinon, { docker, validate, save: saveCertificateTask })(config).run({}); + + expect(saveCertificateTask).to.have.been.calledOnce(); + // Nothing is re-issued: the certificate already exists, it was just never + // copied to where the gateway reads it. + expect(docker.createContainer).to.not.have.been.called(); + }); + }); + + describe('port 80 retry loop', () => { + let homeDir; + let config; + let legoDir; + + beforeEach(() => { + homeDir = HomeDir.createTemp(); + config = getBaseConfigFactory(homeDir)(); + config.set('externalIp', '1.2.3.4'); + legoDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'lego'); + }); + + afterEach(() => homeDir.remove()); + + /** + * @param {Object} sinon + * @return {Object} + */ + function getFailingDockerMock(sinon) { + const missing = Object.assign(new Error('container not found'), { statusCode: 404 }); + + return { + getContainer: sinon.stub().rejects(missing), + createContainer: sinon.stub().resolves({ + start: sinon.stub().resolves(), + logs: sinon.stub().resolves( + Buffer.from('Timeout during connect (likely firewall problem)'), + ), + wait: sinon.stub().resolves({ StatusCode: 1 }), + }), + }; + } + + /** + * @param {Object} sinon + * @param {Object} docker + * @return {Function} + */ + function buildFailingTask(sinon, docker) { + return obtainLetsEncryptCertificateTaskFactory( + docker, + sinon.stub().resolves(), + { addContainer: sinon.stub() }, + homeDir, + sinon.stub().resolves({ error: ERRORS.CERTIFICATE_NOT_FOUND, data: {} }), + sinon.stub(), + null, + {}, + ); + } + + /** + * @param {Object} tasks + * @param {Object} enquirer + * @return {Object} + */ + function inject(tasks, enquirer) { + // eslint-disable-next-line no-param-reassign + tasks.options.injectWrapper = { enquirer }; + + return tasks; + } + + // Every attempt spends one of Let's Encrypt's five failed authorizations + // per hour, and that budget is shared with the helper's renewal of a + // still-valid certificate. An immediate retry cannot succeed anyway: the + // operator has not left the terminal to change a firewall rule. + it('should offer a capped retry that defaults to No', async function it() { + const docker = getFailingDockerMock(this.sinon); + const enquirer = getEnquirerMock(this.sinon, true, true); + + const tasks = inject(buildFailingTask(this.sinon, docker)(config), enquirer); + + await expect(tasks.run({ force: true, interactive: true })).to.be.rejected(); + + expect(docker.createContainer).to.have.been.calledThrice(); + expect(enquirer.prompt).to.have.been.calledTwice(); + expect(enquirer.options[0].initial).to.equal(false); + expect(enquirer.options[0].message).to.contain('[attempt 2 of 3]'); + expect(enquirer.options[1].message).to.contain('[attempt 3 of 3]'); + }); + + it('should stop as soon as the operator declines', async function it() { + const docker = getFailingDockerMock(this.sinon); + const enquirer = getEnquirerMock(this.sinon, false); + + const tasks = inject(buildFailingTask(this.sinon, docker)(config), enquirer); + + await expect(tasks.run({ force: true, interactive: true })).to.be.rejected(); + + expect(docker.createContainer).to.have.been.calledOnce(); + }); + + // A long-failing address may be paused rather than rate-limited, and + // waiting never clears a pause. Telling the operator to come back in N + // minutes would be wrong for exactly the nodes that have been dark longest. + it('should give up with guidance that does not promise waiting will help', async function it() { + const docker = getFailingDockerMock(this.sinon); + const enquirer = getEnquirerMock(this.sinon, false); + + const tasks = inject(buildFailingTask(this.sinon, docker)(config), enquirer); + + const error = await tasks.run({ force: true, interactive: true }).catch((e) => e); + + expect(error.message).to.contain('https://letsencrypt.org/docs/rate-limits/'); + expect(error.message).to.contain('PAUSED'); + expect(error.message).to.contain(`--config ${config.getName()}`); + expect(error.message).to.contain('renews under the same'); + expect(error.message).to.not.match(/come back in \d/i); + }); + + // The retry is a prompt, and a prompt reached unattended never settles. + // Gating it on anything less than a positive opt-in leaves the helper's + // hourly renewal one refactor away from hanging in a container forever. + it('should never construct a prompt when the session cannot answer', async function it() { + const docker = getFailingDockerMock(this.sinon); + const enquirer = getEnquirerMock(this.sinon, true); + + const tasks = inject(buildFailingTask(this.sinon, docker)(config), enquirer); + + await expect(tasks.run({ force: true })).to.be.rejected(); + + expect(enquirer.prompt).to.not.have.been.called(); + expect(docker.createContainer).to.have.been.calledOnce(); + }); + + it('should honour no-retry even for an operator at a terminal', async function it() { + const docker = getFailingDockerMock(this.sinon); + const enquirer = getEnquirerMock(this.sinon, true); + + const tasks = inject(buildFailingTask(this.sinon, docker)(config), enquirer); + + await expect(tasks.run({ force: true, interactive: true, noRetry: true })).to.be.rejected(); + + expect(enquirer.prompt).to.not.have.been.called(); + expect(docker.createContainer).to.have.been.calledOnce(); + }); + + // The gate runs the obtain without --force, so a node whose certificate is + // still valid never reaches lego however often update is run - which is + // what keeps repeated runs off the five-certificates-per-week budget. + it('should not reach lego while the certificate is still valid', async function it() { + const docker = getFailingDockerMock(this.sinon); + const validate = this.sinon.stub().resolves({ + data: { certificate: { expires: new Date() }, isCertificatePairInstalled: true }, + }); + + const tasks = obtainLetsEncryptCertificateTaskFactory( + docker, + this.sinon.stub().resolves(), + { addContainer: this.sinon.stub() }, + homeDir, + validate, + this.sinon.stub(), + null, + {}, + )(config); + + await tasks.run({}); + + expect(docker.createContainer).to.not.have.been.called(); + }); + }); }); diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js index a933d62595a..c392f27526c 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js @@ -42,6 +42,33 @@ describe('validateLetsEncryptCertificateFactory', () => { expect(ERRORS.CERTIFICATE_NOT_INSTALLED).to.equal('CERTIFICATE_NOT_INSTALLED'); }); + // The email check used to fire before every other one, so a node without a + // contact address reported EMAIL_IS_NOT_SET whatever else was wrong with its + // certificate. Nothing prompts for an address any more, so no new node has + // one and this would have become the answer for all of them - including for + // the helper's own renewal scheduler. + it('should judge a certificate for a node that has no contact address', async function it() { + config.get.callsFake((option) => ({ + 'platform.gateway.ssl.providerConfigs.letsencrypt.email': null, + externalIp: EXTERNAL_IP, + }[option])); + + const { error } = await validateLetsEncryptCertificate(config); + + expect(error).to.equal(ERRORS.CERTIFICATE_NOT_FOUND); + }); + + it('should still report a missing external IP ahead of anything else', async function it() { + config.get.callsFake((option) => ({ + 'platform.gateway.ssl.providerConfigs.letsencrypt.email': null, + externalIp: null, + }[option])); + + const { error } = await validateLetsEncryptCertificate(config); + + expect(error).to.equal(ERRORS.EXTERNAL_IP_IS_NOT_SET); + }); + it('should report no problem when the issued certificate is the one the gateway uses', async () => { const { cert, key } = issueCertificate(); diff --git a/packages/dashmate/test/unit/ssl/saveCertificateTask.spec.js b/packages/dashmate/test/unit/ssl/saveCertificateTask.spec.js index 3f17e995cc6..d5201358e8c 100644 --- a/packages/dashmate/test/unit/ssl/saveCertificateTask.spec.js +++ b/packages/dashmate/test/unit/ssl/saveCertificateTask.spec.js @@ -3,6 +3,7 @@ import path from 'path'; import HomeDir from '../../../src/config/HomeDir.js'; import getBaseConfigFactory from '../../../configs/defaults/getBaseConfigFactory.js'; import saveCertificateTaskFactory from '../../../src/listr/tasks/ssl/saveCertificateTask.js'; +import { issueCertificate } from '../../../src/test/certificateFixtures.js'; describe('saveCertificateTaskFactory', () => { let homeDir; @@ -11,8 +12,10 @@ describe('saveCertificateTaskFactory', () => { let certificatePath; let keyPath; let previousUmask; + let pair; beforeEach(() => { + pair = issueCertificate({ ip: '1.2.3.4' }); previousUmask = process.umask(0o022); homeDir = HomeDir.createTemp(); config = getBaseConfigFactory(homeDir)(); @@ -32,12 +35,13 @@ describe('saveCertificateTaskFactory', () => { homeDir.remove(); }); - async function savePair() { + async function savePair(context = {}) { const task = saveCertificateTaskFactory(homeDir)(config); await task.run({ - certificateFile: 'new-certificate', - privateKeyFile: 'new-key', + certificateFile: pair.pem, + privateKeyFile: pair.keyPem, + ...context, }); } @@ -64,8 +68,8 @@ describe('saveCertificateTaskFactory', () => { expect(fs.statSync(certificatePath).ino).to.equal(certificateInode); expect(fs.statSync(keyPath).ino).to.equal(keyInode); - expect(fs.readFileSync(certificatePath, 'utf8')).to.equal('new-certificate'); - expect(fs.readFileSync(keyPath, 'utf8')).to.equal('new-key'); + expect(fs.readFileSync(certificatePath, 'utf8')).to.equal(pair.pem); + expect(fs.readFileSync(keyPath, 'utf8')).to.equal(pair.keyPem); }); it('should create a private key with mode 0600', async () => { @@ -134,4 +138,43 @@ describe('saveCertificateTaskFactory', () => { expect(mode(keyPath)).to.equal(0o400); }); + + // The bundle and the key are two separate in-place writes - in place because + // the bind mount follows the inode - so a full disk, a failed chmod or a + // power loss between them leaves a new certificate paired with the old key. + // With the gateway stopped, as the documented upgrade procedure leaves it, + // nothing else would notice: the command reports success and the node simply + // fails to come back up at the next `dashmate start`, a step removed from + // whatever caused it. + it('should refuse to report success when the written pair does not match', async function it() { + const other = issueCertificate({ ip: '1.2.3.4' }); + + await expect(savePair({ privateKeyFile: other.keyPem })) + .to.be.rejectedWith(/do not match/i); + }); + + it('should name the repair when the written pair does not match', async function it() { + const other = issueCertificate({ ip: '1.2.3.4' }); + + const error = await savePair({ privateKeyFile: other.keyPem }).catch((e) => e); + + expect(error.message).to.contain(`--config ${config.getName()}`); + expect(error.message).to.contain('dashmate ssl obtain'); + }); + + // Models the write that fails without throwing: the certificate lands, the + // key never does, and the old key is left in place. + it('should catch a key that never reached the disk', async function it() { + await savePair(); + + const renewed = issueCertificate({ ip: '1.2.3.4' }); + const writeFileSync = this.sinon.stub(fs, 'writeFileSync'); + writeFileSync.callThrough(); + writeFileSync.withArgs(keyPath).returns(undefined); + + await expect(savePair({ + certificateFile: renewed.pem, + privateKeyFile: renewed.keyPem, + })).to.be.rejectedWith(/do not match/i); + }); }); diff --git a/packages/dashmate/test/unit/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.spec.js index 5c7feb47988..20b5266367a 100644 --- a/packages/dashmate/test/unit/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.spec.js @@ -5,6 +5,7 @@ import ConfigFile from '../../../../src/config/configFile/ConfigFile.js'; import ConfigFileJsonRepository from '../../../../src/config/configFile/ConfigFileJsonRepository.js'; import getBaseConfigFactory from '../../../../configs/defaults/getBaseConfigFactory.js'; import { ERRORS } from '../../../../src/ssl/zerossl/validateZeroSslCertificateFactory.js'; +import getEnquirerMock from '../../../../src/test/mock/getEnquirerMock.js'; describe('obtainZeroSSLCertificateTaskFactory', () => { let config; @@ -318,4 +319,82 @@ describe('obtainZeroSSLCertificateTaskFactory', () => { homeDir.remove(); } }); + + describe('domain verification retry', () => { + let verifyDomain; + let enquirer; + let buildTask; + + beforeEach(function beforeEach() { + config.set = this.sinon.stub(); + verifyDomain = this.sinon.stub().rejects( + Object.assign(new Error('domain control validation failed'), { code: 1 }), + ); + enquirer = getEnquirerMock(this.sinon, false); + + buildTask = (sinon) => obtainZeroSSLCertificateTaskFactory( + sinon.stub().resolves('csr'), + sinon.stub().resolves({ privateKey: 'private', publicKey: 'public' }), + sinon.stub().resolves({ + id: 'certificate-id', + status: 'pending_validation', + validation: { + other_methods: { + '1.2.3.4': { + file_validation_url_http: 'http://1.2.3.4/.well-known/x', + file_validation_content: 'content', + }, + }, + }, + }), + verifyDomain, + sinon.stub(), + sinon.stub(), + sinon.stub(), + sinon.stub(), + verificationServer, + { joinPath: sinon.stub().returns('/tmp') }, + sinon.stub(), + ); + }); + + /** + * @param {Object} context + * @return {Promise} + */ + function run(context) { + const tasks = buildTask(this.sinon)(config, { + onCertificateCreated: this.sinon.stub(), + }); + + tasks.options.injectWrapper = { enquirer }; + + return tasks.run({ + force: true, externalIp: '1.2.3.4', apiKey: 'api-key', ...context, + }); + } + + // This loop was gated only on noRetry, which is fail-open: it prompts + // unless a caller remembers to say otherwise. The helper's unattended + // renewal is safe today only because it happens to pass noRetry, and a + // prompt reached in that container never settles and never releases the + // config lock - which then blocks every other dashmate command forever. + it('should not construct a prompt when the session cannot answer', async function it() { + await expect(run.call(this, {})).to.be.rejected(); + + expect(enquirer.prompt).to.not.have.been.called(); + }); + + it('should still ask an operator who is at a terminal', async function it() { + await expect(run.call(this, { interactive: true })).to.be.rejected(); + + expect(enquirer.prompt).to.have.been.calledOnce(); + }); + + it('should honour no-retry at a terminal', async function it() { + await expect(run.call(this, { interactive: true, noRetry: true })).to.be.rejected(); + + expect(enquirer.prompt).to.not.have.been.called(); + }); + }); }); From e2ee5a4e36385313ac229d421fce1f0fdc46d398 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 10:17:51 +0700 Subject: [PATCH 05/63] feat(dashmate): check the gateway certificate on update 88 of 353 mainnet evonodes serve an expired certificate and 38 of them were running the newest release while doing it, because nothing in the update path looks. `dashmate update` is the one moment where an engaged operator is at a terminal and has decided to do maintenance - 75% of the network took the last non-mandatory point release within a day - so that is where this looks. The certificate is dealt with first and the images second, while the images are already downloading. The check therefore costs no wall-clock time: the operator reads and answers the prompt while the pull runs. ORDERING. The pull is created before the list and settled at creation via .then(ok, err), so the minutes task 1 may spend at a prompt are not a window in which an unhandled rejection can take the process down - updateNode is async and calls getServiceList synchronously, and docker.pull can throw synchronously inside its own executor, so it really can reject. It is awaited and reported from an outer finally as well as from task 2, guarded so it happens exactly once. The list runs with exitOnError: false, measured against the pinned listr2 5.0.7 where it defaults to true; without it a throwing certificate task would skip the pull report entirely and hide the table, including any image that failed to download. There is no task.fail() in that version - typeof is undefined - so throwing a sentinel is the only way to render the task as failed, and errors are partitioned afterwards so a lost lock or a programming bug is rethrown rather than reduced to a certificate message. WHAT IT REFUSES TO DO. Images are always pulled, whatever the verdict: `update` pulls and `restart` applies, so withholding images would deny protocol activations and security patches without protecting anything. Nothing is acted on unattended - a non-interactive run reports and exits 1, because changing an operator's certificate authority without asking is a configuration change they did not request, and it would replace a diagnosis with a silent failure. Nothing blocks on a certificate that passed; someone who bought one is never nagged. The provider is persisted only after a certificate exists to back it. Writing it first and then failing the obtain converts a node working with an expiring certificate into a broken one: configuration would name an authority it has no account with, and the helper's watcher would reschedule renewal against it within the minute, forever. If the lock is lost between the two, it refuses to write - which leaves precisely the interrupted-switch state the checker detects and the next run converges on. A failed courtesy migration is judged by what it left behind rather than by where it failed: an obtain that never touched the gateway files leaves the node as it was and is a warning, while one that damaged the installed pair is an error. The two writes in saveCertificateTask are separate and in place, so that distinction is real. FLAGS. --skip-certificate-check bypasses enforcement and remediation only; the check still runs and the warning names the actual status, so a playbook carrying it keeps surfacing the problem. --non-interactive never prompts. --check-certificate is a strictly read-only preflight - no pull, no prompt, no write, no reload - meant to be run before `dashmate stop`, which is why it also opts out of the configuration lock: BaseCommand now lets a command that declares mutatesConfig exclude a mode that changes nothing, so a preflight cannot fail on a lock timeout. Both bypasses also read DASHMATE_* environment variables, because a playbook cannot carry a flag the currently installed binary would reject. The guidance is written to stderr directly and never through oclif's error printer, which hard-wraps at 74 columns on a non-TTY stream and would break the longest remediation line mid-token. Every command it prints carries the selected --config: without it an operator running several nodes who pastes a bare command acts on a different one. Every claim is limited to what was observed - the check reads disk, so it says "if this is the certificate the gateway is serving" rather than asserting clients failed, and it leads with node state when the node is down, which is the common case under the documented stop-first procedure. The setup wizard's file-provider flow is extracted so the check can offer an operator with their own certificate the chance to replace it before it suggests changing authority. Tests: 55 new or rewritten. - The two existing update tests went red on the restructure and were rethreaded with the new dependencies; both still assert docker.pull is reached. - Orchestration proved red by reverting exitOnError to the listr2 default (3 failed) and by reporting the guidance before the table (4 failed). - Atomicity proved red against a build that persists the provider before the obtain, exactly as the design requires (2 failed). - Phase-awareness proved red against a build that reports every failed courtesy migration as a warning (1 failed). - The remaining message, scope, flag and no-prompt cases cover code that did not exist before this commit. Co-Authored-By: Claude Opus 5 --- packages/dashmate/src/commands/update.js | 277 ++++++++++-- packages/dashmate/src/createDIContainer.js | 6 + .../configureSSLCertificateTaskFactory.js | 61 +-- .../ssl/installCertificateFilesTaskFactory.js | 88 ++++ .../update/gatewayCertificateTaskFactory.js | 413 +++++++++++++++++ .../dashmate/src/oclif/command/BaseCommand.js | 21 +- .../ssl/errors/CertificateUnresolvedError.js | 28 ++ .../src/ssl/renderCertificateGuidance.js | 242 ++++++++++ .../test/unit/commands/update.spec.js | 377 ++++++++++++++-- .../gatewayCertificateTaskFactory.spec.js | 416 ++++++++++++++++++ .../ssl/renderCertificateGuidance.spec.js | 195 ++++++++ 11 files changed, 2008 insertions(+), 116 deletions(-) create mode 100644 packages/dashmate/src/listr/tasks/ssl/installCertificateFilesTaskFactory.js create mode 100644 packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js create mode 100644 packages/dashmate/src/ssl/errors/CertificateUnresolvedError.js create mode 100644 packages/dashmate/src/ssl/renderCertificateGuidance.js create mode 100644 packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js create mode 100644 packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js diff --git a/packages/dashmate/src/commands/update.js b/packages/dashmate/src/commands/update.js index dce6b2c6790..4a431c489f2 100644 --- a/packages/dashmate/src/commands/update.js +++ b/packages/dashmate/src/commands/update.js @@ -1,10 +1,53 @@ import { Flags } from '@oclif/core'; import chalk from 'chalk'; -import { OUTPUT_FORMATS } from '../constants.js'; +import { Listr } from 'listr2'; + +import { NETWORK_MAINNET, NETWORK_TESTNET, OUTPUT_FORMATS } from '../constants.js'; import ConfigBaseCommand from '../oclif/command/ConfigBaseCommand.js'; +import MuteOneLineError from '../oclif/errors/MuteOneLineError.js'; import printArrayOfObjects from '../printers/printArrayOfObjects.js'; +import CertificateUnresolvedError from '../ssl/errors/CertificateUnresolvedError.js'; +import { CERTIFICATE_STATUS } from '../ssl/checkGatewayCertificateFactory.js'; +import renderCertificateGuidance from '../ssl/renderCertificateGuidance.js'; +import isInteractiveSession from '../util/isInteractiveSession.js'; + +/** + * Networks whose certificate has to be publicly trusted. Local and devnet nodes + * are self-signed by design and disposable. + */ +const GATED_NETWORKS = [NETWORK_MAINNET, NETWORK_TESTNET]; + +/** + * @param {string|undefined} value + * @return {boolean} + */ +function isEnvironmentFlagSet(value) { + if (value === undefined || value === null) { + return false; + } + + const normalized = String(value).trim().toLowerCase(); + + return normalized !== '' && normalized !== '0' && normalized !== 'false'; +} export default class UpdateCommand extends ConfigBaseCommand { + // The certificate check can obtain a certificate and record the provider that + // issued it, so it holds the configuration lock for its whole run. + static mutatesConfig = true; + + /** + * The read-only preflight changes nothing, and it exists to be run before the + * node is stopped - possibly while the helper is renewing. Taking a write + * lock there would let it fail on a lock timeout for no reason. + * + * @param {Object} flags + * @return {boolean} + */ + static shouldSkipConfigLock(flags) { + return flags['check-certificate'] === true; + } + static description = 'Update node software'; static flags = { @@ -14,47 +57,231 @@ export default class UpdateCommand extends ConfigBaseCommand { default: OUTPUT_FORMATS.PLAIN, options: Object.values(OUTPUT_FORMATS), }), + 'skip-certificate-check': Flags.boolean({ + description: 'do not act on the gateway certificate check. It still runs and still reports,' + + ' but nothing is prompted, obtained or blocked. Also DASHMATE_SKIP_CERTIFICATE_CHECK', + default: false, + }), + 'non-interactive': Flags.boolean({ + description: 'never prompt. The certificate is checked and reported, nothing is obtained or' + + ' changed. Also DASHMATE_NON_INTERACTIVE. Use CI=0 to prompt on a machine that exports CI', + default: false, + }), + 'check-certificate': Flags.boolean({ + description: 'only report on the gateway certificate and exit. Pulls no images, prompts for' + + ' nothing and changes nothing. Safe to run before dashmate stop', + default: false, + }), }; /** * @param {Object} args - * @param {string} format + * @param {Object} flags * @param {docker} docker * @param {Config} config - * @param updateNode + * @param {updateNode} updateNode + * @param {checkGatewayCertificate} checkGatewayCertificate + * @param {gatewayCertificateTask} gatewayCertificateTask + * @param {DockerCompose} dockerCompose * @return {Promise} */ async runWithDependencies( args, - { - format, - }, + flags, docker, config, updateNode, + checkGatewayCertificate, + gatewayCertificateTask, + dockerCompose, ) { - const updateInfo = await updateNode(config); + const { + format, + verbose: isVerbose, + 'check-certificate': checkCertificateOnly, + } = flags; + + const skipCertificateCheck = flags['skip-certificate-check'] === true + || isEnvironmentFlagSet(process.env.DASHMATE_SKIP_CERTIFICATE_CHECK); + + const interactive = isInteractiveSession({ flags }); - const colors = { - updated: chalk.yellow, - 'up to date': chalk.green, - error: chalk.red, + const isGated = config.get('platform.enable') === true + && GATED_NETWORKS.includes(config.get('network')); + + /** + * @param {Object} verdict + * @return {Promise} + */ + const reportUnresolved = async (verdict) => { + let isNodeRunning = false; + try { + isNodeRunning = await dockerCompose.isServiceRunning(config, 'gateway'); + } catch { + // Docker being unavailable says nothing about the certificate, and the + // node-state line is a courtesy rather than part of the verdict. + } + + process.stderr.write(renderCertificateGuidance({ + config, + verdict, + isNodeRunning, + pull: this.pullResult ?? null, + })); }; - // Draw table or show json - printArrayOfObjects(updateInfo - .reduce( - (acc, { + // Reports only. No pull is started, nothing is prompted, obtained, written + // or reloaded - this is what an operator can run before stopping the node. + if (checkCertificateOnly) { + if (!isGated) { + return; + } + + const verdict = checkGatewayCertificate(config); + + process.stderr.write(`${JSON.stringify({ + status: verdict.status, + reasons: verdict.reasons.map(({ code }) => code), + warnings: verdict.warnings.map(({ code }) => code), + provider: verdict.provider, + config: config.getName(), + expiresAt: verdict.installed ? verdict.installed.validTo.toISOString() : null, + })}\n`); + + if (verdict.status === CERTIFICATE_STATUS.INVALID) { + await reportUnresolved(verdict); + + throw new MuteOneLineError(new CertificateUnresolvedError(verdict)); + } + + return; + } + + // A prompt that leaks past the interactivity guard neither throws nor + // settles: the event loop simply drains and the process exits 0 with + // nothing done. Failing closed here turns that silence into an exit code. + process.exitCode = 1; + + // Both handlers are attached the moment the pull is created, so the minutes + // the certificate task may spend at a prompt are not a window in which an + // unhandled rejection can take the process down. updateNode is async and + // calls getServiceList synchronously, and docker.pull can throw + // synchronously inside its executor, so this promise really can reject. + const settled = updateNode(config).then( + (info) => ({ ok: true, info }), + (error) => ({ ok: false, error }), + ); + + let pullReported = false; + + const reportPull = async () => { + if (pullReported) { + return; + } + pullReported = true; + + const result = await settled; + + if (!result.ok) { + this.pullResult = { ok: false, failed: 0, total: 0 }; + + process.stderr.write(`Failed to pull images: ${result.error.message}\n`); + + return; + } + + this.pullResult = { + ok: true, + failed: result.info.filter(({ updated }) => updated === 'error').length, + total: result.info.length, + }; + + const colors = { + updated: chalk.yellow, + 'up to date': chalk.green, + error: chalk.red, + }; + + printArrayOfObjects(result.info.map(({ + name, title, updated, image, + }) => (format === OUTPUT_FORMATS.PLAIN + ? { Service: title, Image: image, Updated: colors[updated](updated) } + : { name, title, updated, image, - }) => ([ - ...acc, - format === OUTPUT_FORMATS.PLAIN - ? { Service: title, Image: image, Updated: colors[updated](updated) } - : { - name, title, updated, image, - }, - ]), - [], - ), format); + })), format); + }; + + const tasks = new Listr( + [ + { + title: 'Gateway certificate', + enabled: () => isGated, + task: gatewayCertificateTask(config, { interactive, skipCertificateCheck }), + }, + { + title: 'Update node software', + task: () => reportPull(), + }, + ], + { + // The certificate task signals an unresolved certificate by throwing, + // because throwing is the only way to render a listr2 task as failed. + // Without this the throw would skip the pull report entirely, hiding + // the table - including any image that failed to download. + exitOnError: false, + renderer: format === OUTPUT_FORMATS.JSON ? 'silent' : 'default', + rendererOptions: { + showTimer: isVerbose, + clearOutput: false, + collapse: false, + showSubtasks: true, + removeEmptyLines: false, + }, + }, + ); + + const context = {}; + + try { + await tasks.run(context); + } finally { + // Covers what task 2 cannot: an exception from run() itself, or from the + // reporting path. The guard makes the second call harmless, and the table + // is rendered before any failure is reported either way. + await reportPull(); + } + + // listr2 wraps what a task threw, so the sentinel is one level down. + const errors = (tasks.err ?? []).map((error) => error?.error ?? error); + const unresolved = errors.find((error) => error instanceof CertificateUnresolvedError); + const unexpected = errors.find((error) => !(error instanceof CertificateUnresolvedError)); + + (context.certificateWarnings ?? []).forEach((warning) => { + process.stderr.write(`${warning}\n\n`); + }); + + if (context.certificateSkipped) { + process.stderr.write(`Gateway certificate enforcement was skipped.` + + ` The check still ran and its status is ${context.certificate.status}.\n\n`); + } + + if (context.certificateSuccess) { + process.stderr.write(`${context.certificateSuccess}\n`); + } + + // A lost lock, a failed reload or a programming error is a real failure and + // must not be reduced to a certificate message. exitOnError would otherwise + // have swallowed it. + if (unexpected) { + throw unexpected; + } + + if (unresolved) { + await reportUnresolved(unresolved.getVerdict()); + + throw new MuteOneLineError(unresolved); + } + + process.exitCode = 0; } } diff --git a/packages/dashmate/src/createDIContainer.js b/packages/dashmate/src/createDIContainer.js index 97a4ff67671..791bca53a5d 100644 --- a/packages/dashmate/src/createDIContainer.js +++ b/packages/dashmate/src/createDIContainer.js @@ -96,6 +96,9 @@ import obtainZeroSSLCertificateTaskFactory from './listr/tasks/ssl/zerossl/obtai import obtainLetsEncryptCertificateTaskFactory from './listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js'; import VerificationServer from './listr/tasks/ssl/VerificationServer.js'; import saveCertificateTaskFactory from './listr/tasks/ssl/saveCertificateTask.js'; +import installCertificateFilesTaskFactory from './listr/tasks/ssl/installCertificateFilesTaskFactory.js'; +import checkGatewayCertificateFactory from './ssl/checkGatewayCertificateFactory.js'; +import gatewayCertificateTaskFactory from './listr/tasks/update/gatewayCertificateTaskFactory.js'; import createZeroSSLCertificate from './ssl/zerossl/createZeroSSLCertificate.js'; import verifyDomain from './ssl/zerossl/verifyDomain.js'; @@ -329,6 +332,8 @@ export default async function createDIContainer(options = {}) { obtainLetsEncryptCertificateTask: asFunction(obtainLetsEncryptCertificateTaskFactory) .singleton(), saveCertificateTask: asFunction(saveCertificateTaskFactory), + installCertificateFilesTask: asFunction(installCertificateFilesTaskFactory).singleton(), + gatewayCertificateTask: asFunction(gatewayCertificateTaskFactory).singleton(), reindexNodeTask: asFunction(reindexNodeTaskFactory).singleton(), getCoreScope: asFunction(getCoreScopeFactory).singleton(), getMasternodeScope: asFunction(getMasternodeScopeFactory).singleton(), @@ -354,6 +359,7 @@ export default async function createDIContainer(options = {}) { container.register({ validateZeroSslCertificate: asFunction(validateZeroSslCertificateFactory).singleton(), validateLetsEncryptCertificate: asFunction(validateLetsEncryptCertificateFactory).singleton(), + checkGatewayCertificate: asFunction(checkGatewayCertificateFactory).singleton(), getCertificate: asValue(getCertificate), }); diff --git a/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js index 6d4e10bf716..f1067950fb6 100644 --- a/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js @@ -1,19 +1,15 @@ -import fs from 'fs'; import { Listr } from 'listr2'; -import validateSslCertificateFiles from '../../../prompts/validators/validateSslCertificateFiles.js'; - import { PRESET_MAINNET, SSL_PROVIDERS, NODE_TYPE_FULLNODE, } from '../../../../constants.js'; -import validateFileExists from '../../../prompts/validators/validateFileExists.js'; import listCertificates from '../../../../ssl/zerossl/listCertificates.js'; /** - * @param {saveCertificateTask} saveCertificateTask + * @param {installCertificateFilesTask} installCertificateFilesTask * @param {obtainZeroSSLCertificateTask} obtainZeroSSLCertificateTask * @param {obtainSelfSignedCertificateTask} obtainSelfSignedCertificateTask * @param {obtainLetsEncryptCertificateTask} obtainLetsEncryptCertificateTask @@ -22,7 +18,7 @@ import listCertificates from '../../../../ssl/zerossl/listCertificates.js'; * @returns {configureSSLCertificateTask} */ export default function configureSSLCertificateTaskFactory( - saveCertificateTask, + installCertificateFilesTask, obtainZeroSSLCertificateTask, obtainSelfSignedCertificateTask, obtainLetsEncryptCertificateTask, @@ -38,58 +34,7 @@ export default function configureSSLCertificateTaskFactory( [SSL_PROVIDERS.FILE]: { title: 'Set SSL certificate file', enabled: (ctx) => ctx.certificateProvider === SSL_PROVIDERS.FILE, - task: async (ctx, task) => { - let form = ctx.fileCertificateProviderForm; - - if (!ctx.fileCertificateProviderForm) { - form = await task.prompt({ - type: 'form', - header: ` To configure SSL certificates, you need to provide a certificate chain file - and a private key file. - The certificate chain file should contain your server certificate at the top and - then intermediate/root certificates if present.\n`, - message: 'Specify paths to your certificate files', - choices: [ - { - name: 'chainFilePath', - message: 'Path to certificate chain file', - validate: validateFileExists, - }, - { - name: 'privateFilePath', - message: 'Path to certificate key file', - validate: validateFileExists, - }, - ], - validate: ({ chainFilePath, privateFilePath }) => { - if (!validateFileExists(chainFilePath)) { - return 'certificate chain file path is not valid'; - } - - if (!validateFileExists(privateFilePath)) { - return 'certificate key file path is not valid'; - } - - if (chainFilePath === privateFilePath) { - return 'the same path for both files'; - } - - const isValid = validateSslCertificateFiles(chainFilePath, privateFilePath); - - if (!isValid) { - return 'The certificate and private key do not match'; - } - - return true; - }, - }); - } - - ctx.certificateFile = fs.readFileSync(form.chainFilePath, 'utf8'); - ctx.privateKeyFile = fs.readFileSync(form.privateFilePath, 'utf8'); - - return saveCertificateTask(ctx.config); - }, + task: async (ctx) => installCertificateFilesTask(ctx.config, { interactive: true }), }, [SSL_PROVIDERS.ZEROSSL]: { title: 'Obtain ZeroSSL certificate', diff --git a/packages/dashmate/src/listr/tasks/ssl/installCertificateFilesTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/installCertificateFilesTaskFactory.js new file mode 100644 index 00000000000..bcf4f5a0949 --- /dev/null +++ b/packages/dashmate/src/listr/tasks/ssl/installCertificateFilesTaskFactory.js @@ -0,0 +1,88 @@ +import fs from 'fs'; +import { Listr } from 'listr2'; + +import promptOrThrow from '../../../util/promptOrThrow.js'; +import validateFileExists from '../../prompts/validators/validateFileExists.js'; +import validateSslCertificateFiles from '../../prompts/validators/validateSslCertificateFiles.js'; + +/** + * @param {saveCertificateTask} saveCertificateTask + * @return {installCertificateFilesTask} + */ +export default function installCertificateFilesTaskFactory(saveCertificateTask) { + /** + * Ask for a certificate chain and key on disk and install them for the + * gateway. + * + * Shared by the setup wizard's "File on disk" provider and by the update + * certificate check, which offers an operator with their own certificate the + * chance to replace it before it suggests changing authority. + * + * @typedef {installCertificateFilesTask} + * @param {Config} config + * @param {Object} [options] + * @param {boolean} [options.interactive] + * @return {Listr} + */ + function installCertificateFilesTask(config, { interactive } = {}) { + return new Listr([ + { + title: 'Set SSL certificate file', + task: async (ctx, task) => { + let form = ctx.fileCertificateProviderForm; + + if (!ctx.fileCertificateProviderForm) { + form = await promptOrThrow(task, { + type: 'form', + header: ` To configure SSL certificates, you need to provide a certificate chain file + and a private key file. + The certificate chain file should contain your server certificate at the top and + then intermediate/root certificates if present.\n`, + message: 'Specify paths to your certificate files', + choices: [ + { + name: 'chainFilePath', + message: 'Path to certificate chain file', + validate: validateFileExists, + }, + { + name: 'privateFilePath', + message: 'Path to certificate key file', + validate: validateFileExists, + }, + ], + validate: ({ chainFilePath, privateFilePath }) => { + if (!validateFileExists(chainFilePath)) { + return 'certificate chain file path is not valid'; + } + + if (!validateFileExists(privateFilePath)) { + return 'certificate key file path is not valid'; + } + + if (chainFilePath === privateFilePath) { + return 'the same path for both files'; + } + + const isValid = validateSslCertificateFiles(chainFilePath, privateFilePath); + + if (!isValid) { + return 'The certificate and private key do not match'; + } + + return true; + }, + }, { interactive }); + } + + ctx.certificateFile = fs.readFileSync(form.chainFilePath, 'utf8'); + ctx.privateKeyFile = fs.readFileSync(form.privateFilePath, 'utf8'); + + return saveCertificateTask(config); + }, + }, + ]); + } + + return installCertificateFilesTask; +} diff --git a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js new file mode 100644 index 00000000000..b33e77ef1f3 --- /dev/null +++ b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js @@ -0,0 +1,413 @@ +import { SSL_PROVIDERS } from '../../../constants.js'; +import ServiceIsNotRunningError from '../../../docker/errors/ServiceIsNotRunningError.js'; +import CertificateUnresolvedError from '../../../ssl/errors/CertificateUnresolvedError.js'; +import { + CERTIFICATE_REASONS, + CERTIFICATE_STATUS, +} from '../../../ssl/checkGatewayCertificateFactory.js'; +import promptOrThrow from '../../../util/promptOrThrow.js'; +import renderConfigFlag from '../../../util/renderConfigFlag.js'; + +/** + * Below this the switch is offered with Yes preselected. Six of the twenty-one + * ZeroSSL certificates still alive on mainnet at the time of the census had a + * week or less left. + */ +const ZEROSSL_URGENT_DAYS = 14; + +/** + * The whole argument for switching, including the port-80 requirement, in the + * prompt header - this is the last moment the operator can go and open a + * firewall rule instead of failing three times. + * + * @param {Config} config + * @param {string} externalIp + * @return {string} + */ +function renderSwitchOffer(config, externalIp) { + return ` Switching this node to Let's Encrypt will: + - obtain a new certificate now, free, for ${externalIp} + - change platform.gateway.ssl.provider from ${config.get('platform.gateway.ssl.provider')} to letsencrypt + - leave your existing provider's account and credentials untouched but unused + + It needs inbound port 80 reachable from the internet right now, and it needs + port 80 permanently thereafter - not on a schedule you can plan around. + Certificates for IP addresses last about six days and dashmate renews them + continuously for as long as this node runs. A rule you open now and close + later, or one that does not survive a reboot, takes this node dark within + six days. + + It usually takes under a minute. If port 80 is not open yet, answer No, open + it permanently, and re-run dashmate update ${renderConfigFlag(config.getName())}. + + Your image pull is running now and will finish either way, so answering No + does not hold this node back from protocol upgrades or security patches - it + only leaves clients unable to connect until the certificate is fixed. +`; +} + +/** + * The operator who just succeeded is the one who never sees a port-80 failure + * message, and is quite possibly the one who opened port 80 by hand for this + * migration alone. Saying nothing here reproduces the dark-node failure in a + * fresh cohort within a week. + * + * @param {Config} config + * @param {Object} verdict - the verdict taken after the obtain + * @return {string} + */ +function renderSuccess(config, verdict) { + const expiresAt = verdict.installed + ? verdict.installed.validTo.toISOString().slice(0, 10) + : 'unknown'; + const days = verdict.expiresInDays === null ? '?' : Math.floor(verdict.expiresInDays); + + return ` Certificate obtained from Let's Encrypt for ${config.get('externalIp')} + Valid until ${expiresAt} (about ${days} days). + + LEAVE PORT 80 OPEN. This was not a one-time requirement. Certificates for + IP addresses last about six days, and dashmate keeps renewing this one for + as long as the node runs - every renewal needs inbound port 80 again. + + If you opened port 80 just now to make this work, make the rule permanent + and make sure it survives a reboot. If it lapses, this node goes dark + within six days. + + Nothing will warn you: Let's Encrypt stopped sending expiry emails on + 2025-06-04. Check with: dashmate doctor ${renderConfigFlag(config.getName())} +`; +} + +/** + * @param {Object} verdict + * @param {string} code + * @return {boolean} + */ +function hasReason(verdict, code) { + return verdict.reasons.some((reason) => reason.code === code); +} + +/** + * @param {checkGatewayCertificate} checkGatewayCertificate + * @param {obtainLetsEncryptCertificateTask} obtainLetsEncryptCertificateTask + * @param {installCertificateFilesTask} installCertificateFilesTask + * @param {ConfigFileJsonRepository} configFileRepository + * @param {ConfigFile} configFile + * @param {writeConfigTemplates} writeConfigTemplates + * @param {DockerCompose} dockerCompose + * @return {gatewayCertificateTask} + */ +export default function gatewayCertificateTaskFactory( + checkGatewayCertificate, + obtainLetsEncryptCertificateTask, + installCertificateFilesTask, + configFileRepository, + configFile, + writeConfigTemplates, + dockerCompose, +) { + /** + * Persist the provider, and only after a certificate exists to back it. + * + * Writing the provider first and then failing to obtain converts a node that + * was working with an expiring certificate into one that is broken: + * configuration would name an authority the node has no account with, and + * the helper's watcher would reschedule renewal against it within a minute, + * forever. + * + * @param {Config} config + */ + function persistProvider(config) { + // Issuance takes minutes, which is long enough for this lease to be lost + // and another command to save and render newer state. Rendering from this + // configuration would overwrite that. + if (!configFileRepository.isExclusive()) { + throw new Error('Lost the configuration lock while obtaining the certificate, so the' + + ' provider was not saved. The certificate was obtained and installed; re-run once no' + + ' other command is changing configuration.'); + } + + // Written immediately rather than at command exit, because update goes on + // into a multi-minute image pull afterwards. Both calls are needed: saving + // clears the collection's changed flag but leaves the individual config + // marked changed until its templates render. + configFileRepository.write(configFile); + writeConfigTemplates(config); + } + + /** + * Envoy reads the certificate files once at startup. Under the documented + * upgrade procedure the node is stopped and the new certificate loads at the + * next start, but update against a running node is supported too and there + * the reload is what makes the change reach the wire. + * + * @param {Config} config + * @return {Promise} + */ + async function reloadGateway(config) { + try { + await dockerCompose.execCommand(config, 'gateway', 'kill -SIGHUP 1'); + } catch (e) { + if (!(e instanceof ServiceIsNotRunningError)) { + throw e; + } + } + } + + /** + * Check the certificate installed for the gateway and, when an operator is + * there to answer, offer to repair it. + * + * @typedef {gatewayCertificateTask} + * @param {Config} config + * @param {Object} options + * @param {boolean} options.interactive + * @param {boolean} [options.skipCertificateCheck] + * @return {function(Object, Object): Promise} + */ + function gatewayCertificateTask(config, { interactive, skipCertificateCheck = false }) { + const cfg = renderConfigFlag(config.getName()); + + /** + * Run an obtain and decide what its outcome means. + * + * A failure is judged by re-checking the installed pair rather than by + * where it happened. An obtain that failed before touching the gateway + * files leaves the node exactly as it was; one that failed between the two + * writes can have replaced a working pair with a mismatched one, and + * reporting success there would tell an operator their node is fine at the + * moment it stopped serving TLS. + * + * @param {Object} ctx + * @param {Function} run + * @return {Promise} the verdict after the attempt + */ + async function attemptObtain(ctx, run) { + try { + await run(); + } catch (e) { + ctx.certificateObtainError = e; + + return checkGatewayCertificate(config); + } + + persistProvider(config); + + await reloadGateway(config); + + return checkGatewayCertificate(config); + } + + /** + * @param {Object} ctx + * @return {Promise} + */ + async function switchToLetsEncrypt(ctx) { + return attemptObtain(ctx, () => obtainLetsEncryptCertificateTask(config) + .run({ ...ctx, interactive })); + } + + return async (ctx, task) => { + const verdict = checkGatewayCertificate(config); + + ctx.certificate = verdict; + + // The check always runs, even when enforcement is bypassed, so a playbook + // carrying the flag keeps surfacing the problem instead of muting it. + if (skipCertificateCheck) { + ctx.certificateSkipped = true; + + task.skip(`Enforcement skipped, status is ${verdict.status}`); + + return; + } + + if (verdict.status === CERTIFICATE_STATUS.CHECKS_PASSED) { + // Someone who bought a certificate is never nagged. ZeroSSL is the one + // exception, because a free account stops being able to renew and the + // operator has no way to find that out until it has happened. + if (verdict.provider !== SSL_PROVIDERS.ZEROSSL) { + return; + } + + const daysLeft = Math.floor(verdict.expiresInDays ?? 0); + + ctx.certificateCourtesyOffered = true; + ctx.certificateWarnings = [ + ...(ctx.certificateWarnings ?? []), + `This node's ZeroSSL certificate expires in ${daysLeft} days. A free ZeroSSL` + + " account allows three certificates in total, so dashmate's renewals stop" + + ` working after about 270 days. Switch to Let's Encrypt with:` + + `\n dashmate ssl obtain ${cfg} --provider letsencrypt`, + ]; + + if (!interactive) { + return; + } + + const accepted = await promptOrThrow(task, { + type: 'toggle', + header: renderSwitchOffer(config, config.get('externalIp')), + message: "Switch to Let's Encrypt and obtain a certificate now?", + enabled: 'Yes', + disabled: 'Not now', + initial: daysLeft < ZEROSSL_URGENT_DAYS, + }, { interactive }); + + if (!accepted) { + return; + } + + const after = await switchToLetsEncrypt(ctx); + + // Nothing was blocking before this ran, so a failure that left the node + // as it was is a warning. A failure that damaged the installed pair is + // not, and this is the only thing that can tell them apart. + if (after.status === CERTIFICATE_STATUS.CHECKS_PASSED) { + ctx.certificate = after; + + if (ctx.certificateObtainError) { + ctx.certificateWarnings.push( + `The switch to Let's Encrypt did not complete: ${ctx.certificateObtainError.message}` + + '\nThe certificate this node was already using is untouched.', + ); + } else { + ctx.certificateSuccess = renderSuccess(config, after); + } + + return; + } + + ctx.certificate = after; + + throw new CertificateUnresolvedError(after); + } + + if (verdict.status === CERTIFICATE_STATUS.WARN) { + ctx.certificateWarnings = [ + ...(ctx.certificateWarnings ?? []), + ...verdict.warnings.map(({ message }) => message), + ]; + + return; + } + + // INVALID from here on. Nothing is acted on without an operator: a + // configuration change nobody asked for, made unattended on infrastructure + // they own, is not dashmate's to make - and it would replace a diagnosis + // with a silent failure. + if (!interactive) { + throw new CertificateUnresolvedError(verdict); + } + + // The pair is already installed and correct; only the setting was never + // written. There is nothing to obtain. + if (hasReason(verdict, CERTIFICATE_REASONS.SWITCH_INCOMPLETE)) { + const complete = await promptOrThrow(task, { + type: 'toggle', + header: ` A Let's Encrypt certificate is installed for the gateway, but the + configuration still names ${verdict.provider}, so dashmate's helper is renewing the + wrong provider. No certificate needs to be obtained - only the setting has + to be saved.\n`, + message: 'Finish the interrupted switch now?', + enabled: 'Yes', + disabled: 'No', + initial: true, + }, { interactive }); + + if (!complete) { + throw new CertificateUnresolvedError(verdict); + } + + config.set('platform.gateway.ssl.enabled', true); + config.set('platform.gateway.ssl.provider', SSL_PROVIDERS.LETSENCRYPT); + + persistProvider(config); + + await reloadGateway(config); + + ctx.certificate = checkGatewayCertificate(config); + + return; + } + + // An operator with their own certificate is offered the chance to replace + // it before changing authority is even suggested. + if ([SSL_PROVIDERS.FILE, SSL_PROVIDERS.SELF_SIGNED].includes(verdict.provider)) { + const installFiles = await promptOrThrow(task, { + type: 'toggle', + header: ` ${verdict.reasons[0].message} + + If you have a replacement certificate and key on disk, dashmate can install + them for the gateway now.\n`, + message: 'Install new certificate files now?', + enabled: 'Yes', + disabled: 'No', + initial: verdict.provider === SSL_PROVIDERS.FILE, + }, { interactive }); + + if (installFiles) { + await installCertificateFilesTask(config, { interactive }).run({ ...ctx, interactive }); + + await reloadGateway(config); + + const after = checkGatewayCertificate(config); + ctx.certificate = after; + + if (after.status !== CERTIFICATE_STATUS.INVALID) { + return; + } + + throw new CertificateUnresolvedError(after); + } + } + + // Let's Encrypt is already configured, so there is nothing to switch to - + // it is the only authority that issues IP-address certificates over ACME. + // The offer is to try obtaining again. + const isAlreadyLetsEncrypt = verdict.provider === SSL_PROVIDERS.LETSENCRYPT; + + const header = isAlreadyLetsEncrypt + ? ` ${verdict.reasons[0].message} + + This node is already on Let's Encrypt, so there is no provider to switch to. + Renewal has most likely been failing without anyone being told; dashmate has + not inspected the helper's history to confirm that. + + The most likely cause is inbound port 80, which Let's Encrypt re-checks on + every renewal - permanently, roughly every four days. It is not always port + 80: half the nodes in this state have it open and stopped renewing anyway.\n` + : renderSwitchOffer(config, config.get('externalIp')); + + const accepted = await promptOrThrow(task, { + type: 'toggle', + header, + message: isAlreadyLetsEncrypt + ? 'Try to obtain a new certificate now?' + : "Switch to Let's Encrypt and obtain a certificate now?", + enabled: 'Yes', + disabled: 'No', + // Nothing works today, so trying costs the operator nothing they still + // have. The exception is a certificate they bought, where changing + // authority is a decision only they can make. + initial: verdict.provider !== SSL_PROVIDERS.FILE, + }, { interactive }); + + if (!accepted) { + throw new CertificateUnresolvedError(verdict); + } + + const after = await switchToLetsEncrypt(ctx); + + ctx.certificate = after; + + if (after.status === CERTIFICATE_STATUS.INVALID) { + throw new CertificateUnresolvedError(after); + } + + ctx.certificateSuccess = renderSuccess(config, after); + }; + } + + return gatewayCertificateTask; +} diff --git a/packages/dashmate/src/oclif/command/BaseCommand.js b/packages/dashmate/src/oclif/command/BaseCommand.js index 9af8566d897..717e84132a6 100644 --- a/packages/dashmate/src/oclif/command/BaseCommand.js +++ b/packages/dashmate/src/oclif/command/BaseCommand.js @@ -21,6 +21,14 @@ export default class BaseCommand extends Command { }), }; + /** + * Whether this run holds the configuration lock. Defaults to what the command + * declares and is narrowed once its flags are known, because a command can + * declare that it reconfigures a node and still have a mode that changes + * nothing. + */ + holdsConfigLock = this.constructor.mutatesConfig === true; + /** * @param {Object} options * @return {Promise} @@ -51,7 +59,14 @@ export default class BaseCommand extends Command { // for the whole run and no other writer can get in between. Everything else // changes config through configFileRepository.update() and needs nothing // here. - if (this.constructor.mutatesConfig) { + // + // Such a command may still have a mode that changes nothing - a read-only + // preflight, say - and taking a write lock there would let it fail on a + // lock timeout for no reason, so it can opt that mode out. + this.holdsConfigLock = this.holdsConfigLock + && this.constructor.shouldSkipConfigLock?.(this.parsedFlags) !== true; + + if (this.holdsConfigLock) { configFileRepository.acquire(); } @@ -131,7 +146,7 @@ export default class BaseCommand extends Command { } finally { // Whether the command succeeded, failed, or failed before it started, the // lock must not outlive it. - if (this.container && this.constructor.mutatesConfig) { + if (this.container && this.holdsConfigLock) { this.container.resolve('configFileRepository').release(); } } @@ -157,7 +172,7 @@ export default class BaseCommand extends Command { // file it loaded - it read inside the lock, so its state is current. Any // other command changes configuration through update(), and saving its // startup copy here would write a snapshot from before the command ran. - if (this.constructor.mutatesConfig + if (this.holdsConfigLock && this.container.has('configFile') && err === undefined) { /** * @var {ConfigFile} configFile diff --git a/packages/dashmate/src/ssl/errors/CertificateUnresolvedError.js b/packages/dashmate/src/ssl/errors/CertificateUnresolvedError.js new file mode 100644 index 00000000000..6c64de4e0f4 --- /dev/null +++ b/packages/dashmate/src/ssl/errors/CertificateUnresolvedError.js @@ -0,0 +1,28 @@ +import AbstractError from '../../errors/AbstractError.js'; + +/** + * The gateway certificate did not pass the checks and was not repaired. + * + * Thrown rather than recorded because a listr2 task can only render as failed + * by throwing - there is no fail() on the task wrapper - and the operator has + * to see which step failed. The list runs with exitOnError disabled so the + * image pull still finishes and its table is still rendered; the command + * recognises this error afterwards and separates it from a genuine fault. + */ +export default class CertificateUnresolvedError extends AbstractError { + /** + * @param {Object} verdict - as returned by checkGatewayCertificate + */ + constructor(verdict) { + super("The gateway certificate did not pass dashmate's checks"); + + this.verdict = verdict; + } + + /** + * @return {Object} + */ + getVerdict() { + return this.verdict; + } +} diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js new file mode 100644 index 00000000000..cc08c71d0bc --- /dev/null +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -0,0 +1,242 @@ +import { SSL_PROVIDERS } from '../constants.js'; +import renderConfigFlag from '../util/renderConfigFlag.js'; +import { CERTIFICATE_REASONS } from './checkGatewayCertificateFactory.js'; + +/** + * How the run went for the images, said only as far as it was observed. + * + * A registry outage plus a bad certificate must not report that patches were + * fetched. `updateNode` resolves a failed pull as an `error` row rather than + * rejecting, so a run can succeed as a whole and still have delivered nothing. + * + * @param {{ok: boolean, failed: number, total: number}|null} pull + * @return {string} + */ +function renderPullSummary(pull) { + if (!pull || !pull.ok) { + return 'dashmate update could not pull images, and stopped'; + } + + if (pull.failed > 0) { + return `dashmate update pulled images - ${pull.failed} of ${pull.total} failed,` + + ' see the table above - then stopped'; + } + + return 'dashmate update pulled images, then stopped'; +} + +/** + * @param {Object} verdict + * @return {string} + */ +function renderObservation(verdict) { + const [first] = verdict.reasons; + + return first ? first.message : 'the installed certificate did not pass the checks'; +} + +/** + * Why a free ZeroSSL account stops working, and why it is not the operator's + * mistake. + * + * @return {string} + */ +function renderZeroSslExplanation() { + return ` Your certificate provider is ZeroSSL. dashmate's ZeroSSL integration drives + ZeroSSL's REST API, and a free ZeroSSL account allows three certificates + through the dashboard/API and no REST API access - so dashmate's renewals + stop working after about 270 days. (ZeroSSL's ACME service is not a + substitute here: it does not issue IP-address certificates.) You did not + configure anything wrong - as of August 2026, four out of five ZeroSSL + evonodes on mainnet were in this state. +`; +} + +/** + * Port 80 is a permanent standing requirement, and the consequence of losing it + * is the thing operators have to hear. Phrasing it as "every few days when the + * certificate renews" reads as a maintenance window that can be scheduled + * around, which is how the requirement gets lost. + * + * @return {string} + */ +function renderPortEightyPermanence() { + return ` PORT 80 MUST STAY OPEN PERMANENTLY - this is not a maintenance window. + Let's Encrypt IP-address certificates last about six days, and dashmate + renews them continuously for as long as this node runs. Every renewal needs + inbound port 80 again. + + If you open port 80 only to get this certificate and close it afterwards, or + if the rule does not survive a reboot, this node goes dark within six days + and nothing will tell you. That is the most common way an evonode dies: three + mainnet nodes were issued certificates on the same day, all went dark six + days later, and all three now block port 80. + + Make the rule permanent and make sure it persists across reboots. +`; +} + +/** + * The interrupted-switch case needs no certificate work at all - the pair is + * already installed - so it gets the one command that finishes the job rather + * than the whole port-80 argument. + * + * @param {Config} config + * @param {string} cfg + * @return {string} + */ +function renderSwitchIncompleteGuidance(config, cfg) { + return ` A Let's Encrypt certificate is already installed for the gateway, but the + configuration still names ${config.get('platform.gateway.ssl.provider')}. A previous switch was interrupted + after the files were written and before the setting was saved, so dashmate's + helper is renewing the wrong provider. + + Nothing needs to be obtained. Finish the switch: + + dashmate config set ${cfg} platform.gateway.ssl.provider letsencrypt +`; +} + +/** + * Already on Let's Encrypt and already broken. No provider switch is offered + * because there is nothing to switch to - Let's Encrypt is the only authority + * that issues IP-address certificates over ACME. + * + * Port 80 is named as the prime suspect rather than the cause: half the nodes + * measured in this state have port 80 demonstrably open and stopped renewing + * regardless. + * + * @param {string} cfg + * @return {string} + */ +function renderLetsEncryptDiagnosis(cfg) { + return ` This node is already configured for Let's Encrypt, so there is no provider to + switch to - it is the only authority that issues IP-address certificates over + ACME. dashmate's helper is configured to retry renewal hourly, so renewal has + most likely been failing without anyone being told. dashmate has not inspected + the helper's history to confirm that. + + The most likely cause is inbound port 80. Let's Encrypt re-checks it on every + renewal - roughly every four days, permanently - and a firewall rule that was + opened once and later closed, or that did not survive a reboot, produces + exactly this pattern. Three mainnet nodes issued on the same day went dark + together six days later, and all three now block port 80. + + It is not always port 80: half the nodes in this state have port 80 open and + stopped renewing regardless. Check the renewal logs as well: + + dashmate doctor ${cfg} + dashmate logs ${cfg} dashmate_helper +`; +} + +/** + * @param {string} cfg + * @param {boolean} isNodeRunning + * @return {string} + */ +function renderFix(cfg, isNodeRunning) { + return ` THE FIX - switch to Let's Encrypt, which issues IP-address certificates free. + + Let's Encrypt proves this node owns its IP by connecting to it on inbound + port 80. Check that first; it limits how often you may fail, so a blind + attempt is expensive: + + dashmate doctor ${cfg} + + Then: + + dashmate ssl obtain ${cfg} --provider letsencrypt + dashmate ${isNodeRunning ? 'restart' : 'start'} ${cfg} +`; +} + +/** + * Build the guidance printed after a run whose certificate was not resolved. + * + * Written to stderr directly and never handed to oclif's error printer: that + * printer hard-wraps at 74 columns on a non-TTY stream, which breaks the + * longest remediation line mid-token into something an operator cannot paste. + * + * Every claim is limited to what was observed. The check reads files on disk, + * so it cannot say what is on the wire, whether clients failed to connect, or + * what the helper has been doing. + * + * @param {Object} options + * @param {Config} options.config + * @param {Object} options.verdict + * @param {boolean} options.isNodeRunning + * @param {{ok: boolean, failed: number, total: number}|null} options.pull + * @return {string} + */ +export default function renderCertificateGuidance({ + config, + verdict, + isNodeRunning, + pull, +}) { + const cfg = renderConfigFlag(config.getName()); + const provider = config.get('platform.gateway.ssl.provider'); + const isSwitchIncomplete = verdict.reasons + .some(({ code }) => code === CERTIFICATE_REASONS.SWITCH_INCOMPLETE); + + const blocks = [ + ` ${renderPullSummary(pull)}: this node's installed TLS + certificate did not pass dashmate's checks. + + Node: ${config.get('network')} (config "${config.getName()}", ${config.get('externalIp') ?? 'no external IP set'}) + Certificate: ${renderObservation(verdict)} + + If this is the certificate the gateway is serving, standards-compliant + clients reject it. dashmate did not open a connection to check what is + actually on the wire; \`dashmate doctor ${cfg}\` does that. + + Nothing broke just now. This is the first release of dashmate that checks + the certificate, so this is the first time you are being told. +`, + ]; + + // The node is normally down when this is read: the documented upgrade + // procedure stops it before update runs. An operator who reads a certificate + // complaint, assumes it changed nothing and walks away has left a stopped + // masternode behind. + if (!isNodeRunning) { + blocks.push(` Your node is currently stopped. Run \`dashmate start ${cfg}\` to bring + it back up - the certificate problem does not prevent it from starting. +`); + } + + if (isSwitchIncomplete) { + blocks.push(renderSwitchIncompleteGuidance(config, cfg)); + } else { + if (provider === SSL_PROVIDERS.ZEROSSL) { + blocks.push(renderZeroSslExplanation()); + } + + if (provider === SSL_PROVIDERS.LETSENCRYPT) { + blocks.push(renderLetsEncryptDiagnosis(cfg)); + } + + blocks.push(renderFix(cfg, isNodeRunning)); + blocks.push(renderPortEightyPermanence()); + + blocks.push(` IF YOU CANNOT OPEN PORT 80. dashmate currently has no supported alternative + for an IP-address certificate. If your host will not open it, this node + cannot serve DAPI clients. Updates themselves are unaffected: images are + always pulled, whatever this check finds, so this node is not being held back + from protocol upgrades or security patches. To suppress this check for one + run: + + dashmate update ${cfg} --skip-certificate-check + + This leaves clients unable to connect to your node. It is an escape for a + single run, not a line to add to a playbook. +`); + } + + blocks.push(` This release does not block \`dashmate start\` or \`dashmate restart\`. The + certificate check applies only to \`dashmate update\`. +`); + + return `\n${blocks.join('\n')}\n`; +} diff --git a/packages/dashmate/test/unit/commands/update.spec.js b/packages/dashmate/test/unit/commands/update.spec.js index 12536a22930..20cfc468f66 100644 --- a/packages/dashmate/test/unit/commands/update.spec.js +++ b/packages/dashmate/test/unit/commands/update.spec.js @@ -2,7 +2,9 @@ import UpdateCommand from '../../../src/commands/update.js'; import HomeDir from '../../../src/config/HomeDir.js'; import getBaseConfigFactory from '../../../configs/defaults/getBaseConfigFactory.js'; import updateNodeFactory from '../../../src/update/updateNodeFactory.js'; -import getConfigMock from '../../../src/test/mock/getConfigMock.js'; +import CertificateUnresolvedError from '../../../src/ssl/errors/CertificateUnresolvedError.js'; +import { CERTIFICATE_STATUS } from '../../../src/ssl/checkGatewayCertificateFactory.js'; +import MuteOneLineError from '../../../src/oclif/errors/MuteOneLineError.js'; describe('Update command', () => { let config; @@ -11,15 +13,73 @@ describe('Update command', () => { let mockDocker; let mockDockerStream; let mockDockerResponse; + let dockerCompose; + let stderr; + let exitCode; - beforeEach(async function it() { - config = getConfigMock(this.sinon); + /** + * @param {Object} verdict + * @return {Object} + */ + const passingVerdict = () => ({ + status: CERTIFICATE_STATUS.CHECKS_PASSED, + reasons: [], + warnings: [], + skipped: [], + provider: 'letsencrypt', + installed: null, + expiresInDays: 6, }); - beforeEach(async function it() { + /** + * @param {Object} overrides + * @return {Object} + */ + const invalidVerdict = (overrides = {}) => ({ + ...passingVerdict(), + status: CERTIFICATE_STATUS.INVALID, + reasons: [{ code: 'EXPIRED', message: 'The installed certificate expired on 2026-05-01 - 111 days ago' }], + ...overrides, + }); + + /** + * @param {Object} options + * @return {Promise} + */ + function runUpdate({ + flags = {}, + updateNode, + checkGatewayCertificate = () => passingVerdict(), + gatewayCertificateTask = () => async () => {}, + } = {}) { + return new UpdateCommand().runWithDependencies( + {}, + { + format: 'json', + verbose: false, + 'skip-certificate-check': false, + 'non-interactive': false, + 'check-certificate': false, + ...flags, + }, + mockDocker, + config, + updateNode ?? updateNodeFactory(mockGetServicesList, mockDocker), + checkGatewayCertificate, + gatewayCertificateTask, + dockerCompose, + ); + } + + beforeEach(function it() { const getBaseConfig = getBaseConfigFactory(HomeDir.createTemp()); config = getBaseConfig(); + config.set('network', 'mainnet'); + config.set('platform.enable', true); + + mockDockerResponse = { status: 'Status: Image is up to date for' }; + mockServicesList = [{ name: 'fake', image: 'fake', title: 'FAKE' }]; mockGetServicesList = this.sinon.stub().callsFake(() => mockServicesList); mockDockerStream = { @@ -27,59 +87,316 @@ describe('Update command', () => { ? cb(Buffer.from(`${JSON.stringify(mockDockerResponse)}\r\n`)) : null)), }; mockDocker = { pull: this.sinon.stub().callsFake((image, cb) => cb(false, mockDockerStream)) }; - }); + dockerCompose = { isServiceRunning: this.sinon.stub().resolves(false) }; - it('should just update', async () => { - mockDockerResponse = { status: 'Status: Image is up to date for' }; - mockServicesList = [{ name: 'fake', image: 'fake', title: 'FAKE' }]; + stderr = ''; + this.sinon.stub(process.stderr, 'write').callsFake((chunk) => { + stderr += chunk; + return true; + }); - const command = new UpdateCommand(); + exitCode = process.exitCode; + }); - const updateNode = updateNodeFactory(mockGetServicesList, mockDocker); + afterEach(() => { + process.exitCode = exitCode; + }); - await command.runWithDependencies({}, { format: 'json' }, mockDocker, config, updateNode); + it('should just update', async () => { + await runUpdate(); expect(mockGetServicesList).to.have.been.calledOnceWithExactly(config); expect(mockDocker.pull).to.have.been.calledOnceWith(mockServicesList[0].image); }); it('should update other services if one of them fails', async function it() { - const command = new UpdateCommand(); - mockDockerResponse = { status: 'Status: Image is up to date for' }; mockServicesList = [{ name: 'fake', image: 'fake', title: 'FAKE' }, { name: 'fake_docker_pull_error', image: 'fake_err_image', title: 'FAKE_ERROR' }]; - // test docker.pull returns error mockDocker = { pull: this.sinon.stub() .callsFake((image, cb) => (image === mockServicesList[1].image ? cb(new Error(), null) : cb(false, mockDockerStream))), }; - let updateNode = updateNodeFactory(mockGetServicesList, mockDocker); - - await command.runWithDependencies({}, { format: 'json' }, mockDocker, config, updateNode); + await runUpdate({ updateNode: updateNodeFactory(mockGetServicesList, mockDocker) }); expect(mockGetServicesList).to.have.been.calledOnceWithExactly(config); expect(mockDocker.pull.firstCall.firstArg).to.equal(mockServicesList[0].image); expect(mockDocker.pull.secondCall.firstArg).to.equal(mockServicesList[1].image); + }); - // test docker.pull stream returns error - mockDocker = { pull: this.sinon.stub().callsFake((image, cb) => cb(false, mockDockerStream)) }; - mockDockerStream = { - on: this.sinon.stub().callsFake((channel, cb) => (channel === 'error' ? cb(new Error()) : null)), - }; + describe('the pull is never left unobserved', () => { + // updateNode is async and calls getServiceList synchronously, and + // docker.pull can throw synchronously inside its own executor, so this + // promise really can reject - while the certificate task holds a prompt + // open for minutes. + it('should observe a pull that rejects immediately', async function it() { + const rejection = new Error('service list is broken'); + let handledDuringTask = true; - // reset - mockGetServicesList = this.sinon.stub().callsFake(() => mockServicesList); - mockDocker = { pull: this.sinon.stub().callsFake((image, cb) => cb(false, mockDockerStream)) }; + await expect(runUpdate({ + updateNode: () => Promise.reject(rejection), + gatewayCertificateTask: () => async () => { + // Anything unhandled would already have been reported by now. + await new Promise((resolve) => { setImmediate(resolve); }); + handledDuringTask = true; + }, + })).to.not.be.rejected(); - updateNode = updateNodeFactory(mockGetServicesList, mockDocker); + expect(handledDuringTask).to.be.true(); + expect(stderr).to.contain('service list is broken'); + }); - await command.runWithDependencies({}, { format: 'json' }, mockDocker, config, updateNode); + // exitOnError is false so the throw does not stop the list, and the + // outer boundary covers the paths where task 2 cannot run at all. + it('should report the pull exactly once', async function it() { + const updateNode = this.sinon.stub().resolves([ + { name: 'fake', title: 'FAKE', updated: 'updated', image: 'fake' }, + ]); + const log = this.sinon.stub(console, 'log'); - expect(mockGetServicesList).to.have.been.calledOnceWithExactly(config); - expect(mockDocker.pull.firstCall.firstArg).to.equal(mockServicesList[0].image); - expect(mockDocker.pull.secondCall.firstArg).to.equal(mockServicesList[1].image); + await runUpdate({ updateNode }); + + expect(updateNode).to.have.been.calledOnce(); + expect(log).to.have.been.calledOnce(); + }); + + // A lost config lock, a failed reload or a programming error is a real + // failure. It must not be swallowed by exitOnError, and it must not be + // reported as a certificate problem. + it('should still report the pull when the certificate task throws unexpectedly', async () => { + const unexpected = new Error('lost the configuration lock'); + + await expect(runUpdate({ + gatewayCertificateTask: () => async () => { + throw unexpected; + }, + })).to.be.rejectedWith(unexpected); + + expect(mockDocker.pull).to.have.been.calledOnce(); + }); + }); + + describe('an unresolved certificate', () => { + /** + * @param {Object} options + * @return {Promise} + */ + async function runUnresolved(options = {}) { + const verdict = invalidVerdict(); + + return runUpdate({ + checkGatewayCertificate: () => verdict, + gatewayCertificateTask: () => async (ctx) => { + ctx.certificate = verdict; + throw new CertificateUnresolvedError(verdict); + }, + ...options, + }).catch((e) => e); + } + + it('should pull images regardless of the verdict', async () => { + await runUnresolved(); + + expect(mockDocker.pull).to.have.been.calledOnce(); + }); + + it('should exit non-zero through a muted error', async () => { + const error = await runUnresolved(); + + expect(error).to.be.an.instanceOf(MuteOneLineError); + expect(error.getError()).to.be.an.instanceOf(CertificateUnresolvedError); + }); + + // The table has to be on screen before the failure is reported, so a failed + // image row is never hidden behind an exit code. + it('should render the table before the failure message', async function it() { + const order = []; + this.sinon.stub(console, 'log').callsFake(() => order.push('table')); + process.stderr.write.callsFake((chunk) => { + stderr += chunk; + if (String(chunk).includes('did not pass')) { + order.push('guidance'); + } + return true; + }); + + await runUnresolved(); + + expect(order).to.deep.equal(['table', 'guidance']); + }); + + // updateNode resolves a failed pull as an error row rather than rejecting, + // so a registry outage plus a bad certificate would otherwise report that + // patches were fetched when they were not. + it('should not claim images were pulled when a pull failed', async function it() { + mockServicesList = [{ name: 'a', image: 'a', title: 'A' }, { name: 'b', image: 'b', title: 'B' }]; + mockDocker = { + pull: this.sinon.stub().callsFake((image, cb) => (image === 'b' + ? cb(new Error('registry down'), null) + : cb(false, mockDockerStream))), + }; + this.sinon.stub(console, 'log'); + + await runUnresolved({ updateNode: updateNodeFactory(mockGetServicesList, mockDocker) }); + + expect(stderr).to.contain('1 of 2 failed'); + }); + }); + + describe('the read-only preflight', () => { + it('should not pull, prompt or change anything', async function it() { + const gatewayCertificateTask = this.sinon.stub(); + + await runUpdate({ + flags: { 'check-certificate': true }, + gatewayCertificateTask, + }); + + expect(mockDocker.pull).to.not.have.been.called(); + expect(gatewayCertificateTask).to.not.have.been.called(); + }); + + it('should report the verdict and exit non-zero when it is invalid', async () => { + const error = await runUpdate({ + flags: { 'check-certificate': true }, + checkGatewayCertificate: () => invalidVerdict(), + }).catch((e) => e); + + expect(error).to.be.an.instanceOf(MuteOneLineError); + expect(stderr).to.contain('"status":"INVALID"'); + expect(stderr).to.contain('"reasons":["EXPIRED"]'); + }); + + it('should exit zero when the checks pass', async () => { + await expect(runUpdate({ + flags: { 'check-certificate': true }, + checkGatewayCertificate: () => passingVerdict(), + })).to.not.be.rejected(); + + expect(stderr).to.contain('"status":"CHECKS_PASSED"'); + }); + + // A read-only preflight is meant to be run before the node is stopped, + // possibly while the helper is renewing. Taking a write lock there would + // let it fail on a lock timeout for no reason. + it('should not take the configuration lock', () => { + expect(UpdateCommand.mutatesConfig).to.be.true(); + expect(UpdateCommand.shouldSkipConfigLock({ 'check-certificate': true })).to.be.true(); + expect(UpdateCommand.shouldSkipConfigLock({ 'check-certificate': false })).to.be.false(); + }); + }); + + describe('scope', () => { + ['local', 'devnet'].forEach((network) => { + it(`should not check the certificate on ${network}`, async function it() { + const gatewayCertificateTask = this.sinon.stub().returns(async () => {}); + config.set('network', network); + + await runUpdate({ gatewayCertificateTask }); + + expect(mockDocker.pull).to.have.been.calledOnce(); + }); + }); + + it('should not check the certificate when platform is disabled', async function it() { + config.set('platform.enable', false); + const task = this.sinon.stub().resolves(); + + await runUpdate({ gatewayCertificateTask: () => task }); + + expect(task).to.not.have.been.called(); + expect(mockDocker.pull).to.have.been.calledOnce(); + }); + }); + + describe('flags', () => { + it('should offer exactly the documented flags', () => { + expect(Object.keys(UpdateCommand.flags).sort()).to.deep.equal([ + 'check-certificate', + 'config', + 'format', + 'non-interactive', + 'skip-certificate-check', + 'verbose', + ]); + }); + + // The bypass suppresses enforcement, never the check, so a playbook that + // carries it keeps surfacing the problem instead of muting it. + it('should still run the check under --skip-certificate-check', async function it() { + let observed; + + await runUpdate({ + flags: { 'skip-certificate-check': true }, + gatewayCertificateTask: (taskConfig, options) => { + observed = options; + return async (ctx) => { + ctx.certificate = invalidVerdict(); + ctx.certificateSkipped = true; + }; + }, + }); + + expect(observed.skipCertificateCheck).to.be.true(); + expect(stderr).to.contain('status is INVALID'); + }); + + it('should honour DASHMATE_SKIP_CERTIFICATE_CHECK', async function it() { + this.sinon.stub(process, 'env').value({ ...process.env, DASHMATE_SKIP_CERTIFICATE_CHECK: '1' }); + let observed; + + await runUpdate({ + gatewayCertificateTask: (taskConfig, options) => { + observed = options; + return async () => {}; + }, + }); + + expect(observed.skipCertificateCheck).to.be.true(); + }); + + it('should never prompt under --non-interactive', async function it() { + let observed; + + await runUpdate({ + flags: { 'non-interactive': true }, + gatewayCertificateTask: (taskConfig, options) => { + observed = options; + return async () => {}; + }, + }); + + expect(observed.interactive).to.be.false(); + }); + }); + + // A prompt that leaks past the interactivity guard neither throws nor + // settles - the event loop drains and the process exits 0 with nothing done. + // The entry-time exit code is the only thing that turns that into a failure. + it('should fail closed until the run resolves', async () => { + let duringRun; + + await runUpdate({ + gatewayCertificateTask: () => async () => { + duringRun = process.exitCode; + }, + }); + + expect(duringRun).to.equal(1); + expect(process.exitCode).to.equal(0); + }); + + it('should not clear the exit code when the certificate is unresolved', async () => { + const verdict = invalidVerdict(); + + await runUpdate({ + gatewayCertificateTask: () => async () => { + throw new CertificateUnresolvedError(verdict); + }, + }).catch(() => {}); + + expect(process.exitCode).to.equal(1); }); }); diff --git a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js new file mode 100644 index 00000000000..59dec27ef9d --- /dev/null +++ b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js @@ -0,0 +1,416 @@ +import fs from 'fs'; +import path from 'path'; +import { Listr } from 'listr2'; +import HomeDir from '../../../../../src/config/HomeDir.js'; +import getBaseConfigFactory from '../../../../../configs/defaults/getBaseConfigFactory.js'; +import gatewayCertificateTaskFactory from '../../../../../src/listr/tasks/update/gatewayCertificateTaskFactory.js'; +import CertificateUnresolvedError from '../../../../../src/ssl/errors/CertificateUnresolvedError.js'; +import { + CERTIFICATE_REASONS, + CERTIFICATE_STATUS, +} from '../../../../../src/ssl/checkGatewayCertificateFactory.js'; +import getEnquirerMock from '../../../../../src/test/mock/getEnquirerMock.js'; + +describe('gatewayCertificateTaskFactory', () => { + let homeDir; + let config; + let configFile; + let configFileRepository; + let writeConfigTemplates; + let dockerCompose; + let obtainLetsEncryptCertificateTask; + let installCertificateFilesTask; + let enquirer; + + /** + * @param {Object} [overrides] + * @return {Object} + */ + const verdict = (overrides = {}) => ({ + status: CERTIFICATE_STATUS.CHECKS_PASSED, + reasons: [], + warnings: [], + skipped: [], + provider: config.get('platform.gateway.ssl.provider'), + installed: { validTo: new Date(Date.now() + 6 * 864e5) }, + expiresInDays: 6, + ...overrides, + }); + + /** + * @param {Object} overrides + * @return {Object} + */ + const invalid = (code = CERTIFICATE_REASONS.EXPIRED, overrides = {}) => verdict({ + status: CERTIFICATE_STATUS.INVALID, + reasons: [{ code, message: `certificate problem: ${code}` }], + ...overrides, + }); + + /** + * Run the task the way the update command runs it, with a stubbed enquirer so + * a prompt can be answered - or its absence asserted. + * + * @param {Object} options + * @return {Promise} the listr context + */ + async function run({ + checkGatewayCertificate, + interactive = true, + skipCertificateCheck = false, + answers = [], + }) { + enquirer = getEnquirerMock(this.sinon, ...answers); + + const gatewayCertificateTask = gatewayCertificateTaskFactory( + checkGatewayCertificate, + obtainLetsEncryptCertificateTask, + installCertificateFilesTask, + configFileRepository, + configFile, + writeConfigTemplates, + dockerCompose, + ); + + const context = {}; + const tasks = new Listr([{ + title: 'Gateway certificate', + task: gatewayCertificateTask(config, { interactive, skipCertificateCheck }), + }], { renderer: 'silent', exitOnError: false }); + + tasks.options.injectWrapper = { enquirer }; + + await tasks.run(context); + + return { context, errors: (tasks.err ?? []).map((e) => e?.error ?? e) }; + } + + beforeEach(function it() { + homeDir = HomeDir.createTemp(); + config = getBaseConfigFactory(homeDir)(); + config.set('network', 'mainnet'); + config.set('externalIp', '1.2.3.4'); + config.set('platform.gateway.ssl.provider', 'zerossl'); + config.markAsSaved(); + + configFile = { getConfig: () => config }; + configFileRepository = { + isExclusive: this.sinon.stub().returns(true), + write: this.sinon.stub(), + }; + writeConfigTemplates = this.sinon.stub(); + dockerCompose = { execCommand: this.sinon.stub().resolves() }; + obtainLetsEncryptCertificateTask = this.sinon.stub() + .callsFake(() => new Listr([{ task: () => {} }], { renderer: 'silent' })); + installCertificateFilesTask = this.sinon.stub() + .callsFake(() => new Listr([{ task: () => {} }], { renderer: 'silent' })); + }); + + afterEach(() => homeDir.remove()); + + describe('nothing blocks on a certificate that passed', () => { + it('should say nothing at all for a provider that is working', async function it() { + config.set('platform.gateway.ssl.provider', 'letsencrypt'); + + const { context, errors } = await run.call(this, { + checkGatewayCertificate: () => verdict(), + }); + + expect(errors).to.be.empty(); + expect(context.certificateWarnings).to.be.undefined(); + expect(enquirer.prompt).to.not.have.been.called(); + }); + + // A free ZeroSSL account allows three certificates in total and no REST + // access, so renewal stops permanently and the operator has no way to find + // that out before it happens. This is the only passing case that speaks. + it('should always warn about a ZeroSSL certificate, even to a machine', async function it() { + const { context, errors } = await run.call(this, { + checkGatewayCertificate: () => verdict(), + interactive: false, + }); + + expect(errors).to.be.empty(); + expect(context.certificateWarnings.join('\n')).to.contain('expires in 6 days'); + expect(enquirer.prompt).to.not.have.been.called(); + }); + + it('should preselect the switch only when time is short', async function it() { + await run.call(this, { + checkGatewayCertificate: () => verdict({ expiresInDays: 40 }), + answers: [false], + }); + expect(enquirer.options[0].initial).to.be.false(); + + await run.call(this, { + checkGatewayCertificate: () => verdict({ expiresInDays: 5 }), + answers: [false], + }); + expect(enquirer.options[0].initial).to.be.true(); + }); + + it('should exit cleanly when the courtesy switch is declined', async function it() { + const { errors } = await run.call(this, { + checkGatewayCertificate: () => verdict(), + answers: [false], + }); + + expect(errors).to.be.empty(); + expect(obtainLetsEncryptCertificateTask).to.not.have.been.called(); + }); + }); + + describe('the courtesy migration is judged by what it left behind', () => { + // Nothing was blocking before this ran. An obtain that failed before + // touching the gateway files leaves the node exactly as it was, so it is a + // warning - reporting an error would tell an operator to act on a node that + // is fine. + it('should warn when a failed switch changed nothing', async function it() { + obtainLetsEncryptCertificateTask.callsFake(() => ({ + run: async () => { + throw new Error('port 80 is closed'); + }, + })); + + const { context, errors } = await run.call(this, { + checkGatewayCertificate: () => verdict(), + answers: [true], + }); + + expect(errors).to.be.empty(); + expect(context.certificateWarnings.join('\n')).to.contain('did not complete'); + expect(context.certificateWarnings.join('\n')).to.contain('untouched'); + }); + + // saveCertificateTask writes the bundle and the key as two separate + // in-place writes, so a failure between them can replace a working pair + // with a mismatched one. Promising exit 0 here would tell an operator their + // node is fine at the moment it stopped serving TLS. + it('should fail when a failed switch damaged the installed pair', async function it() { + obtainLetsEncryptCertificateTask.callsFake(() => ({ + run: async () => { + throw new Error('no space left on device'); + }, + })); + + let checked = 0; + const { errors } = await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 ? verdict() : invalid(CERTIFICATE_REASONS.KEY_MISMATCH); + }, + answers: [true], + }); + + expect(errors[0]).to.be.an.instanceOf(CertificateUnresolvedError); + }); + }); + + describe('the provider is persisted only after a certificate exists', () => { + // Writing the provider first and then failing the obtain converts a node + // that was working with an expiring certificate into one that is broken: + // configuration would name an authority it has no account with, and the + // helper's watcher would reschedule renewal against it within a minute, + // forever. + it('should not persist anything when the obtain fails', async function it() { + obtainLetsEncryptCertificateTask.callsFake(() => ({ + run: async () => { + throw new Error('lego failed'); + }, + })); + + await run.call(this, { + checkGatewayCertificate: () => invalid(), + answers: [true], + }); + + expect(configFileRepository.write).to.not.have.been.called(); + expect(writeConfigTemplates).to.not.have.been.called(); + expect(config.get('platform.gateway.ssl.provider')).to.equal('zerossl'); + }); + + it('should persist after the obtain succeeds', async function it() { + let checked = 0; + await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 ? invalid() : verdict(); + }, + answers: [true], + }); + + expect(configFileRepository.write).to.have.been.calledOnceWith(configFile); + expect(writeConfigTemplates).to.have.been.calledOnceWith(config); + }); + + // Issuance takes minutes, long enough for the lease to be lost and another + // command to save and render newer state. The state this leaves behind is + // the interrupted switch, which the next run detects and converges on. + it('should refuse to write once the lock is gone', async function it() { + configFileRepository.isExclusive.returns(false); + + let checked = 0; + const { errors } = await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 ? invalid() : verdict(); + }, + answers: [true], + }); + + expect(errors[0].message).to.contain('Lost the configuration lock'); + expect(configFileRepository.write).to.not.have.been.called(); + }); + }); + + describe('an interrupted switch converges', () => { + it('should finish the persistence without obtaining anything', async function it() { + let checked = 0; + const { errors } = await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 + ? invalid(CERTIFICATE_REASONS.SWITCH_INCOMPLETE) + : verdict({ provider: 'letsencrypt' }); + }, + answers: [true], + }); + + expect(errors).to.be.empty(); + expect(obtainLetsEncryptCertificateTask).to.not.have.been.called(); + expect(config.get('platform.gateway.ssl.provider')).to.equal('letsencrypt'); + expect(configFileRepository.write).to.have.been.calledOnce(); + }); + + it('should block when the operator declines to finish it', async function it() { + const { errors } = await run.call(this, { + checkGatewayCertificate: () => invalid(CERTIFICATE_REASONS.SWITCH_INCOMPLETE), + answers: [false], + }); + + expect(errors[0]).to.be.an.instanceOf(CertificateUnresolvedError); + expect(config.get('platform.gateway.ssl.provider')).to.equal('zerossl'); + }); + }); + + describe('an operator with their own certificate', () => { + it('should offer to install replacement files before suggesting a new authority', async function it() { + config.set('platform.gateway.ssl.provider', 'file'); + + let checked = 0; + const { errors } = await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 ? invalid() : verdict(); + }, + answers: [true], + }); + + expect(errors).to.be.empty(); + expect(installCertificateFilesTask).to.have.been.calledOnce(); + expect(obtainLetsEncryptCertificateTask).to.not.have.been.called(); + expect(enquirer.options[0].message).to.contain('Install new certificate files'); + }); + + // Changing the authority on a certificate someone bought is a decision only + // they can make, so it is offered second and not preselected. + it('should offer Let\'s Encrypt second and not preselect it', async function it() { + config.set('platform.gateway.ssl.provider', 'file'); + + await run.call(this, { + checkGatewayCertificate: () => invalid(), + answers: [false, false], + }); + + expect(enquirer.options[1].message).to.contain("Switch to Let's Encrypt"); + expect(enquirer.options[1].initial).to.be.false(); + }); + + it('should preselect the switch for a self-signed certificate', async function it() { + config.set('platform.gateway.ssl.provider', 'self-signed'); + + await run.call(this, { + checkGatewayCertificate: () => invalid(CERTIFICATE_REASONS.SELF_SIGNED), + answers: [false, false], + }); + + expect(enquirer.options[1].initial).to.be.true(); + }); + }); + + describe('already on Let\'s Encrypt', () => { + it('should offer another attempt rather than a switch', async function it() { + config.set('platform.gateway.ssl.provider', 'letsencrypt'); + + await run.call(this, { + checkGatewayCertificate: () => invalid(), + answers: [false], + }); + + expect(enquirer.options[0].message).to.contain('Try to obtain a new certificate'); + expect(enquirer.options[0].header).to.contain('no provider to switch to'); + expect(enquirer.options[0].header).to.contain('It is not always port'); + }); + }); + + describe('unattended runs report and never act', () => { + // A configuration change nobody asked for, made unattended on + // infrastructure the operator owns, is not dashmate's to make - and it + // would replace a diagnosis with a silent failure. + it('should construct no prompt and change nothing', async function it() { + const { errors } = await run.call(this, { + checkGatewayCertificate: () => invalid(), + interactive: false, + }); + + expect(enquirer.prompt).to.not.have.been.called(); + expect(errors[0]).to.be.an.instanceOf(CertificateUnresolvedError); + expect(obtainLetsEncryptCertificateTask).to.not.have.been.called(); + expect(configFileRepository.write).to.not.have.been.called(); + }); + }); + + describe('the bypass suppresses enforcement, never the check', () => { + it('should record the real status and let the run continue', async function it() { + const { context, errors } = await run.call(this, { + checkGatewayCertificate: () => invalid(), + skipCertificateCheck: true, + }); + + expect(errors).to.be.empty(); + expect(context.certificateSkipped).to.be.true(); + expect(context.certificate.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(enquirer.prompt).to.not.have.been.called(); + }); + }); + + describe('warnings are reported and never blocked on', () => { + it('should carry every warning through without failing', async function it() { + const { context, errors } = await run.call(this, { + checkGatewayCertificate: () => verdict({ + status: CERTIFICATE_STATUS.WARN, + warnings: [ + { code: CERTIFICATE_REASONS.PROVIDER_MISMATCH, message: 'issuer disagrees' }, + { code: CERTIFICATE_REASONS.EXPIRING_SOON, message: 'expires tomorrow' }, + ], + }), + }); + + expect(errors).to.be.empty(); + expect(context.certificateWarnings).to.deep.equal(['issuer disagrees', 'expires tomorrow']); + }); + }); + + // ZeroSSL's obtain writes the certificate id to config before anything is + // issued, and every caller must persist at that callback. Running it from + // here would spend one of a free-tier operator's three lifetime certificates + // and leave it unreferenced - on exactly the population this check exists for. + it('should never construct the ZeroSSL obtain task', async function it() { + const source = fs.readFileSync( + path.join(process.cwd(), 'src/listr/tasks/update/gatewayCertificateTaskFactory.js'), + 'utf8', + ); + + expect(source).to.not.contain('obtainZeroSSLCertificateTask'); + }); +}); diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js new file mode 100644 index 00000000000..de53fd57cff --- /dev/null +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -0,0 +1,195 @@ +import getBaseConfigFactory from '../../../configs/defaults/getBaseConfigFactory.js'; +import HomeDir from '../../../src/config/HomeDir.js'; +import renderCertificateGuidance from '../../../src/ssl/renderCertificateGuidance.js'; +import { CERTIFICATE_REASONS, CERTIFICATE_STATUS } from '../../../src/ssl/checkGatewayCertificateFactory.js'; + +describe('renderCertificateGuidance', () => { + let config; + + /** + * @param {Object} [overrides] + * @return {Object} + */ + const verdict = (overrides = {}) => ({ + status: CERTIFICATE_STATUS.INVALID, + reasons: [{ + code: CERTIFICATE_REASONS.EXPIRED, + message: 'The installed certificate expired on 2026-05-01 - 111 days ago', + }], + warnings: [], + skipped: [], + provider: 'zerossl', + installed: null, + expiresInDays: -111, + ...overrides, + }); + + /** + * @param {Object} [options] + * @return {string} + */ + const render = (options = {}) => renderCertificateGuidance({ + config, + verdict: verdict(), + isNodeRunning: false, + pull: { ok: true, failed: 0, total: 7 }, + ...options, + }); + + beforeEach(() => { + config = getBaseConfigFactory(HomeDir.createTemp())(); + config.set('network', 'mainnet'); + config.set('externalIp', '149.28.241.190'); + config.set('platform.gateway.ssl.provider', 'zerossl'); + }); + + // ConfigBaseCommand falls back to the default config when --config is absent, + // so an operator running several nodes who pastes a bare command would obtain + // a certificate for, restart, or bypass the check on a different node. + it('should put the selected config on every command it prints', function it() { + this.sinon.stub(config, 'getName').returns('testnet_2'); + + const output = render(); + + // Commands an operator is meant to run appear either in an indented block + // or in backticks. Everything else naming dashmate is prose. + const commands = [ + ...output.split('\n') + .filter((line) => /^ {6,}dashmate /.test(line)) + .map((line) => line.trim()), + ...(output.match(/`dashmate [^`]+`/g) ?? []) + .map((match) => match.replace(/`/g, '')) + // A bare `dashmate start` in prose names the command rather than + // telling the operator to run it here. + .filter((command) => command.split(' ').length > 2), + ]; + + expect(commands).to.have.length.greaterThan(4); + commands.forEach((command) => { + expect(command, command).to.contain('--config testnet_2'); + }); + }); + + it('should shell-quote a config name that needs it', function it() { + this.sinon.stub(config, 'getName').returns('my node'); + + expect(render()).to.contain("--config 'my node'"); + }); + + // The guidance is written straight to stderr rather than handed to oclif's + // error printer, which hard-wraps at 74 columns on a non-TTY stream and would + // break the longest remediation line mid-token into something unpastable. + it('should never break a command across lines', () => { + const output = render(); + + expect(output).to.contain( + 'dashmate ssl obtain --config base --provider letsencrypt', + ); + output.split('\n').forEach((line) => { + expect(line, line).to.not.match(/--conf$|--provide$|dashm$/); + }); + }); + + // The check reads files on disk. It cannot know what is on the wire, whether + // any client failed to connect, or what the helper has been doing. + it('should claim nothing it did not observe', () => { + const output = render(); + + expect(output).to.contain('If this is the certificate the gateway is serving'); + expect(output).to.contain('dashmate did not open a connection'); + expect(output).to.not.contain('still being paid'); + expect(output).to.not.contain('clients could not connect'); + expect(output).to.not.contain('there is currently no other way'); + }); + + it('should reassure that the update itself broke nothing', () => { + expect(render()).to.contain('Nothing broke just now.'); + }); + + // The promise of a future release that refuses to start reads, to a + // masternode operator, as a threat to their collateral position. + it('should not promise a future release that blocks start', () => { + const output = render(); + + expect(output).to.contain('This release does not block `dashmate start`'); + expect(output).to.not.match(/future version|will not allow/i); + }); + + // The documented upgrade procedure stops the node before update runs, so most + // operators who see this have a stopped node. Reading a certificate complaint + // and assuming it changed nothing leaves a masternode down. + it('should lead with node state when the node is stopped', () => { + const output = render({ isNodeRunning: false }); + + expect(output).to.contain('Your node is currently stopped'); + expect(output).to.contain('dashmate start --config base'); + }); + + it('should offer restart instead when the node is running', () => { + const output = render({ isNodeRunning: true }); + + expect(output).to.not.contain('Your node is currently stopped'); + expect(output).to.contain('dashmate restart --config base'); + }); + + it('should say when images failed to pull', () => { + expect(render({ pull: { ok: true, failed: 2, total: 7 } })) + .to.contain('2 of 7 failed'); + expect(render({ pull: { ok: false, failed: 0, total: 0 } })) + .to.contain('could not pull images'); + expect(render()).to.contain('pulled images, then stopped'); + }); + + it('should explain the ZeroSSL wall without blaming the operator', () => { + const output = render(); + + expect(output).to.contain('You did not\n configure anything wrong'); + expect(output).to.contain('as of August 2026'); + }); + + // Half the expired Let's Encrypt nodes measured had port 80 demonstrably + // open and stopped renewing anyway, so it is the prime suspect, not the + // diagnosis. + it('should name port 80 as a suspect rather than the cause', () => { + config.set('platform.gateway.ssl.provider', 'letsencrypt'); + + const output = render({ verdict: verdict({ provider: 'letsencrypt' }) }); + + expect(output).to.contain('The most likely cause is inbound port 80'); + expect(output).to.contain('It is not always port 80'); + expect(output).to.contain('dashmate logs --config base dashmate_helper'); + }); + + it('should state that port 80 is permanent, never periodic', () => { + const output = render(); + + expect(output).to.contain('PORT 80 MUST STAY OPEN PERMANENTLY'); + expect(output).to.contain('goes dark within six days'); + expect(output).to.not.match(/every few days when the certificate renews/i); + }); + + // The interrupted switch needs no certificate work at all, so it gets the one + // command that finishes the job instead of the whole port-80 argument. + it('should name the exact repair for an interrupted switch', () => { + const output = render({ + verdict: verdict({ + reasons: [{ + code: CERTIFICATE_REASONS.SWITCH_INCOMPLETE, + message: 'A switch was interrupted before it finished', + }], + }), + }); + + expect(output).to.contain( + 'dashmate config set --config base platform.gateway.ssl.provider letsencrypt', + ); + expect(output).to.not.contain('THE FIX'); + }); + + it('should name the bypass and say it is not a playbook line', () => { + const output = render(); + + expect(output).to.contain('dashmate update --config base --skip-certificate-check'); + expect(output).to.contain('not a line to add to a playbook'); + }); +}); From 51d0d180a9b6c386c0bde31fc84bbba66233a2f6 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 10:22:00 +0700 Subject: [PATCH 06/63] fix(dashmate): report certificate problems doctor could not see analyseGatewayCertificate returned an empty list whenever there was no servedCertificate sample, and that sample only exists when the gateway answers a TLS connection. So a stopped node with an expired bundle on disk produced no doctor problem at all. That is exactly the node this matters for. The documented upgrade procedure runs `dashmate stop` before `dashmate update`, so when the new certificate check fails the gateway is down - and the message it prints sends the operator to `dashmate doctor`, which until now had nothing to say to them. Doctor now collects the on-disk verdict alongside the wire probe and reports it independently. Collected rather than computed at analysis time, because a report is routinely unarchived and read on a different machine days later, where the local files describe nothing. Each blocking problem's solution carries the update consequence: clients cannot connect, but `dashmate update` still pulls images, so protocol upgrades and security patches keep arriving and only the exit code changes. Without that sentence an operator reads a client-reachability problem as a software-delivery one and concludes their node is falling behind. Tests: 5 new, red before this commit - a stopped node with an expired bundle reported nothing, and warnings reported nothing - green after. The existing collectSamples cases were threaded with the new dependency and still assert what they did before. Co-Authored-By: Claude Opus 5 --- .../analyseGatewayCertificateFactory.js | 50 +++++++++++- .../tasks/doctor/collectSamplesTaskFactory.js | 26 +++++++ .../analyseGatewayCertificateFactory.spec.js | 76 +++++++++++++++++++ .../doctor/collectSamplesTaskFactory.spec.js | 2 + 4 files changed, 150 insertions(+), 4 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 23d77a4e270..8a7637c609f 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -9,9 +9,19 @@ import Problem from '../Problem.js'; */ const RESTART_HINT = chalk`Then restart Platform so the gateway picks it up: {bold.cyanBright dashmate restart --platform}`; +/** + * An operator reading a certificate problem is deciding whether their node is + * falling behind. It is not: `update` pulls images whatever the certificate + * does, and only refuses to report success. Leaving this out lets a client + * reachability problem be read as a software delivery one. + */ +const UPDATE_CONSEQUENCE = 'While this is unresolved, clients cannot connect to this node.' + + ' `dashmate update` still pulls new images, so protocol upgrades and security patches' + + ' continue to arrive - but it exits non-zero until the certificate is fixed.'; + export default function analyseGatewayCertificateFactory() { /** - * Analyse the certificate the gateway actually serves. + * Analyse the certificate installed for the gateway and the one it serves. * * @typedef analyseGatewayCertificate * @param {Samples} samples @@ -24,14 +34,46 @@ export default function analyseGatewayCertificateFactory() { return []; } + const problems = []; + + // The gateway is stopped whenever the documented upgrade procedure is + // followed, and a stopped gateway answers no TLS connection - so the probe + // below records nothing and every problem with the files on disk would go + // unreported, on exactly the node an operator has just been told to run + // doctor on. + const installed = samples.getServiceInfo('gateway', 'installedCertificate'); + + if (installed) { + installed.reasons.forEach(({ message }) => { + problems.push(new Problem( + message, + chalk`${UPDATE_CONSEQUENCE} + +Check what is wrong and obtain a new certificate: +{bold.cyanBright dashmate doctor} +{bold.cyanBright dashmate ssl obtain --provider letsencrypt} +${RESTART_HINT}`, + SEVERITY.HIGH, + )); + }); + + installed.warnings.forEach(({ message }) => { + problems.push(new Problem( + message, + chalk`Nothing is broken yet. If it needs attention, obtain a new certificate: +{bold.cyanBright dashmate ssl obtain --provider letsencrypt} +${RESTART_HINT}`, + SEVERITY.LOW, + )); + }); + } + const served = samples.getServiceInfo('gateway', 'servedCertificate'); if (!served) { - return []; + return problems; } - 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 diff --git a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js index 632a57371a0..d4ba303284b 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 {checkGatewayCertificate} checkGatewayCertificate * @return {collectSamplesTask} */ export default function collectSamplesTaskFactory( @@ -51,6 +52,7 @@ export default function collectSamplesTaskFactory( homeDir, validateZeroSslCertificate, validateLetsEncryptCertificate, + checkGatewayCertificate, ) { /** * @typedef {function} collectSamplesTask @@ -192,6 +194,30 @@ export default function collectSamplesTaskFactory( } }, }, + { + // Judged where the files are, because an archived report is + // analysed somewhere else entirely. This is also the only + // certificate sample a stopped node produces: the probe below + // needs a listener to answer it, and the documented upgrade + // procedure leaves the gateway down. + enabled: () => config.get('platform.enable'), + title: 'Gateway certificate files', + task: async () => { + const verdict = checkGatewayCertificate(config); + + ctx.samples.setServiceInfo('gateway', 'installedCertificate', { + status: verdict.status, + reasons: verdict.reasons, + warnings: verdict.warnings, + skipped: verdict.skipped, + provider: verdict.provider, + expiresInDays: verdict.expiresInDays, + validTo: verdict.installed + ? verdict.installed.validTo.toUTCString() + : null, + }); + }, + }, { // 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 diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index f7f7a022b47..08dbec3d67a 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -164,4 +164,80 @@ describe('analyseGatewayCertificateFactory', () => { expect(analyse(served({ certificate: { validTo: validTo(-100) } }))).to.be.empty(); }); + + describe('the certificate on disk', () => { + /** + * @param {Object} installed + * @param {Object} [servedCertificate] + * @return {Problem[]} + */ + function analyseInstalled(installed, servedCertificate) { + samples.setServiceInfo('gateway', 'installedCertificate', installed); + + if (servedCertificate) { + samples.setServiceInfo('gateway', 'servedCertificate', servedCertificate); + } + + return analyseGatewayCertificate(samples); + } + + // Under the documented upgrade procedure the node is stopped when the + // certificate check fails, and a stopped gateway answers no TLS connection + // - so the probe records nothing and every problem on disk went unreported. + // That is exactly the node an operator has just been told to run doctor on. + it('should report a problem for a stopped node with a broken bundle', () => { + const problems = analyseInstalled({ + status: 'INVALID', + reasons: [{ code: 'EXPIRED', message: 'The installed certificate expired on 2026-05-01' }], + warnings: [], + }); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getSeverity()).to.equal(SEVERITY.HIGH); + expect(problems[0].getDescription()).to.include('expired on 2026-05-01'); + }); + + // An operator who reads this is deciding whether to stop updating. Images + // keep arriving whatever the certificate does, and saying so is what keeps + // a client-reachability problem from being read as a software-delivery one. + it('should say that updates still deliver images', () => { + const [problem] = analyseInstalled({ + status: 'INVALID', + reasons: [{ code: 'EXPIRED', message: 'expired' }], + warnings: [], + }); + + expect(problem.getSolution()).to.include('still pulls new images'); + expect(problem.getSolution()).to.include('exits non-zero'); + }); + + it('should report each warning separately and more quietly', () => { + const problems = analyseInstalled({ + status: 'WARN', + reasons: [], + warnings: [ + { code: 'EXPIRING_SOON', message: 'expires tomorrow' }, + { code: 'PROVIDER_MISMATCH', message: 'issuer disagrees' }, + ], + }); + + expect(problems).to.have.lengthOf(2); + problems.forEach((problem) => expect(problem.getSeverity()).to.equal(SEVERITY.LOW)); + }); + + it('should say nothing when the checks passed', () => { + expect(analyseInstalled({ status: 'CHECKS_PASSED', reasons: [], warnings: [] })) + .to.have.lengthOf(0); + }); + + it('should still analyse what the gateway serves', () => { + const problems = analyseInstalled( + { status: 'CHECKS_PASSED', reasons: [], warnings: [] }, + served({ certificate: { fingerprint256: 'AA:BB', validTo: validTo(-1) } }), + ); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getDescription()).to.include('expired'); + }); + }); }); 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 7de4901d627..88d6c795660 100644 --- a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js @@ -10,6 +10,7 @@ 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 checkGatewayCertificateFactory from '../../../../../src/ssl/checkGatewayCertificateFactory.js'; import validateZeroSslCertificateFactory, { ERRORS as ZEROSSL_ERRORS } from '../../../../../src/ssl/zerossl/validateZeroSslCertificateFactory.js'; import providers from '../../../../../src/status/providers.js'; @@ -117,6 +118,7 @@ describe('collectSamplesTaskFactory', () => { homeDir, validateZeroSslCertificateFactory(homeDir, getCertificate), this.sinon.stub().resolves({}), + checkGatewayCertificateFactory(homeDir), ); analyseConfig = analyseConfigFactory(); From e92c4fda04220c73525f0829468eea4baf4c28ec Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 10:25:50 +0700 Subject: [PATCH 07/63] test(dashmate): prove contactless issuance against a real ACME server Removing the Let's Encrypt email prompt puts contactless issuance on the critical path for every fresh setup and every migration from another provider: no new node will have a contact address, so if this does not work the change does not work. The client half was already measured - lego does not require --email and substitutes noemail@example.com as a local directory name - but the authority's half was unproven past account registration. Against Pebble, through the real obtain task, for the same IP identifier: a certificate issued with no contact and one issued with a contact are identical where it matters - same validity-window length, same IP subject alternative name, and both installed as a matching pair. The provider is recorded for the contactless node and its email stays null. The half that issuance alone cannot prove is renewal. lego keys its account directory by the contact string, so a contactless node's account lives under noemail@example.com and `lego renew` needs the account that issued. Renewal is driven through renewCertificate - the helper's own entry point, with no options - and produces a new serial for the same address, still correctly paired. This is where a missing account would have surfaced, unattended, months later, on a node nobody was watching. Also pins two things the update check depends on: the certificate step renders as FAILED when the certificate is unresolved, because listr2 5.0.7 has no fail() on the task wrapper and a green line above an error message is worse than no line; and the gateway is reloaded after a successful obtain, skipped when it is not running, and not swallowed when the reload fails for any other reason. Pebble run: 11 passing. Co-Authored-By: Claude Opus 5 --- .../integration/ssl/letsencryptPebble.spec.js | 147 ++++++++++++++++++ .../gatewayCertificateTaskFactory.spec.js | 69 +++++++- 2 files changed, 215 insertions(+), 1 deletion(-) diff --git a/packages/dashmate/test/integration/ssl/letsencryptPebble.spec.js b/packages/dashmate/test/integration/ssl/letsencryptPebble.spec.js index 46c39e4e034..bae85be4b08 100644 --- a/packages/dashmate/test/integration/ssl/letsencryptPebble.spec.js +++ b/packages/dashmate/test/integration/ssl/letsencryptPebble.spec.js @@ -4,8 +4,13 @@ import crypto from 'crypto'; import Docker from 'dockerode'; import { asValue } from 'awilix'; import createDIContainer from '../../../src/createDIContainer.js'; +import Config from '../../../src/config/Config.js'; +import ConfigFile from '../../../src/config/configFile/ConfigFile.js'; +import ConfigFileJsonRepository from '../../../src/config/configFile/ConfigFileJsonRepository.js'; import HomeDir from '../../../src/config/HomeDir.js'; import getBaseConfigFactory from '../../../configs/defaults/getBaseConfigFactory.js'; +import isCertificatePairInstalled from '../../../src/ssl/letsencrypt/isCertificatePairInstalled.js'; +import renewCertificate from '../../../src/helper/renewCertificate.js'; /** * Obtain a certificate from a real ACME server. @@ -374,4 +379,146 @@ describe('Let\'s Encrypt certificate against a local ACME server', function main // clobber concurrent edits, and a renewal check runs unattended. expect(config.isChanged()).to.be.false(); }); + + /** + * Nothing prompts for a contact address any more, so every fresh setup and + * every migration from another provider issues without one. If contactless + * issuance does not work, the feature does not work - which is why this is a + * gate rather than a nice-to-have. + * + * The client half is already measured: lego does not require --email and + * substitutes noemail@example.com as a local directory name. What is proved + * here is the CA half - that registration without a contact is accepted and + * the certificate that comes back is the same certificate. + */ + describe('issuance without a contact address', () => { + /** + * @param {string} name + * @param {string|null} email + * @return {Config} + */ + function createConfig(name, email) { + const created = new Config(name, getBaseConfigFactory(homeDir)().getOptions()); + + created.set('externalIp', legoIp); + created.set('platform.gateway.ssl.providerConfigs.letsencrypt.email', email); + created.set( + 'platform.gateway.ssl.providerConfigs.letsencrypt.acmeDirectoryUrl', + `https://${PEBBLE_HOSTNAME}:${PEBBLE_ACME_PORT}/dir`, + ); + + return created; + } + + /** + * @param {Config} target + * @return {{certificate: crypto.X509Certificate, paired: boolean, accounts: string[]}} + */ + function inspect(target) { + const dir = homeDir.joinPath(target.getName(), 'platform', 'gateway', 'ssl'); + const legoDir = homeDir.joinPath(target.getName(), 'platform', 'gateway', 'lego'); + const bundlePath = path.join(dir, 'bundle.crt'); + const keyPath = path.join(dir, 'private.key'); + + return { + certificate: new crypto.X509Certificate(fs.readFileSync(bundlePath)), + paired: isCertificatePairInstalled( + path.join(legoDir, 'certificates', `${legoIp}.crt`), + path.join(legoDir, 'certificates', `${legoIp}.key`), + bundlePath, + keyPath, + ), + accounts: fs.readdirSync(path.join(legoDir, 'accounts'), { recursive: true }) + .map((entry) => entry.toString()), + }; + } + + let contactless; + let withContact; + + before(async () => { + const obtainLetsEncryptCertificateTask = container.resolve('obtainLetsEncryptCertificateTask'); + + contactless = createConfig('contactless', null); + withContact = createConfig('withcontact', 'operator@example.org'); + + await obtainLetsEncryptCertificateTask(contactless).run({ force: true }); + await obtainLetsEncryptCertificateTask(withContact).run({ force: true }); + }); + + it('should produce the same certificate with and without a contact address', () => { + const a = inspect(contactless); + const b = inspect(withContact); + + // Same identifier, same validity window length, same subject alternative + // name. A contact address buys nothing from the authority. + expect(a.certificate.subjectAltName).to.equal(`IP Address:${legoIp}`); + expect(b.certificate.subjectAltName).to.equal(a.certificate.subjectAltName); + + const window = (certificate) => new Date(certificate.validTo).getTime() + - new Date(certificate.validFrom).getTime(); + + expect(window(a.certificate)).to.equal(window(b.certificate)); + + // Both have to be installed as a matching pair, or the gateway cannot + // serve either of them. + expect(a.paired).to.be.true(); + expect(b.paired).to.be.true(); + }); + + it('should record the provider for a node that has no contact address', () => { + expect(contactless.get('platform.gateway.ssl.enabled')).to.be.true(); + expect(contactless.get('platform.gateway.ssl.provider')).to.equal('letsencrypt'); + expect(contactless.get('platform.gateway.ssl.providerConfigs.letsencrypt.email')).to.be.null(); + }); + + // The account directory lego uses is named after the contact address, so a + // contactless node's account lives somewhere else entirely. `lego renew` + // needs the account that issued, which makes this the half of contactless + // operation that issuance alone does not prove. + it('should keep the two accounts apart on disk', () => { + expect(inspect(contactless).accounts.some((entry) => entry.includes('noemail@example.com'))) + .to.be.true(); + expect(inspect(withContact).accounts.some((entry) => entry.includes('operator@example.org'))) + .to.be.true(); + }); + + // Renewal is where a missing account would surface, and it runs unattended + // inside the helper - the one place a failure goes unnoticed for months. + it('should renew a contactless certificate through the helper entry point', async () => { + const obtainLetsEncryptCertificateTask = container.resolve('obtainLetsEncryptCertificateTask'); + const before = inspect(contactless).certificate.serialNumber; + + const configFile = new ConfigFile( + [contactless], + '4.2.0', + 'abcdef12', + contactless.getName(), + null, + ); + const configFileRepository = new ConfigFileJsonRepository( + (data) => data, + homeDir, + () => null, + ); + configFileRepository.write(configFile); + + const { renewed } = await renewCertificate({ + configName: contactless.getName(), + provider: 'letsencrypt', + // Well past the certificate's own six-day life, so renewal is due. + expirationDays: 60, + obtainCertificateTask: obtainLetsEncryptCertificateTask, + configFileRepository, + writeConfigTemplates: () => {}, + }); + + expect(renewed).to.be.true(); + + const after = inspect(contactless); + expect(after.certificate.serialNumber).to.not.equal(before); + expect(after.certificate.subjectAltName).to.equal(`IP Address:${legoIp}`); + expect(after.paired).to.be.true(); + }); + }); }); diff --git a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js index 59dec27ef9d..f588d23f4ee 100644 --- a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js @@ -10,6 +10,7 @@ import { CERTIFICATE_STATUS, } from '../../../../../src/ssl/checkGatewayCertificateFactory.js'; import getEnquirerMock from '../../../../../src/test/mock/getEnquirerMock.js'; +import ServiceIsNotRunningError from '../../../../../src/docker/errors/ServiceIsNotRunningError.js'; describe('gatewayCertificateTaskFactory', () => { let homeDir; @@ -82,7 +83,11 @@ describe('gatewayCertificateTaskFactory', () => { await tasks.run(context); - return { context, errors: (tasks.err ?? []).map((e) => e?.error ?? e) }; + return { + context, + errors: (tasks.err ?? []).map((e) => e?.error ?? e), + state: tasks.tasks[0].state, + }; } beforeEach(function it() { @@ -368,6 +373,68 @@ describe('gatewayCertificateTaskFactory', () => { expect(obtainLetsEncryptCertificateTask).to.not.have.been.called(); expect(configFileRepository.write).to.not.have.been.called(); }); + + // listr2 5.0.7 has no fail() on the task wrapper, so throwing is the only + // way to show the operator which step went wrong. A green line above an + // error message is worse than no line at all. + it('should render the step as failed', async function it() { + const { state } = await run.call(this, { + checkGatewayCertificate: () => invalid(), + interactive: false, + }); + + expect(state).to.equal('FAILED'); + }); + }); + + describe('the gateway is told about a new certificate', () => { + // Envoy reads the certificate files once at startup. Under the documented + // upgrade procedure the node is stopped and the new certificate loads at + // the next start, but update against a running node is supported too, and + // there this is what makes the change reach the wire. + it('should reload a running gateway after a successful obtain', async function it() { + let checked = 0; + await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 ? invalid() : verdict(); + }, + answers: [true], + }); + + expect(dockerCompose.execCommand) + .to.have.been.calledOnceWith(config, 'gateway', 'kill -SIGHUP 1'); + }); + + it('should carry on when the gateway is not running', async function it() { + dockerCompose.execCommand.rejects(new ServiceIsNotRunningError('base', 'gateway')); + + let checked = 0; + const { errors } = await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 ? invalid() : verdict(); + }, + answers: [true], + }); + + expect(errors).to.be.empty(); + }); + + it('should not swallow a reload that failed for another reason', async function it() { + dockerCompose.execCommand.rejects(new Error('docker daemon is unreachable')); + + let checked = 0; + const { errors } = await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 ? invalid() : verdict(); + }, + answers: [true], + }); + + expect(errors[0].message).to.contain('docker daemon is unreachable'); + }); }); describe('the bypass suppresses enforcement, never the check', () => { From 73bbc404919d87e6ef38e99ef0655acbbc9ca935 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 10:29:32 +0700 Subject: [PATCH 08/63] fix(dashmate): honour the JSON contract and stop the preflight claiming a pull Three corrections found by running the command rather than the tests. The read-only preflight opened with "dashmate update could not pull images, and stopped" - it starts no pull at all, so that reported a registry failure that never happened and would send an operator to look at something that is fine. The opening now says nothing about images when none were pulled. Under --format json the certificate diagnostics were only written for --check-certificate, so an ordinary JSON run reported the verdict nowhere a machine could read it. stdout still carries exactly one parseable array; the diagnostics go to stderr as one line, with reasons and warnings as arrays - they are ordered lists because several can be true at once, and collapsing them to one value reintroduces a precedence nobody defined - alongside the pull result. The renderer ignored --verbose entirely. Interactivity still beats it, because the verbose renderer manages no prompt area and -v is exactly what an operator adds when the check has just failed, but a non-interactive verbose run now gets the verbose renderer. Tests: 3 new. The preflight one was red before this commit against the existing message, green after. Verified against a real config: the preflight reports, exits 1 on INVALID and 0 where the check is out of scope, and prints commands carrying --config testnet throughout. Co-Authored-By: Claude Opus 5 --- packages/dashmate/src/commands/update.js | 25 ++++++++++- .../src/ssl/renderCertificateGuidance.js | 26 ++++++++---- .../test/unit/commands/update.spec.js | 41 +++++++++++++++++++ .../ssl/renderCertificateGuidance.spec.js | 11 +++++ 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/packages/dashmate/src/commands/update.js b/packages/dashmate/src/commands/update.js index 4a431c489f2..c8d62c61b4f 100644 --- a/packages/dashmate/src/commands/update.js +++ b/packages/dashmate/src/commands/update.js @@ -229,7 +229,13 @@ export default class UpdateCommand extends ConfigBaseCommand { // Without this the throw would skip the pull report entirely, hiding // the table - including any image that failed to download. exitOnError: false, - renderer: format === OUTPUT_FORMATS.JSON ? 'silent' : 'default', + // Interactivity beats --verbose: the verbose renderer manages no prompt + // area, and -v is exactly what an operator adds when the check has just + // failed. + renderer: (format === OUTPUT_FORMATS.JSON && 'silent') + || (interactive && 'default') + || (isVerbose && 'verbose') + || 'default', rendererOptions: { showTimer: isVerbose, clearOutput: false, @@ -256,6 +262,23 @@ export default class UpdateCommand extends ConfigBaseCommand { const unresolved = errors.find((error) => error instanceof CertificateUnresolvedError); const unexpected = errors.find((error) => !(error instanceof CertificateUnresolvedError)); + // Under JSON output stdout is exactly one parseable array, so everything a + // machine might want about the certificate goes to stderr as one line. + if (format === OUTPUT_FORMATS.JSON && context.certificate) { + process.stderr.write(`${JSON.stringify({ + status: context.certificate.status, + reasons: context.certificate.reasons.map(({ code }) => code), + warnings: context.certificate.warnings.map(({ code }) => code), + provider: context.certificate.provider, + config: config.getName(), + expiresAt: context.certificate.installed + ? context.certificate.installed.validTo.toISOString() + : null, + skipped: context.certificateSkipped === true, + pull: this.pullResult ?? null, + })}\n`); + } + (context.certificateWarnings ?? []).forEach((warning) => { process.stderr.write(`${warning}\n\n`); }); diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index cc08c71d0bc..cce09eb9578 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -9,20 +9,31 @@ import { CERTIFICATE_REASONS } from './checkGatewayCertificateFactory.js'; * fetched. `updateNode` resolves a failed pull as an `error` row rather than * rejecting, so a run can succeed as a whole and still have delivered nothing. * + * A null pull means none was attempted - the read-only preflight - and the + * opening then says nothing about images at all, rather than reporting a + * failure that never happened. + * * @param {{ok: boolean, failed: number, total: number}|null} pull * @return {string} */ -function renderPullSummary(pull) { - if (!pull || !pull.ok) { - return 'dashmate update could not pull images, and stopped'; +function renderOpening(pull) { + if (pull === null || pull === undefined) { + return " This node's installed TLS certificate did not pass dashmate's checks."; + } + + if (!pull.ok) { + return ` dashmate update could not pull images, and stopped: this node's installed TLS + certificate did not pass dashmate's checks.`; } if (pull.failed > 0) { - return `dashmate update pulled images - ${pull.failed} of ${pull.total} failed,` - + ' see the table above - then stopped'; + return ` dashmate update pulled images - ${pull.failed} of ${pull.total} failed, see the + table above - then stopped: this node's installed TLS certificate did not + pass dashmate's checks.`; } - return 'dashmate update pulled images, then stopped'; + return ` dashmate update pulled images, then stopped: this node's installed TLS + certificate did not pass dashmate's checks.`; } /** @@ -181,8 +192,7 @@ export default function renderCertificateGuidance({ .some(({ code }) => code === CERTIFICATE_REASONS.SWITCH_INCOMPLETE); const blocks = [ - ` ${renderPullSummary(pull)}: this node's installed TLS - certificate did not pass dashmate's checks. + `${renderOpening(pull)} Node: ${config.get('network')} (config "${config.getName()}", ${config.get('externalIp') ?? 'no external IP set'}) Certificate: ${renderObservation(verdict)} diff --git a/packages/dashmate/test/unit/commands/update.spec.js b/packages/dashmate/test/unit/commands/update.spec.js index 20cfc468f66..d1dfecdc561 100644 --- a/packages/dashmate/test/unit/commands/update.spec.js +++ b/packages/dashmate/test/unit/commands/update.spec.js @@ -372,6 +372,47 @@ describe('Update command', () => { }); }); + describe('machine output', () => { + // Under JSON output stdout carries exactly one parseable array, so nothing + // about the certificate may be written there. + it('should keep the certificate diagnostics off stdout', async function it() { + const log = this.sinon.stub(console, 'log'); + + await runUpdate({ + checkGatewayCertificate: () => invalidVerdict(), + gatewayCertificateTask: () => async (ctx) => { + ctx.certificate = invalidVerdict(); + }, + }); + + expect(log).to.have.been.calledOnce(); + expect(() => JSON.parse(log.firstCall.firstArg)).to.not.throw(); + expect(stderr).to.contain('"status":"INVALID"'); + }); + + // reasons and warnings are ordered arrays because several can be true at + // once, and collapsing them to one value reintroduces a precedence nobody + // defined. + it('should emit reasons and warnings as arrays alongside the pull result', async () => { + await runUpdate({ + gatewayCertificateTask: () => async (ctx) => { + ctx.certificate = { + ...invalidVerdict(), + warnings: [{ code: 'PROVIDER_MISMATCH', message: 'x' }], + }; + }, + }).catch(() => {}); + + const line = stderr.split('\n').find((entry) => entry.startsWith('{')); + const diagnostics = JSON.parse(line); + + expect(diagnostics.reasons).to.deep.equal(['EXPIRED']); + expect(diagnostics.warnings).to.deep.equal(['PROVIDER_MISMATCH']); + expect(diagnostics.pull).to.deep.equal({ ok: true, failed: 0, total: 1 }); + expect(diagnostics.status).to.not.equal('VALID'); + }); + }); + // A prompt that leaks past the interactivity guard neither throws nor // settles - the event loop drains and the process exits 0 with nothing done. // The entry-time exit code is the only thing that turns that into a failure. diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index de53fd57cff..1370690dbff 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -140,6 +140,17 @@ describe('renderCertificateGuidance', () => { expect(render()).to.contain('pulled images, then stopped'); }); + // The read-only preflight starts no pull at all, so it must not say anything + // about one. Reporting a pull failure that never happened sends an operator + // to look at a registry that is fine. + it('should say nothing about images when no pull was attempted', () => { + const output = render({ pull: null }); + + expect(output).to.not.contain('could not pull images'); + expect(output).to.not.contain('pulled images'); + expect(output).to.contain("This node's installed TLS certificate did not pass"); + }); + it('should explain the ZeroSSL wall without blaming the operator', () => { const output = render(); From d6ec6af0e0737f4dcd69a781e18f7b0a27a5924a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 10:58:28 +0700 Subject: [PATCH 09/63] refactor(dashmate): one rule for reading a boolean environment variable DASHMATE_NON_INTERACTIVE and DASHMATE_SKIP_CERTIFICATE_CHECK have to be read the same way - unset, "0", "false" and empty all mean off, case-folded - and the rule had been written out twice. Also pins two things the certificate check depends on and covers the account-directory hazard from both sides. There is no exit code beyond 0, 1 and 2. A "the check could not run" code was considered and dropped: the configuration lock is taken before the command body runs and the repository throws a plain Error, so the boundary cannot tell that case apart without a typed error and central mapping, and a lock timeout is an ordinary failure. Against Pebble: reissuing for a node that has a contact address reuses the account directory that address names, and a switch interrupted between installing the pair and saving the provider is detected as SWITCH_INCOMPLETE and blocks - the window that only shows up once real lego output is on disk. The integration suite's budget goes to 15 minutes. `lego renew` sleeps a random delay of up to about eight minutes whenever the authority's renewalInfo endpoint says renewal is not yet due, which the renewal case always hits because it renews a certificate issued moments earlier. That was making the run's duration a coin toss; the new budget covers the sleep instead of racing it. The reissue case above deliberately does not pay it twice. Co-Authored-By: Claude Opus 5 --- packages/dashmate/src/commands/update.js | 15 +----- .../dashmate/src/util/isEnvironmentFlagSet.js | 19 +++++++ .../dashmate/src/util/isInteractiveSession.js | 20 +------- .../integration/ssl/letsencryptPebble.spec.js | 51 ++++++++++++++++++- .../test/unit/commands/update.spec.js | 17 +++++++ 5 files changed, 88 insertions(+), 34 deletions(-) create mode 100644 packages/dashmate/src/util/isEnvironmentFlagSet.js diff --git a/packages/dashmate/src/commands/update.js b/packages/dashmate/src/commands/update.js index c8d62c61b4f..e6fe0f639c4 100644 --- a/packages/dashmate/src/commands/update.js +++ b/packages/dashmate/src/commands/update.js @@ -9,6 +9,7 @@ import printArrayOfObjects from '../printers/printArrayOfObjects.js'; import CertificateUnresolvedError from '../ssl/errors/CertificateUnresolvedError.js'; import { CERTIFICATE_STATUS } from '../ssl/checkGatewayCertificateFactory.js'; import renderCertificateGuidance from '../ssl/renderCertificateGuidance.js'; +import isEnvironmentFlagSet from '../util/isEnvironmentFlagSet.js'; import isInteractiveSession from '../util/isInteractiveSession.js'; /** @@ -17,20 +18,6 @@ import isInteractiveSession from '../util/isInteractiveSession.js'; */ const GATED_NETWORKS = [NETWORK_MAINNET, NETWORK_TESTNET]; -/** - * @param {string|undefined} value - * @return {boolean} - */ -function isEnvironmentFlagSet(value) { - if (value === undefined || value === null) { - return false; - } - - const normalized = String(value).trim().toLowerCase(); - - return normalized !== '' && normalized !== '0' && normalized !== 'false'; -} - export default class UpdateCommand extends ConfigBaseCommand { // The certificate check can obtain a certificate and record the provider that // issued it, so it holds the configuration lock for its whole run. diff --git a/packages/dashmate/src/util/isEnvironmentFlagSet.js b/packages/dashmate/src/util/isEnvironmentFlagSet.js new file mode 100644 index 00000000000..c38e6f6d0f8 --- /dev/null +++ b/packages/dashmate/src/util/isEnvironmentFlagSet.js @@ -0,0 +1,19 @@ +/** + * Whether an environment variable carrying a boolean is switched on. + * + * An unset variable, "0", "false" and an empty value all mean off. The + * comparison is case-folded because the systems that set these write TRUE, + * True and true interchangeably and all three mean the same thing. + * + * @param {string|undefined} value + * @return {boolean} + */ +export default function isEnvironmentFlagSet(value) { + if (value === undefined || value === null) { + return false; + } + + const normalized = String(value).trim().toLowerCase(); + + return normalized !== '' && normalized !== '0' && normalized !== 'false'; +} diff --git a/packages/dashmate/src/util/isInteractiveSession.js b/packages/dashmate/src/util/isInteractiveSession.js index 339fe89e3c7..521de6181b3 100644 --- a/packages/dashmate/src/util/isInteractiveSession.js +++ b/packages/dashmate/src/util/isInteractiveSession.js @@ -1,22 +1,4 @@ -/** - * Whether an environment variable carrying a boolean is switched on. - * - * An unset variable, "0", "false" and an empty value all mean off. The - * comparison is case-folded because CI systems write TRUE, True and true - * interchangeably and all three mean the same thing. - * - * @param {string|undefined} value - * @return {boolean} - */ -function isEnvironmentFlagSet(value) { - if (value === undefined || value === null) { - return false; - } - - const normalized = String(value).trim().toLowerCase(); - - return normalized !== '' && normalized !== '0' && normalized !== 'false'; -} +import isEnvironmentFlagSet from './isEnvironmentFlagSet.js'; /** * Decide whether this process may ask the operator a question. diff --git a/packages/dashmate/test/integration/ssl/letsencryptPebble.spec.js b/packages/dashmate/test/integration/ssl/letsencryptPebble.spec.js index bae85be4b08..d0633e76baa 100644 --- a/packages/dashmate/test/integration/ssl/letsencryptPebble.spec.js +++ b/packages/dashmate/test/integration/ssl/letsencryptPebble.spec.js @@ -152,7 +152,11 @@ describe('Pebble candidate network selection', () => { }); describe('Let\'s Encrypt certificate against a local ACME server', function main() { - this.timeout(5 * 60 * 1000); + // `lego renew` sleeps a random delay of up to about eight minutes when the + // authority's renewalInfo endpoint says renewal is not yet due, which the + // renewal case below always hits: the certificate it renews was issued + // moments earlier. The budget covers that sleep rather than racing it. + this.timeout(15 * 60 * 1000); const docker = new Docker(); const networkName = `dashmate-acme-test-${crypto.randomBytes(4).toString('hex')}`; @@ -483,6 +487,51 @@ describe('Let\'s Encrypt certificate against a local ACME server', function main .to.be.true(); }); + // A node that already has an address on file must keep using the account + // that address names. Nothing may quietly move it: a new account means a + // new account key and a reset failed-authorization budget, spent against + // the per-address registration limit. + // + // Issued rather than renewed, so this does not pay lego's renewal delay + // twice. The renewal path is covered below, and the property under test - + // which account directory the address resolves to - is the same either way. + it('should reissue for a node with a contact address against its original account', async () => { + const obtainLetsEncryptCertificateTask = container.resolve('obtainLetsEncryptCertificateTask'); + const accountsBefore = inspect(withContact).accounts; + const serialBefore = inspect(withContact).certificate.serialNumber; + + await obtainLetsEncryptCertificateTask(withContact).run({ force: true }); + + const after = inspect(withContact); + + expect(after.certificate.serialNumber).to.not.equal(serialBefore); + expect(after.accounts).to.deep.equal(accountsBefore); + expect(withContact.get('platform.gateway.ssl.providerConfigs.letsencrypt.email')) + .to.equal('operator@example.org'); + }); + + // The window between installing the pair and saving the provider. Left as + // a warning this never repairs itself - the helper keeps renewing the old + // provider while the installed six-day certificate runs out - so it has to + // block, and the block has to name a repair that needs no new certificate. + it('should detect a switch interrupted before the provider was saved', () => { + const checkGatewayCertificate = container.resolve('checkGatewayCertificate'); + + expect(checkGatewayCertificate(contactless).status).to.equal('CHECKS_PASSED'); + + // Exactly what a kill between the two steps leaves behind: the pair lego + // produced is installed for the gateway, the setting still names the + // provider it was switched away from. + contactless.set('platform.gateway.ssl.provider', 'zerossl'); + + const verdict = checkGatewayCertificate(contactless); + + expect(verdict.status).to.equal('INVALID'); + expect(verdict.reasons.map(({ code }) => code)).to.deep.equal(['SWITCH_INCOMPLETE']); + + contactless.set('platform.gateway.ssl.provider', 'letsencrypt'); + }); + // Renewal is where a missing account would surface, and it runs unattended // inside the helper - the one place a failure goes unnoticed for months. it('should renew a contactless certificate through the helper entry point', async () => { diff --git a/packages/dashmate/test/unit/commands/update.spec.js b/packages/dashmate/test/unit/commands/update.spec.js index d1dfecdc561..60b8833433b 100644 --- a/packages/dashmate/test/unit/commands/update.spec.js +++ b/packages/dashmate/test/unit/commands/update.spec.js @@ -1,3 +1,5 @@ +import fs from 'fs'; +import path from 'path'; import UpdateCommand from '../../../src/commands/update.js'; import HomeDir from '../../../src/config/HomeDir.js'; import getBaseConfigFactory from '../../../configs/defaults/getBaseConfigFactory.js'; @@ -281,6 +283,21 @@ describe('Update command', () => { // A read-only preflight is meant to be run before the node is stopped, // possibly while the helper is renewing. Taking a write lock there would // let it fail on a lock timeout for no reason. + // A "the check could not run" exit code was considered and dropped: the + // configuration lock is taken before the command body runs and the + // repository throws a plain Error, so the boundary cannot tell that case + // apart without a typed error and central mapping. A lock timeout is an + // ordinary failure and exits 1. + it('should use no exit code beyond 0, 1 and 2', () => { + const source = fs.readFileSync( + path.join(process.cwd(), 'src/commands/update.js'), + 'utf8', + ); + + expect(source).to.not.match(/exitCode\s*=\s*[3-9]/); + expect(source).to.not.match(/process\.exit\(/); + }); + it('should not take the configuration lock', () => { expect(UpdateCommand.mutatesConfig).to.be.true(); expect(UpdateCommand.shouldSkipConfigLock({ 'check-certificate': true })).to.be.true(); From 01fcd60d3a414ad48599f347cb89d2c1fa5905eb Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 11:01:50 +0700 Subject: [PATCH 10/63] fix(dashmate): stop warning about ZeroSSL after switching away from it The days-remaining warning is unconditional by design - a human must never be told less than a script, and nothing else tells an operator that a free ZeroSSL account has run out of certificates until it has. But when the offered switch actually succeeds the node is no longer on ZeroSSL, and printing its expiry directly above "Certificate obtained from Let's Encrypt" contradicts the success it just reported. The warning now fires on every path except that one, including when the switch was attempted and failed without touching anything - there the node really is still on ZeroSSL and still needs to hear it. Test: red before this commit, green after. Co-Authored-By: Claude Opus 5 --- .../update/gatewayCertificateTaskFactory.js | 31 ++++++++++++++----- .../gatewayCertificateTaskFactory.spec.js | 17 ++++++++++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js index b33e77ef1f3..939a3b9e7e9 100644 --- a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js @@ -232,16 +232,22 @@ export default function gatewayCertificateTaskFactory( const daysLeft = Math.floor(verdict.expiresInDays ?? 0); - ctx.certificateCourtesyOffered = true; - ctx.certificateWarnings = [ - ...(ctx.certificateWarnings ?? []), - `This node's ZeroSSL certificate expires in ${daysLeft} days. A free ZeroSSL` - + " account allows three certificates in total, so dashmate's renewals stop" - + ` working after about 270 days. Switch to Let's Encrypt with:` - + `\n dashmate ssl obtain ${cfg} --provider letsencrypt`, - ]; + // Said on every run, to a human and to a script alike: a free ZeroSSL + // account allows three certificates in total, and nothing tells an + // operator that renewal has stopped being possible until it has. + const warn = () => { + ctx.certificateWarnings = [ + ...(ctx.certificateWarnings ?? []), + `This node's ZeroSSL certificate expires in ${daysLeft} days. A free ZeroSSL` + + " account allows three certificates in total, so dashmate's renewals stop" + + ` working after about 270 days. Switch to Let's Encrypt with:` + + `\n dashmate ssl obtain ${cfg} --provider letsencrypt`, + ]; + }; if (!interactive) { + warn(); + return; } @@ -255,6 +261,8 @@ export default function gatewayCertificateTaskFactory( }, { interactive }); if (!accepted) { + warn(); + return; } @@ -267,11 +275,18 @@ export default function gatewayCertificateTaskFactory( ctx.certificate = after; if (ctx.certificateObtainError) { + // Nothing was touched, so the node still holds the ZeroSSL + // certificate and still needs to hear about it. + warn(); + ctx.certificateWarnings.push( `The switch to Let's Encrypt did not complete: ${ctx.certificateObtainError.message}` + '\nThe certificate this node was already using is untouched.', ); } else { + // The node is no longer on ZeroSSL, so its expiry is no longer + // this node's problem and repeating it would contradict the + // success message. ctx.certificateSuccess = renderSuccess(config, after); } diff --git a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js index f588d23f4ee..ff8a7731549 100644 --- a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js @@ -187,6 +187,23 @@ describe('gatewayCertificateTaskFactory', () => { expect(context.certificateWarnings.join('\n')).to.contain('untouched'); }); + // The node is no longer on ZeroSSL, so repeating its expiry alongside the + // success message would contradict it. + it('should drop the ZeroSSL warning once the switch succeeds', async function it() { + let checked = 0; + const { context, errors } = await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 ? verdict() : verdict({ provider: 'letsencrypt' }); + }, + answers: [true], + }); + + expect(errors).to.be.empty(); + expect(context.certificateWarnings).to.be.undefined(); + expect(context.certificateSuccess).to.contain('LEAVE PORT 80 OPEN'); + }); + // saveCertificateTask writes the bundle and the key as two separate // in-place writes, so a failure between them can replace a working pair // with a mismatched one. Promising exit 0 here would tell an operator their From 2d0635703d74db9b43c845aacff23372a06a3ef8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 11:10:06 +0700 Subject: [PATCH 11/63] style(dashmate): drop lint noise this change introduced Two eslint-disable directives for a rule this project does not enable, and an unused variable in a test. The workspace is back to the exact 48 warnings it carried before this work, so nothing here adds to them. Co-Authored-By: Claude Opus 5 --- .../ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js | 2 -- .../letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index 66e2d7128a6..e6028ef6afe 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -320,7 +320,6 @@ export default function obtainLetsEncryptCertificateTaskFactory( for (let attempt = 1; attempt <= MAX_OBTAIN_ATTEMPTS; attempt += 1) { try { - // eslint-disable-next-line no-await-in-loop await runLego(); break; @@ -337,7 +336,6 @@ export default function obtainLetsEncryptCertificateTaskFactory( // operator has not left the terminal to change a firewall rule, // and each attempt spends one of the five failed authorizations // per hour this node shares with its own automatic renewal. - // eslint-disable-next-line no-await-in-loop const retry = canRetry && await promptOrThrow(task, { type: 'toggle', header: ` Let's Encrypt could not reach ${ctx.externalIp} on port 80: diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js index 0fbc2aa3cea..c1e64d44389 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js @@ -406,13 +406,11 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { describe('port 80 retry loop', () => { let homeDir; let config; - let legoDir; beforeEach(() => { homeDir = HomeDir.createTemp(); config = getBaseConfigFactory(homeDir)(); config.set('externalIp', '1.2.3.4'); - legoDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'lego'); }); afterEach(() => homeDir.remove()); From 1a07767485293df208bdd4e35e5d3f6ad762d61e Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 12:00:45 +0700 Subject: [PATCH 12/63] fix(dashmate): five defects found by cross-model review A REJECTED PULL EXITED 0. Converting the pull to a never-rejecting settled result made a total failure report one line and return, so nothing threw and the command succeeded. The baseline awaited updateNode directly, so a rejection propagated and exited non-zero. Two paths genuinely reject - getServiceList throwing on a compose service with no title label, and a synchronous throw from docker.pull inside its executor - and on either of them a playbook running `update && start` was handed a node whose images were never fetched, with no exit code for set -e to catch. The error is now retained and raised after the certificate has had its say, so an operator with both problems still gets the remediation for the one they can act on and still sees a non-zero exit for the one they cannot. Per-image failures are unaffected: those resolve as error rows and have always exited 0. THE PREFLIGHT WAS NEITHER READ-ONLY NOR LOCK-FREE. --check-certificate opted out of the config lock in runWithDependencies, but BaseCommand.init runs first and calls readAndMigrate for every command, which takes the same lock, renders service templates and rewrites config.json whenever a migration is due. A migration is due on exactly one run: the first after upgrading, which is the only run the preflight exists for. So the command documented as changing nothing wrote config, rendered templates, and could abort on a 15s lock timeout while the helper renewed. The opt-out now covers the migration too, and says what it means: isReadOnlyRun. readAndMigrate gains a readOnly option that migrates in memory and stops there. COMPLETING AN INTERRUPTED SWITCH NEVER RE-JUDGED. The branch saved the setting, re-checked, and discarded the result - alone among its siblings. The installed pair being byte-identical to the one lego produced says nothing about whether it is still valid, so a node whose switch was interrupted months ago takes this branch with a long-expired certificate, is told "no certificate needs to be obtained", and exits 0 dark. That state now falls through to the obtain, because the setting is not what is missing, and the branch that does run judges what the node holds afterwards. The guidance printed non-interactively carried the same false claim and is guarded the same way. DOCTOR SUGGESTED COMMANDS WITHOUT A NODE. The new on-disk prescriptions rendered `dashmate ssl obtain --provider letsencrypt` with no --config, which falls back to the default config: pasted on a multi-node host it re-issues a certificate for a different node's address and rewrites that node's provider. Every command in this analyser now names the node it analysed, the pre-existing blocks in the same file included, rather than leaving two conventions in one file. The circular "run dashmate doctor" suggestion inside a doctor report is dropped. INSTALLING OPERATOR FILES LEFT A PLAINTEXT LISTENER. Offering a self-signed node the chance to install its own certificate ran saveCertificateTask, which sets only ssl.enabled. Config still said self-signed, and the gateway listener is branched on it: self-signed renders a tls_inspector plus a raw_buffer filter chain, so the DAPI port went on accepting plaintext on a node whose operator had just done the right thing. The provider is now recorded as `file` after the files are installed - after, so configuration can never name a provider the node has no certificate for - and persisted immediately, because update carries on into a multi-minute pull and the end-of-run save is skipped whenever the run later throws. Also carries the new verdict's warnings out of the two branches that re-checked and returned without them, which is the only reason a provider-mismatch warning would have vanished from plain output. Tests: 14 new, all red before this commit - - a rejecting updateNode resolved instead of rejecting (3 red) - readAndMigrate wrote and locked under readOnly; the command's own declaration did not exist (3 red) - an expired interrupted switch reported success and never obtained (2 red), and the guidance offered the setting as the repair (1 red) - doctor rendered commands with no --config and suggested doctor (2 red) - the provider stayed self-signed, nothing was persisted, and warnings were dropped (3 red) All green after. 516 unit tests passing, 0 lint errors. Co-Authored-By: Claude Opus 5 --- packages/dashmate/src/commands/update.js | 29 ++++- .../configFile/ConfigFileJsonRepository.js | 6 +- .../analyseGatewayCertificateFactory.js | 47 +++++--- .../update/gatewayCertificateTaskFactory.js | 61 +++++++++- .../dashmate/src/oclif/command/BaseCommand.js | 24 +++- .../src/ssl/renderCertificateGuidance.js | 8 +- .../test/unit/commands/update.spec.js | 59 ++++++++-- .../ConfigFileJsonRepository.spec.js | 24 ++++ .../analyseGatewayCertificateFactory.spec.js | 39 ++++++- .../gatewayCertificateTaskFactory.spec.js | 108 ++++++++++++++++++ .../unit/oclif/command/BaseCommand.spec.js | 37 +++++- .../ssl/renderCertificateGuidance.spec.js | 18 +++ 12 files changed, 410 insertions(+), 50 deletions(-) diff --git a/packages/dashmate/src/commands/update.js b/packages/dashmate/src/commands/update.js index e6fe0f639c4..bd80b20d267 100644 --- a/packages/dashmate/src/commands/update.js +++ b/packages/dashmate/src/commands/update.js @@ -24,14 +24,16 @@ export default class UpdateCommand extends ConfigBaseCommand { static mutatesConfig = true; /** - * The read-only preflight changes nothing, and it exists to be run before the - * node is stopped - possibly while the helper is renewing. Taking a write - * lock there would let it fail on a lock timeout for no reason. + * The preflight changes nothing, and it exists to be run before the node is + * stopped - possibly while the helper is renewing. So it takes no lock, saves + * no configuration, and does not persist a migration: any of those could make + * it fail on a lock acquire timeout, and all of them would break the promise + * that it changes nothing. * * @param {Object} flags * @return {boolean} */ - static shouldSkipConfigLock(flags) { + static isReadOnlyRun(flags) { return flags['check-certificate'] === true; } @@ -170,9 +172,13 @@ export default class UpdateCommand extends ConfigBaseCommand { const result = await settled; if (!result.ok) { + // Nothing was fetched at all - not a per-image failure, which resolves + // as an error row and has always exited 0. Retained so it can be + // raised once the certificate has had its say: returning quietly here + // hands `update && start` a node whose images were never downloaded, + // with no exit code for the caller to catch. this.pullResult = { ok: false, failed: 0, total: 0 }; - - process.stderr.write(`Failed to pull images: ${result.error.message}\n`); + this.pullError = result.error; return; } @@ -286,9 +292,20 @@ export default class UpdateCommand extends ConfigBaseCommand { throw unexpected; } + // Printed before either failure is raised, so an operator whose node has + // both problems still gets the remediation for the one they can act on. if (unresolved) { await reportUnresolved(unresolved.getVerdict()); + } + // A pull that fetched nothing is what this command exists to do, so it + // outranks the certificate: the caller has to see a non-zero exit and the + // reason, not a muted certificate message. + if (this.pullError) { + throw this.pullError; + } + + if (unresolved) { throw new MuteOneLineError(unresolved); } diff --git a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js index b3add6cef0d..a13d909a0fd 100644 --- a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js +++ b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js @@ -231,6 +231,10 @@ export default class ConfigFileJsonRepository { * write. * * @param {Object} [options={}] - passed through to read() + * @param {boolean} [options.readOnly=false] - migrate in memory and stop + * there: no lock, no render, no save. For a caller that has promised to + * change nothing, which has to hold even on the one run where a migration + * is due * @param {function(Config[]): void} [onMigrated] - runs before the migrated * config file is saved and while the lock is held * @returns {{configFile: ConfigFile}} @@ -247,7 +251,7 @@ export default class ConfigFileJsonRepository { // Migrations are not all pure - some move service files on disk and delete // the originals - so running them to find out would do that work outside // the lock, and again inside it. - if (!this.#isMigrationDue()) { + if (options.readOnly === true || !this.#isMigrationDue()) { return { configFile: this.read(options) }; } diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 8a7637c609f..b40b586a659 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -1,13 +1,20 @@ import chalk from 'chalk'; import { SEVERITY } from '../Prescription.js'; import Problem from '../Problem.js'; +import renderConfigFlag from '../../util/renderConfigFlag.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. + * + * The node is named because a report is read against one config among several, and a command + * pasted without one acts on whichever happens to be the default. + * + * @param {string} cfg + * @return {string} */ -const RESTART_HINT = chalk`Then restart Platform so the gateway picks it up: {bold.cyanBright dashmate restart --platform}`; +const restartHint = (cfg) => chalk`Then restart Platform so the gateway picks it up: {bold.cyanBright dashmate restart ${cfg} --platform}`; /** * An operator reading a certificate problem is deciding whether their node is @@ -15,9 +22,10 @@ const RESTART_HINT = chalk`Then restart Platform so the gateway picks it up: {bo * does, and only refuses to report success. Leaving this out lets a client * reachability problem be read as a software delivery one. */ -const UPDATE_CONSEQUENCE = 'While this is unresolved, clients cannot connect to this node.' - + ' `dashmate update` still pulls new images, so protocol upgrades and security patches' - + ' continue to arrive - but it exits non-zero until the certificate is fixed.'; +const UPDATE_CONSEQUENCE = 'Standards-compliant clients reject a certificate in this state,' + + ' so this node may not be reachable. `dashmate update` still pulls new images, so protocol' + + ' upgrades and security patches continue to arrive - but it exits non-zero until the' + + ' certificate is fixed.'; export default function analyseGatewayCertificateFactory() { /** @@ -34,6 +42,8 @@ export default function analyseGatewayCertificateFactory() { return []; } + const cfg = renderConfigFlag(config.getName()); + const problems = []; // The gateway is stopped whenever the documented upgrade procedure is @@ -49,10 +59,9 @@ export default function analyseGatewayCertificateFactory() { message, chalk`${UPDATE_CONSEQUENCE} -Check what is wrong and obtain a new certificate: -{bold.cyanBright dashmate doctor} -{bold.cyanBright dashmate ssl obtain --provider letsencrypt} -${RESTART_HINT}`, +Obtain a new certificate: +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt} +${restartHint(cfg)}`, SEVERITY.HIGH, )); }); @@ -61,8 +70,8 @@ ${RESTART_HINT}`, problems.push(new Problem( message, chalk`Nothing is broken yet. If it needs attention, obtain a new certificate: -{bold.cyanBright dashmate ssl obtain --provider letsencrypt} -${RESTART_HINT}`, +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt} +${restartHint(cfg)}`, SEVERITY.LOW, )); }); @@ -83,7 +92,7 @@ ${RESTART_HINT}`, 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}`, + chalk`Please check that the gateway is running and listening: {bold.cyanBright dashmate status ${cfg} platform}`, SEVERITY.MEDIUM, )); @@ -105,8 +114,8 @@ ${RESTART_HINT}`, `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}`, +regenerate the certificate if needed: {bold.cyanBright dashmate ssl obtain ${cfg} --force} +${restartHint(cfg)}`, SEVERITY.HIGH, )); @@ -122,7 +131,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 --platform}`, +{bold.cyanBright dashmate restart ${cfg} --platform}`, SEVERITY.HIGH, )); } else if (isServedExpired) { @@ -130,9 +139,9 @@ ${RESTART_HINT}`, `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}`, +{bold.cyanBright dashmate logs ${cfg} dashmate_helper} +Then obtain a new certificate: {bold.cyanBright dashmate ssl obtain ${cfg}} +${restartHint(cfg)}`, SEVERITY.HIGH, )); } else if (onDiskDiffers) { @@ -142,7 +151,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 --platform}`, +{bold.cyanBright dashmate restart ${cfg} --platform}`, SEVERITY.HIGH, )); } @@ -156,7 +165,7 @@ ${RESTART_HINT}`, 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}`, +${restartHint(cfg)}`, SEVERITY.HIGH, )); } diff --git a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js index 939a3b9e7e9..947d720c24b 100644 --- a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js @@ -78,6 +78,28 @@ function renderSuccess(config, verdict) { `; } +/** + * Carry a verdict's warnings into the run's report. + * + * The command prints only what is collected here, so a branch that re-checks + * and returns without this drops any warning the new state carries - a + * provider that still disagrees, or an accepted self-signed certificate on a + * fullnode - from everything except machine output. + * + * @param {Object} ctx + * @param {Object} verdict + */ +function collectWarnings(ctx, verdict) { + if (verdict.warnings.length === 0) { + return; + } + + ctx.certificateWarnings = [ + ...(ctx.certificateWarnings ?? []), + ...verdict.warnings.map(({ message }) => message), + ]; +} + /** * @param {Object} verdict * @param {string} code @@ -315,9 +337,13 @@ export default function gatewayCertificateTaskFactory( throw new CertificateUnresolvedError(verdict); } - // The pair is already installed and correct; only the setting was never - // written. There is nothing to obtain. - if (hasReason(verdict, CERTIFICATE_REASONS.SWITCH_INCOMPLETE)) { + // Only when the interrupted switch is the whole problem. The pair being + // byte-identical to the one lego produced says nothing about whether it + // is still valid, so this state can carry an expired or misaddressed + // certificate alongside it - and there the setting is not all that is + // missing. Those fall through to the obtain below. + if (verdict.reasons.length === 1 + && hasReason(verdict, CERTIFICATE_REASONS.SWITCH_INCOMPLETE)) { const complete = await promptOrThrow(task, { type: 'toggle', header: ` A Let's Encrypt certificate is installed for the gateway, but the @@ -341,7 +367,18 @@ export default function gatewayCertificateTaskFactory( await reloadGateway(config); - ctx.certificate = checkGatewayCertificate(config); + // Judged by what the node holds afterwards, like every other branch. + // Persisting can fail, the reload can fail, and the installed pair can + // expire between the check and the write - telling an operator their + // node is fixed when it is dark is worse than saying nothing. + const after = checkGatewayCertificate(config); + + ctx.certificate = after; + collectWarnings(ctx, after); + + if (after.status === CERTIFICATE_STATUS.INVALID) { + throw new CertificateUnresolvedError(after); + } return; } @@ -364,10 +401,26 @@ export default function gatewayCertificateTaskFactory( if (installFiles) { await installCertificateFilesTask(config, { interactive }).run({ ...ctx, interactive }); + // The gateway listener is branched on the provider: self-signed + // renders a tls_inspector and a raw_buffer filter chain, so the port + // goes on accepting plaintext connections. Leaving the setting behind + // would keep that chain on a node that now holds a real certificate. + // + // Written only now, after the files are installed, so configuration + // can never name a provider the node has no certificate for. Saved + // immediately rather than at command exit, because update carries on + // into a multi-minute pull and the end-of-run save is skipped + // whenever the run later throws. + config.set('platform.gateway.ssl.enabled', true); + config.set('platform.gateway.ssl.provider', SSL_PROVIDERS.FILE); + + persistProvider(config); + await reloadGateway(config); const after = checkGatewayCertificate(config); ctx.certificate = after; + collectWarnings(ctx, after); if (after.status !== CERTIFICATE_STATUS.INVALID) { return; diff --git a/packages/dashmate/src/oclif/command/BaseCommand.js b/packages/dashmate/src/oclif/command/BaseCommand.js index 717e84132a6..7efb9bca8bb 100644 --- a/packages/dashmate/src/oclif/command/BaseCommand.js +++ b/packages/dashmate/src/oclif/command/BaseCommand.js @@ -21,11 +21,20 @@ export default class BaseCommand extends Command { }), }; + /** + * Whether this run changes nothing on disk. A command that reconfigures a + * node can still have a mode that only reports, and such a mode has to keep + * that promise all the way down: it takes no lock, saves no configuration, + * and does not persist a migration it happened to need. + * + * Set from the command's flags in init(). A command that declares one of + * these modes gives up its end-of-run save in that mode, which is the point. + */ + isReadOnlyRun = false; + /** * Whether this run holds the configuration lock. Defaults to what the command - * declares and is narrowed once its flags are known, because a command can - * declare that it reconfigures a node and still have a mode that changes - * nothing. + * declares and is narrowed once its flags are known. */ holdsConfigLock = this.constructor.mutatesConfig === true; @@ -62,9 +71,11 @@ export default class BaseCommand extends Command { // // Such a command may still have a mode that changes nothing - a read-only // preflight, say - and taking a write lock there would let it fail on a - // lock timeout for no reason, so it can opt that mode out. - this.holdsConfigLock = this.holdsConfigLock - && this.constructor.shouldSkipConfigLock?.(this.parsedFlags) !== true; + // lock timeout for no reason, so it can opt that mode out. The migration + // below is opted out with it: migrating writes and renders under the same + // lock, and it is due on exactly the run right after an upgrade. + this.isReadOnlyRun = this.constructor.isReadOnlyRun?.(this.parsedFlags) === true; + this.holdsConfigLock = this.holdsConfigLock && !this.isReadOnlyRun; if (this.holdsConfigLock) { configFileRepository.acquire(); @@ -82,6 +93,7 @@ export default class BaseCommand extends Command { ({ configFile } = configFileRepository.readAndMigrate( { skipValidation, + readOnly: this.isReadOnlyRun, }, (migratedConfigs) => { const writeConfigTemplates = this.container.resolve('writeConfigTemplates'); diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index cce09eb9578..2cae54f8a02 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -188,8 +188,12 @@ export default function renderCertificateGuidance({ }) { const cfg = renderConfigFlag(config.getName()); const provider = config.get('platform.gateway.ssl.provider'); - const isSwitchIncomplete = verdict.reasons - .some(({ code }) => code === CERTIFICATE_REASONS.SWITCH_INCOMPLETE); + // Only when the interrupted switch is the whole problem. The installed pair + // being the one lego produced says nothing about whether it is still valid, + // so this state can carry an expired or misaddressed certificate alongside + // it - and there, saving the setting is not the repair. + const isSwitchIncomplete = verdict.reasons.length === 1 + && verdict.reasons[0].code === CERTIFICATE_REASONS.SWITCH_INCOMPLETE; const blocks = [ `${renderOpening(pull)} diff --git a/packages/dashmate/test/unit/commands/update.spec.js b/packages/dashmate/test/unit/commands/update.spec.js index 60b8833433b..626ca34b0fe 100644 --- a/packages/dashmate/test/unit/commands/update.spec.js +++ b/packages/dashmate/test/unit/commands/update.spec.js @@ -135,19 +135,60 @@ describe('Update command', () => { // open for minutes. it('should observe a pull that rejects immediately', async function it() { const rejection = new Error('service list is broken'); - let handledDuringTask = true; + let taskRan = false; await expect(runUpdate({ updateNode: () => Promise.reject(rejection), gatewayCertificateTask: () => async () => { - // Anything unhandled would already have been reported by now. + // An unobserved rejection would already have taken the process down. await new Promise((resolve) => { setImmediate(resolve); }); - handledDuringTask = true; + taskRan = true; }, - })).to.not.be.rejected(); + })).to.be.rejectedWith(rejection); + + expect(taskRan).to.be.true(); + }); + + // A pull that rejects fetched nothing at all. Reporting it and carrying on + // hands a playbook running `update && start` a node whose images were never + // fetched, with no exit code to catch - and set -e cannot see it. + it('should fail the command when the pull rejects', async () => { + const rejection = new Error('service list is broken'); + + await expect(runUpdate({ updateNode: () => Promise.reject(rejection) })) + .to.be.rejectedWith(rejection); + }); + + // The certificate guidance is still worth printing, but it is not what the + // command failed on, and it must not replace the error that is. + it('should still print the certificate guidance before failing on the pull', async () => { + const rejection = new Error('service list is broken'); + const verdict = invalidVerdict(); + + await expect(runUpdate({ + updateNode: () => Promise.reject(rejection), + checkGatewayCertificate: () => verdict, + gatewayCertificateTask: () => async (ctx) => { + ctx.certificate = verdict; + throw new CertificateUnresolvedError(verdict); + }, + })).to.be.rejectedWith(rejection); + + expect(stderr).to.contain('did not pass'); + }); + + // Individual images failing is not a rejection: updateNode resolves those + // as error rows, and that has always exited 0. + it('should not fail the command when individual pulls fail', async function it() { + mockDocker = { + pull: this.sinon.stub().callsFake((image, cb) => cb(new Error('registry down'), null)), + }; + this.sinon.stub(console, 'log'); + + await expect(runUpdate({ updateNode: updateNodeFactory(mockGetServicesList, mockDocker) })) + .to.not.be.rejected(); - expect(handledDuringTask).to.be.true(); - expect(stderr).to.contain('service list is broken'); + expect(process.exitCode).to.equal(0); }); // exitOnError is false so the throw does not stop the list, and the @@ -298,10 +339,10 @@ describe('Update command', () => { expect(source).to.not.match(/process\.exit\(/); }); - it('should not take the configuration lock', () => { + it('should declare itself read-only so it neither locks nor writes', () => { expect(UpdateCommand.mutatesConfig).to.be.true(); - expect(UpdateCommand.shouldSkipConfigLock({ 'check-certificate': true })).to.be.true(); - expect(UpdateCommand.shouldSkipConfigLock({ 'check-certificate': false })).to.be.false(); + expect(UpdateCommand.isReadOnlyRun({ 'check-certificate': true })).to.be.true(); + expect(UpdateCommand.isReadOnlyRun({ 'check-certificate': false })).to.be.false(); }); }); diff --git a/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js b/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js index 1e3f84fdf50..c3dde7bc591 100644 --- a/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js +++ b/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js @@ -241,6 +241,30 @@ describe('ConfigFileJsonRepository', () => { .to.equal('9.9.9'); }); + // A command that promises to change nothing must keep that promise even on + // the one run where a migration is due - which, right after an upgrade, is + // the run it is most likely to be used on. It still gets the migrated + // shape; it just does not write it, render from it, or take the lock to do + // either, so it cannot abort on a lock timeout while renewal holds it. + it('should migrate in memory only when the caller changes nothing', () => { + seedConfigFile(); + + const migration = (data) => ({ ...data, configFormatVersion: '9.9.9' }); + const repository = new ConfigFileJsonRepository(migration, homeDir, createDefaults); + const before = fs.readFileSync(configFilePath, 'utf8'); + + let rendered = false; + const { configFile } = repository.readAndMigrate( + { readOnly: true }, + () => { rendered = true; }, + ); + + expect(configFile.getConfigFormatVersion()).to.equal('9.9.9'); + expect(rendered).to.be.false(); + expect(fs.readFileSync(configFilePath, 'utf8')).to.equal(before); + expect(fs.existsSync(homeDir.joinPath('.config.json.lock'))).to.be.false(); + }); + // Migrations are not all pure - one moves TLS files and deletes the // originals - so deciding whether one is due must not run them. Running // them to find out would do that work outside the lock, where another diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index 08dbec3d67a..95fbdb4fca2 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 --platform'); + expect(problems[0].getSolution()).to.include('dashmate restart --config base --platform'); }); it('should warn before the outage when a renewed certificate has not been picked up', () => { @@ -211,6 +211,43 @@ describe('analyseGatewayCertificateFactory', () => { expect(problem.getSolution()).to.include('exits non-zero'); }); + // Doctor is run against a named node, and a solution pasted without one + // targets whichever config happens to be the default. For `ssl obtain` + // that re-issues a certificate for a different node's address and rewrites + // that node's provider - a mutation of the wrong machine. + it('should put the node it analysed on every command it suggests', () => { + const problems = analyseInstalled({ + status: 'INVALID', + reasons: [{ code: 'EXPIRED', message: 'expired' }], + warnings: [{ code: 'EXPIRING_SOON', message: 'expires tomorrow' }], + }); + + expect(problems).to.have.lengthOf(2); + + problems.forEach((problem) => { + const commands = problem.getSolution() + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('dashmate ')); + + expect(commands).to.have.length.greaterThan(0); + commands.forEach((command) => { + expect(command, command).to.contain(`--config ${config.getName()}`); + }); + }); + }); + + // Telling someone who is reading a doctor report to run doctor is circular. + it('should not suggest running doctor as the fix for a doctor problem', () => { + const [problem] = analyseInstalled({ + status: 'INVALID', + reasons: [{ code: 'EXPIRED', message: 'expired' }], + warnings: [], + }); + + expect(problem.getSolution()).to.not.match(/^\s*dashmate doctor\b/m); + }); + it('should report each warning separately and more quietly', () => { const problems = analyseInstalled({ status: 'WARN', diff --git a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js index ff8a7731549..1d7cf215751 100644 --- a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js @@ -304,6 +304,52 @@ describe('gatewayCertificateTaskFactory', () => { expect(configFileRepository.write).to.have.been.calledOnce(); }); + // The pair being the one lego produced says nothing about whether it is + // still valid, so this state can carry an expired certificate too. Saving + // the setting there fixes the helper's target and leaves the node dark - + // and telling the operator it is fixed is worse than saying nothing. + it('should not claim a dead certificate is fixed by saving the setting', async function it() { + const dead = invalid(CERTIFICATE_REASONS.SWITCH_INCOMPLETE, { + reasons: [ + { code: CERTIFICATE_REASONS.SWITCH_INCOMPLETE, message: 'switch interrupted' }, + { code: CERTIFICATE_REASONS.EXPIRED, message: 'expired 158 days ago' }, + ], + }); + + let checked = 0; + const { errors } = await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 ? dead : verdict(); + }, + answers: [true], + }); + + // A certificate is genuinely needed here, so the operator is asked for + // one rather than told the setting is all that was missing. + expect(obtainLetsEncryptCertificateTask).to.have.been.calledOnce(); + expect(errors).to.be.empty(); + expect(enquirer.options[0].message).to.not.contain('Finish the interrupted switch'); + }); + + // Persisting can fail, the reload can fail, and the installed pair can have + // expired between the check and the write. Success is what the re-check + // says, not what the branch assumed. + it('should fail when the certificate is still unusable after completing', async function it() { + let checked = 0; + const { errors } = await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 + ? invalid(CERTIFICATE_REASONS.SWITCH_INCOMPLETE) + : invalid(CERTIFICATE_REASONS.KEY_MISMATCH); + }, + answers: [true], + }); + + expect(errors[0]).to.be.an.instanceOf(CertificateUnresolvedError); + }); + it('should block when the operator declines to finish it', async function it() { const { errors } = await run.call(this, { checkGatewayCertificate: () => invalid(CERTIFICATE_REASONS.SWITCH_INCOMPLETE), @@ -334,6 +380,68 @@ describe('gatewayCertificateTaskFactory', () => { expect(enquirer.options[0].message).to.contain('Install new certificate files'); }); + // The gateway listener is branched on the provider: self-signed renders a + // tls_inspector plus a raw_buffer filter chain, so the port keeps accepting + // plaintext. Installing a real certificate without recording that the + // provider changed leaves that chain in place on a node whose operator has + // just done exactly the right thing. + it('should record the new provider after installing operator files', async function it() { + config.set('platform.gateway.ssl.provider', 'self-signed'); + + let checked = 0; + const { errors } = await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 ? invalid(CERTIFICATE_REASONS.SELF_SIGNED) : verdict(); + }, + answers: [true], + }); + + expect(errors).to.be.empty(); + expect(config.get('platform.gateway.ssl.provider')).to.equal('file'); + expect(config.get('platform.gateway.ssl.enabled')).to.be.true(); + }); + + // The gate is the only caller that persists mid-run, because update carries + // on into a multi-minute pull afterwards and the end-of-run save is skipped + // whenever the run later throws. + it('should persist and re-render immediately after installing files', async function it() { + config.set('platform.gateway.ssl.provider', 'self-signed'); + + let checked = 0; + await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 ? invalid(CERTIFICATE_REASONS.SELF_SIGNED) : verdict(); + }, + answers: [true], + }); + + expect(configFileRepository.write).to.have.been.calledOnceWith(configFile); + expect(writeConfigTemplates).to.have.been.calledOnceWith(config); + }); + + // The verdict after the install can still carry warnings - a provider that + // disagrees with the new certificate's issuer, say - and the command prints + // only what the task collects. + it('should carry warnings from the installed certificate', async function it() { + config.set('platform.gateway.ssl.provider', 'file'); + + let checked = 0; + const { context } = await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 ? invalid() : verdict({ + status: CERTIFICATE_STATUS.WARN, + warnings: [{ code: CERTIFICATE_REASONS.PROVIDER_MISMATCH, message: 'issuer disagrees' }], + }); + }, + answers: [true], + }); + + expect(context.certificateWarnings).to.deep.equal(['issuer disagrees']); + }); + // Changing the authority on a certificate someone bought is a decision only // they can make, so it is offered second and not preselected. it('should offer Let\'s Encrypt second and not preselect it', async function it() { diff --git a/packages/dashmate/test/unit/oclif/command/BaseCommand.spec.js b/packages/dashmate/test/unit/oclif/command/BaseCommand.spec.js index 460ad6c84fb..662fd08bc94 100644 --- a/packages/dashmate/test/unit/oclif/command/BaseCommand.spec.js +++ b/packages/dashmate/test/unit/oclif/command/BaseCommand.spec.js @@ -5,8 +5,14 @@ import BaseCommand from '../../../../src/oclif/command/BaseCommand.js'; import ResetCommand from '../../../../src/commands/reset.js'; import GroupResetCommand from '../../../../src/commands/group/reset.js'; +// Reconfigures a node, but has a mode that only reports - the shape the +// read-only opt-out exists for. class MutatingCommand extends BaseCommand { static mutatesConfig = true; + + static isReadOnlyRun(flags) { + return flags.report === true; + } } describe('BaseCommand', () => { @@ -97,6 +103,33 @@ describe('BaseCommand', () => { expect(configFileRepository.release).to.have.been.calledOnce(); }); + // A mode that promises to change nothing has to keep that promise all the + // way down. Migrating writes and renders under the same lock, and it is due + // on exactly the run right after an upgrade - the run this mode exists for. + it('should neither lock nor persist a migration on a read-only run', async function it() { + const { command, configFileRepository } = createCommandWithContainer( + this.sinon, + ); + command.parse.resolves({ args: {}, flags: { report: true } }); + + await command.init(); + + expect(configFileRepository.acquire).to.not.have.been.called(); + expect(configFileRepository.readAndMigrate.firstCall.args[0].readOnly).to.be.true(); + }); + + it('should lock and migrate normally in the same command\'s other modes', async function it() { + const { command, configFileRepository } = createCommandWithContainer( + this.sinon, + ); + command.parse.resolves({ args: {}, flags: { report: false } }); + + await command.init(); + + expect(configFileRepository.acquire).to.have.been.calledOnce(); + expect(configFileRepository.readAndMigrate.firstCall.args[0].readOnly).to.be.false(); + }); + it('should not let an unrelated force flag skip config validation', async function it() { const { command, configFileRepository } = createCommandWithContainer( this.sinon, @@ -107,7 +140,7 @@ describe('BaseCommand', () => { await command.init(); expect(configFileRepository.readAndMigrate.firstCall.args[0]) - .to.deep.equal({ skipValidation: false }); + .to.deep.equal({ skipValidation: false, readOnly: false }); }); it('should skip validation only for the config replaced by a forced total reset', async function it() { @@ -122,7 +155,7 @@ describe('BaseCommand', () => { await platformReset.command.init(); expect(platformReset.configFileRepository.readAndMigrate.firstCall.args[0]) - .to.deep.equal({ skipValidation: false }); + .to.deep.equal({ skipValidation: false, readOnly: false }); const totalReset = createCommandWithContainer(this.sinon, ResetCommand); totalReset.command.parse.resolves({ diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index 1370690dbff..e7cbed5be86 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -197,6 +197,24 @@ describe('renderCertificateGuidance', () => { expect(output).to.not.contain('THE FIX'); }); + // The installed pair being the one lego produced says nothing about whether + // it is still valid. When something else is wrong with it too, saving the + // setting is not the repair, and offering it as one sends the operator away + // believing a dark node is fixed. + it('should not offer the setting as the repair when the certificate is also broken', () => { + const output = render({ + verdict: verdict({ + reasons: [ + { code: CERTIFICATE_REASONS.SWITCH_INCOMPLETE, message: 'A switch was interrupted' }, + { code: CERTIFICATE_REASONS.EXPIRED, message: 'expired 158 days ago' }, + ], + }), + }); + + expect(output).to.not.contain('Nothing needs to be obtained'); + expect(output).to.contain('THE FIX'); + }); + it('should name the bypass and say it is not a playbook line', () => { const output = render(); From 77d81d6fd2d8d98381a9ae652bee3b4abb95ccb8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 12:08:36 +0700 Subject: [PATCH 13/63] fix(dashmate): honest operator messaging and a private doctor archive Six smaller findings from the same review, all agreed by both reviewers. THE ARCHIVE LEAKED THE OPERATOR'S USERNAME. The on-disk certificate sample stored its reasons verbatim, and a problem with the files names the file it could not read - an absolute path under the home directory. Every neighbouring certificate branch in the same collector already masks the username before storing; this one did not. Doctor reports are what operators paste into support channels. The path stays, because it is what makes the problem actionable; the name in it does not. THE PORT-80 CLUSTER CLAIM WAS NOT SUPPORTED. Two messages asserted that three named mainnet nodes "all three now block port 80". They are silent drops of an external probe, and a silent drop is no information at all - 52 nodes that dropped the same probe hold Let's Encrypt certificates issued within four days, which is only possible over port 80. What the evidence does support is the cluster itself: one operator, one day, three nodes, dark together six days later. That is the persuasive part anyway, and it is now all that is claimed. The file's own docblock promises every claim is limited to what was observed, and this was the one place it was not. REACHABILITY WAS ASSERTED FROM A DISK CHECK. The passing status was renamed to say the checks passed, precisely because nothing here opens a connection - and three strings put the wire claim back. The worst was in the courtesy switch offer, which is only ever made when the installed certificate passed and stays in place: it told that operator declining leaves clients unable to connect, contradicting what the same run had just established. The offer now says what is true on each path. The doctor and guidance strings state what a client does with a certificate in this state rather than what this node's clients experienced. The guard that should have caught all three matched one exact sentence the code never used, and now matches the family. A FAILED COURTESY SWITCH DEMANDED A PERFECT RE-CHECK. It treated anything short of a clean pass as damage, while the sibling branch asks only whether the result blocks. A certificate that crossed the expiring-soon boundary during a multi-minute failed obtain came back as a warning and failed the run - on a node where nothing had been touched. Also: the line-break test now re-wraps the output at the width oclif's printer uses and shows the longest command does not survive it, instead of hard-coding three truncation suffixes; and all three signal call sites record why a signal is enough. PID 1 in the gateway container is Envoy's hot-restarter, which re-execs Envoy against the same yaml with a new restart epoch, so both a renewed certificate and a changed listener structure take effect. Without that written down the obvious "fix" is a container restart, which would buy an outage and nothing else. Tests: 7 new or rewritten, red before this commit - - the archive carried the username (1 red) - the guidance asserted the cluster blocks port 80 and stated wire outcomes it never measured (2 red) - the switch offer told a passing node its clients could not connect, and said nothing useful on the failing path (2 red) - a warning-level re-check failed the courtesy path (1 red) The wrap test was rewritten rather than added: it previously could not exercise the wrapping it is named for. All green after. 521 unit tests passing, 0 lint errors. Co-Authored-By: Claude Opus 5 --- packages/dashmate/src/commands/ssl/obtain.js | 9 ++ .../dashmate/src/helper/scheduleRenewalJob.js | 8 ++ .../tasks/doctor/collectSamplesTaskFactory.js | 16 ++- .../update/gatewayCertificateTaskFactory.js | 35 ++++-- .../src/ssl/renderCertificateGuidance.js | 22 ++-- .../doctor/collectSamplesTaskFactory.spec.js | 55 +++++++++- .../gatewayCertificateTaskFactory.spec.js | 53 +++++++++ .../ssl/renderCertificateGuidance.spec.js | 103 +++++++++++++++--- 8 files changed, 264 insertions(+), 37 deletions(-) diff --git a/packages/dashmate/src/commands/ssl/obtain.js b/packages/dashmate/src/commands/ssl/obtain.js index 270d31c656d..db2fa7f0c9f 100644 --- a/packages/dashmate/src/commands/ssl/obtain.js +++ b/packages/dashmate/src/commands/ssl/obtain.js @@ -112,6 +112,15 @@ Certificate will be renewed if it is about to expire (see 'expiration-days' flag // been obtained by then, so failing there would report the whole // command as failed and send the operator back to a provider that may // have nothing left to issue. + // + // A signal is sufficient and nothing here needs to restart the + // container. PID 1 in the gateway container is Envoy's hot-restarter, + // not Envoy: its SIGHUP handler forks and re-execs Envoy with an + // incremented restart epoch against the same envoy.yaml. The new + // process parses that file from scratch and opens the certificate by + // name, so both a renewed certificate and a changed listener + // structure take effect while the old process drains. A container + // restart would achieve the same thing and cost an outage. title: 'Reload gateway', task: async (ctx, listrTask) => { try { diff --git a/packages/dashmate/src/helper/scheduleRenewalJob.js b/packages/dashmate/src/helper/scheduleRenewalJob.js index faffaf53924..1c2dfc2bf69 100644 --- a/packages/dashmate/src/helper/scheduleRenewalJob.js +++ b/packages/dashmate/src/helper/scheduleRenewalJob.js @@ -58,6 +58,14 @@ export default function scheduleRenewalJob({ completion = 'stop'; } else { + // A signal is sufficient and nothing here needs to restart the + // container. PID 1 in the gateway container is Envoy's hot-restarter, + // not Envoy: its SIGHUP handler forks and re-execs Envoy with an + // incremented restart epoch against the same envoy.yaml. The new + // process parses that file from scratch and opens the certificate by + // name, so the renewed certificate takes effect while the old process + // drains. A container restart would achieve the same thing and cost an + // outage. await dockerCompose.execCommand(renewal.config, 'gateway', 'kill -SIGHUP 1'); // eslint-disable-next-line no-console diff --git a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js index d4ba303284b..ab95ad57b89 100644 --- a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js @@ -205,7 +205,7 @@ export default function collectSamplesTaskFactory( task: async () => { const verdict = checkGatewayCertificate(config); - ctx.samples.setServiceInfo('gateway', 'installedCertificate', { + const installed = { status: verdict.status, reasons: verdict.reasons, warnings: verdict.warnings, @@ -215,7 +215,19 @@ export default function collectSamplesTaskFactory( validTo: verdict.installed ? verdict.installed.validTo.toUTCString() : null, - }); + }; + + // A problem with the files names the file it could not read, + // which is an absolute path under the operator's home + // directory. The report this ends up in is what an operator + // hands to whoever is helping them, so the path stays - it is + // what makes the problem actionable - and the name in it does + // not. + obfuscateObjectRecursive(installed, (_field, value) => (typeof value === 'string' + ? value.replaceAll(process.env.USER, hideString(process.env.USER)) + : value)); + + ctx.samples.setServiceInfo('gateway', 'installedCertificate', installed); }, }, { diff --git a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js index 947d720c24b..a727bf36b5c 100644 --- a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js @@ -22,9 +22,12 @@ const ZEROSSL_URGENT_DAYS = 14; * * @param {Config} config * @param {string} externalIp + * @param {Object} [options] + * @param {boolean} [options.certificatePassedChecks] - whether the certificate + * this node is running on right now cleared the checks * @return {string} */ -function renderSwitchOffer(config, externalIp) { +function renderSwitchOffer(config, externalIp, { certificatePassedChecks = false } = {}) { return ` Switching this node to Let's Encrypt will: - obtain a new certificate now, free, for ${externalIp} - change platform.gateway.ssl.provider from ${config.get('platform.gateway.ssl.provider')} to letsencrypt @@ -41,9 +44,12 @@ function renderSwitchOffer(config, externalIp) { it permanently, and re-run dashmate update ${renderConfigFlag(config.getName())}. Your image pull is running now and will finish either way, so answering No - does not hold this node back from protocol upgrades or security patches - it - only leaves clients unable to connect until the certificate is fixed. -`; + does not hold this node back from protocol upgrades or security patches. +${certificatePassedChecks + ? ' The certificate this node is running on now passed its checks and stays\n' + + ' in place, so nothing changes if you decline.\n' + : ' Declining leaves this node without a certificate a standards-compliant\n' + + ' client will accept.\n'}`; } /** @@ -163,6 +169,14 @@ export default function gatewayCertificateTaskFactory( * next start, but update against a running node is supported too and there * the reload is what makes the change reach the wire. * + * A signal is sufficient and nothing here needs to restart the container. + * PID 1 in the gateway container is Envoy's hot-restarter, not Envoy: its + * SIGHUP handler forks and re-execs Envoy with an incremented restart epoch + * against the same envoy.yaml. The new process parses that file from scratch + * and opens the certificate by name, so both a renewed certificate and a + * changed listener structure take effect while the old process drains. A + * container restart would achieve the same thing and cost an outage. + * * @param {Config} config * @return {Promise} */ @@ -275,7 +289,9 @@ export default function gatewayCertificateTaskFactory( const accepted = await promptOrThrow(task, { type: 'toggle', - header: renderSwitchOffer(config, config.get('externalIp')), + header: renderSwitchOffer(config, config.get('externalIp'), { + certificatePassedChecks: true, + }), message: "Switch to Let's Encrypt and obtain a certificate now?", enabled: 'Yes', disabled: 'Not now', @@ -292,8 +308,11 @@ export default function gatewayCertificateTaskFactory( // Nothing was blocking before this ran, so a failure that left the node // as it was is a warning. A failure that damaged the installed pair is - // not, and this is the only thing that can tell them apart. - if (after.status === CERTIFICATE_STATUS.CHECKS_PASSED) { + // not, and this is the only thing that can tell them apart. Judged the + // same way as every other branch - anything short of blocking is a + // node that still works, and a certificate can cross the + // expiring-soon boundary during a multi-minute failed obtain. + if (after.status !== CERTIFICATE_STATUS.INVALID) { ctx.certificate = after; if (ctx.certificateObtainError) { @@ -305,6 +324,8 @@ export default function gatewayCertificateTaskFactory( `The switch to Let's Encrypt did not complete: ${ctx.certificateObtainError.message}` + '\nThe certificate this node was already using is untouched.', ); + + collectWarnings(ctx, after); } else { // The node is no longer on ZeroSSL, so its expiry is no longer // this node's problem and repeating it would contradict the diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index 2cae54f8a02..fb92de91604 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -80,8 +80,8 @@ function renderPortEightyPermanence() { If you open port 80 only to get this certificate and close it afterwards, or if the rule does not survive a reboot, this node goes dark within six days and nothing will tell you. That is the most common way an evonode dies: three - mainnet nodes were issued certificates on the same day, all went dark six - days later, and all three now block port 80. + mainnet nodes issued certificates on the same day all went dark together six + days later - one operator, one change, a whole fleet at once. Make the rule permanent and make sure it persists across reboots. `; @@ -130,8 +130,8 @@ function renderLetsEncryptDiagnosis(cfg) { The most likely cause is inbound port 80. Let's Encrypt re-checks it on every renewal - roughly every four days, permanently - and a firewall rule that was opened once and later closed, or that did not survive a reboot, produces - exactly this pattern. Three mainnet nodes issued on the same day went dark - together six days later, and all three now block port 80. + exactly this pattern. Three mainnet nodes issued certificates on the same day + went dark together six days later - one operator, one change, three nodes. It is not always port 80: half the nodes in this state have port 80 open and stopped renewing regardless. Check the renewal logs as well: @@ -201,9 +201,9 @@ export default function renderCertificateGuidance({ Node: ${config.get('network')} (config "${config.getName()}", ${config.get('externalIp') ?? 'no external IP set'}) Certificate: ${renderObservation(verdict)} - If this is the certificate the gateway is serving, standards-compliant - clients reject it. dashmate did not open a connection to check what is - actually on the wire; \`dashmate doctor ${cfg}\` does that. + A standards-compliant client rejects a certificate in this state. dashmate + did not open a connection to check what is actually on the wire, so it cannot + say what this node is serving; \`dashmate doctor ${cfg}\` does that. Nothing broke just now. This is the first release of dashmate that checks the certificate, so this is the first time you are being told. @@ -235,16 +235,16 @@ export default function renderCertificateGuidance({ blocks.push(renderPortEightyPermanence()); blocks.push(` IF YOU CANNOT OPEN PORT 80. dashmate currently has no supported alternative - for an IP-address certificate. If your host will not open it, this node - cannot serve DAPI clients. Updates themselves are unaffected: images are + for an IP-address certificate. Without one this node has no certificate a + DAPI client will accept. Updates themselves are unaffected: images are always pulled, whatever this check finds, so this node is not being held back from protocol upgrades or security patches. To suppress this check for one run: dashmate update ${cfg} --skip-certificate-check - This leaves clients unable to connect to your node. It is an escape for a - single run, not a line to add to a playbook. + This silences the check; it does not repair the certificate. It is an escape + for a single run, not a line to add to a playbook. `); } 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 88d6c795660..d527a92a04d 100644 --- a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js @@ -48,6 +48,8 @@ describe('collectSamplesTaskFactory', () => { let collectSamplesTask; let analyseConfig; let samples; + let dockerCompose; + let rpcClient; /** * Run the sample collection the same way the doctor command does: as a subtask @@ -93,13 +95,13 @@ describe('collectSamplesTaskFactory', () => { text: async () => 'metrics_sample 1', }); - const dockerCompose = { + dockerCompose = { throwErrorIfNotInstalled: this.sinon.stub().resolves(), inspectService: this.sinon.stub().resolves({}), logs: this.sinon.stub().resolves({ out: '', err: '' }), }; - const rpcClient = { + rpcClient = { getBestChainLock: this.sinon.stub().resolves({ result: {} }), quorum: this.sinon.stub().resolves({ result: {} }), getBlockchainInfo: this.sinon.stub().resolves({ result: {} }), @@ -171,6 +173,55 @@ describe('collectSamplesTaskFactory', () => { expect(analyseConfig(samples)).to.be.empty(); }); + // Doctor archives are the artefact operators hand to support, and a + // certificate problem names the file it could not read - an absolute path + // under the operator's home directory. Every neighbouring certificate branch + // masks the username before storing; this one has to as well. + it('should not put the operator\'s username in a shared archive', async function it() { + const leakyPath = `/Users/${process.env.USER}/.dashmate/base/platform/gateway/ssl/bundle.crt`; + + const task = 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({}), + () => ({ + status: 'INVALID', + reasons: [{ + code: 'BUNDLE_MISSING', + message: `dashmate could not find the certificate bundle at ${leakyPath}`, + }], + warnings: [], + skipped: [], + provider: 'zerossl', + installed: null, + expiresInDays: null, + }), + ); + + getCertificate.resolves(new Certificate({ + id: 'certificate-id', + common_name: EXTERNAL_IP, + status: 'issued', + created: toZeroSslDate(daysFromNow(-1)), + expires: toZeroSslDate(daysFromNow(89)), + })); + + await new Listr([{ task: () => task(config) }], { renderer: 'silent' }).run({ samples }); + + const serialised = JSON.stringify(samples.getServiceInfo('gateway', 'installedCertificate')); + + // The path is still there - it is what makes the problem actionable - but + // the name in it is not. + expect(serialised).to.contain('bundle.crt'); + expect(serialised).to.not.contain(process.env.USER); + }); + it('should collect the certificate the gateway actually serves', async () => { const { cert, key } = createCertificateForTest({ ip: EXTERNAL_IP, days: 30 }); diff --git a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js index 1d7cf215751..4fced64d0ad 100644 --- a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js @@ -154,6 +154,32 @@ describe('gatewayCertificateTaskFactory', () => { expect(enquirer.options[0].initial).to.be.true(); }); + // The courtesy offer is only made when the installed certificate passed the + // checks and stays in place. Telling that operator declining leaves clients + // unable to connect contradicts what the same run just established. + it('should not tell an operator whose certificate passed that clients cannot connect', async function it() { + await run.call(this, { + checkGatewayCertificate: () => verdict(), + answers: [false], + }); + + const { header } = enquirer.options[0]; + + expect(header).to.not.match(/clients (are|were|could not|cannot|unable)/i); + expect(header).to.contain('nothing changes'); + }); + + // The same wording is correct on the failing path, where the certificate + // really did not pass, so the offer has to say different things. + it('should say what declining costs when the certificate did not pass', async function it() { + await run.call(this, { + checkGatewayCertificate: () => invalid(), + answers: [false], + }); + + expect(enquirer.options[0].header).to.contain('leaves this node without'); + }); + it('should exit cleanly when the courtesy switch is declined', async function it() { const { errors } = await run.call(this, { checkGatewayCertificate: () => verdict(), @@ -204,6 +230,33 @@ describe('gatewayCertificateTaskFactory', () => { expect(context.certificateSuccess).to.contain('LEAVE PORT 80 OPEN'); }); + // Nothing was touched, so nothing regressed - and a certificate that + // crossed the expiring-soon boundary during a multi-minute failed obtain + // re-checks as a warning, not a pass. Demanding an exact pass there fails + // the run on a node that is exactly as it was. + it('should stay benign when a failed switch leaves only a warning behind', async function it() { + obtainLetsEncryptCertificateTask.callsFake(() => ({ + run: async () => { + throw new Error('port 80 is closed'); + }, + })); + + let checked = 0; + const { errors, context } = await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 ? verdict() : verdict({ + status: CERTIFICATE_STATUS.WARN, + warnings: [{ code: CERTIFICATE_REASONS.EXPIRING_SOON, message: 'expires tomorrow' }], + }); + }, + answers: [true], + }); + + expect(errors).to.be.empty(); + expect(context.certificateWarnings.join('\n')).to.contain('did not complete'); + }); + // saveCertificateTask writes the bundle and the key as two separate // in-place writes, so a failure between them can replace a working pair // with a mismatched one. Promising exit 0 here would tell an operator their diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index e7cbed5be86..e8b03b971ae 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -80,26 +80,99 @@ describe('renderCertificateGuidance', () => { // error printer, which hard-wraps at 74 columns on a non-TTY stream and would // break the longest remediation line mid-token into something unpastable. it('should never break a command across lines', () => { - const output = render(); + // oclif's error printer hard-wraps at the terminal width less six, which is + // 74 on a non-TTY stderr, and it breaks mid-token. Re-wrapping the output + // that way is what shows the hazard is real: a command an operator is meant + // to paste does not survive it. That is why the guidance is written straight + // to stderr and never handed to that printer. + const WRAP_AT = 74; + + /** + * @param {string} text + * @return {string[]} + */ + const commandsIn = (text) => text.split('\n') + .filter((line) => /^ {6,}dashmate /.test(line)) + .map((line) => line.trim()); + + /** + * @param {string} text + * @return {string} + */ + const hardWrap = (text) => text.split('\n') + .flatMap((line) => line.match(new RegExp(`.{1,${WRAP_AT}}`, 'g')) ?? ['']) + .join('\n'); + + // Every rendered command survives as written, on every variant. + [ + render(), + render({ verdict: verdict({ provider: 'letsencrypt' }) }), + render({ + verdict: verdict({ + reasons: [{ + code: CERTIFICATE_REASONS.SWITCH_INCOMPLETE, + message: 'A switch was interrupted', + }], + }), + }), + ].forEach((output) => { + const commands = commandsIn(output); - expect(output).to.contain( - 'dashmate ssl obtain --config base --provider letsencrypt', - ); - output.split('\n').forEach((line) => { - expect(line, line).to.not.match(/--conf$|--provide$|dashm$/); + expect(commands).to.have.length.greaterThan(0); + commands.forEach((command) => expect(output, command).to.contain(command)); + }); + + // And at least one of them is long enough that the printer would have + // broken it, so this is pinning a hazard that exists rather than one that + // cannot arise. + const switchIncomplete = render({ + verdict: verdict({ + reasons: [{ + code: CERTIFICATE_REASONS.SWITCH_INCOMPLETE, + message: 'A switch was interrupted', + }], + }), }); + const longest = commandsIn(switchIncomplete) + .reduce((a, b) => (b.length > a.length ? b : a)); + + expect(longest.length).to.be.greaterThan(WRAP_AT - 6); + expect(hardWrap(switchIncomplete)).to.not.contain(longest); }); - // The check reads files on disk. It cannot know what is on the wire, whether - // any client failed to connect, or what the helper has been doing. - it('should claim nothing it did not observe', () => { - const output = render(); + // The check reads files. It never opens a connection, so it cannot report + // what clients experienced - only what a client verifying this certificate + // would do with it. Matching on a family of phrasings rather than one exact + // string, because the previous guard named a sentence the code never used. + it('should not state a wire outcome it never measured', () => { + const outputs = [ + render(), + render({ isNodeRunning: true }), + render({ verdict: verdict({ provider: 'letsencrypt' }) }), + render({ pull: null }), + ]; + + outputs.forEach((output) => { + expect(output).to.not.match(/clients (are|were|could not|cannot|unable)/i); + expect(output).to.not.match(/(is|was|has been) unreachable/i); + expect(output).to.not.match(/your node is (down|dark|offline)/i); + }); + }); + + // A silent drop of an external probe is no information at all: 52 nodes that + // dropped the same probe hold Let's Encrypt certificates issued within four + // days, which is only possible over port 80. Asserting the port is blocked + // from that is the kind of overclaim this whole design is meant to avoid. + it('should not assert that the cluster blocks port 80', () => { + const outputs = [render(), render({ verdict: verdict({ provider: 'letsencrypt' }) })]; + + outputs.forEach((output) => { + expect(output).to.not.match(/all three now (block|filter)/i); + expect(output).to.not.match(/now block port 80/i); + }); - expect(output).to.contain('If this is the certificate the gateway is serving'); - expect(output).to.contain('dashmate did not open a connection'); - expect(output).to.not.contain('still being paid'); - expect(output).to.not.contain('clients could not connect'); - expect(output).to.not.contain('there is currently no other way'); + // What the evidence does support: one operator, one day, three nodes. + expect(render()).to.contain('issued certificates on the same day'); }); it('should reassure that the update itself broke nothing', () => { From ce0095ceb3f0fc90b1da7c5a0b736728b221d0ad Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 12:11:33 +0700 Subject: [PATCH 14/63] test(dashmate): pin the interactivity guards distinctly from the prompt refusal Both retry loops decide whether to prompt, and promptOrThrow refuses to build a prompt with nobody to answer it. Two guards for one hazard is deliberate, but it meant the tests could not tell them apart: reverting either loop's condition to the old fail-open form left both suites green, because the refusal caught what the loop no longer did. That redundancy is safe but not free. Reaching the prompt and being refused there replaces the failure the operator needs to read - lego's own account of why port 80 did not answer, or the authority's validation error - with a report that dashmate tried to ask a question. The decision not to retry has to be made before the prompt is reached. Both tests now assert on which error came back. Verified by reverting each loop's condition in turn: each mutation is now caught by its own suite, and neither was before. Also re-ran the reordered-build proof for the atomicity test, which the review recorded as unconfirmed: persisting the provider before the obtain makes `should not persist anything when the obtain fails` red, and restoring the order makes it green. Co-Authored-By: Claude Opus 5 --- .../obtainLetsEncryptCertificateTaskFactory.spec.js | 9 ++++++++- .../zerossl/obtainZeroSSLCertificateTaskFactory.spec.js | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js index c1e64d44389..d5c687aeff9 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js @@ -521,10 +521,17 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { const tasks = inject(buildFailingTask(this.sinon, docker)(config), enquirer); - await expect(tasks.run({ force: true })).to.be.rejected(); + const error = await tasks.run({ force: true }).catch((e) => e); expect(enquirer.prompt).to.not.have.been.called(); expect(docker.createContainer).to.have.been.calledOnce(); + + // lego's own account of the failure is what the operator needs. Reaching + // the prompt and being refused there would be safe but would replace it + // with a report that dashmate tried to ask a question, so the decision + // not to retry has to be made before the prompt is reached. + expect(error.message).to.contain('Timeout during connect'); + expect(error.message).to.not.contain('without a terminal'); }); it('should honour no-retry even for an operator at a terminal', async function it() { diff --git a/packages/dashmate/test/unit/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.spec.js index 20b5266367a..18611f721a3 100644 --- a/packages/dashmate/test/unit/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.spec.js @@ -380,9 +380,16 @@ describe('obtainZeroSSLCertificateTaskFactory', () => { // prompt reached in that container never settles and never releases the // config lock - which then blocks every other dashmate command forever. it('should not construct a prompt when the session cannot answer', async function it() { - await expect(run.call(this, {})).to.be.rejected(); + const error = await run.call(this, {}).catch((e) => e); expect(enquirer.prompt).to.not.have.been.called(); + + // The verification failure is what the operator needs to see. Reaching + // the prompt and being refused there would be safe but would replace the + // real error with a report that dashmate tried to ask a question, so the + // decision not to retry has to be made before the prompt is reached. + expect(error.message).to.contain('domain control validation failed'); + expect(error.message).to.not.contain('without a terminal'); }); it('should still ask an operator who is at a terminal', async function it() { From b4861d30768c725e88e9f48dddfca7bf2a18629e Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 12:34:25 +0700 Subject: [PATCH 15/63] fix(dashmate): make the preflight decline rather than migrate unlocked Two rejections from the verification pass. THE PREFLIGHT STILL RAN MIGRATIONS. Suppressing the lock and the save was not enough: read() invokes the migration chain unconditionally, so the read-only path still executed it and only declined to persist the result. Migrations are not pure. The 0.25.7 one copies private.key, bundle.crt, bundle.csr and csr.pem to a new location, removes each original, and then deletes the whole legacy ssl directory outright; a later one does the same for the gateway path. So a command documented as safe to run against a node that is still up could move and delete TLS material, without holding the configuration lock, and without recording the migrated version - so it would do it again on the next run. Migrating in memory is not a fix either, because running the migration is what touches the disk. The read-only path now refuses outright when the recorded format is behind this build, using the version comparison that was already there for deciding whether to take the lock - it reads the recorded version and runs nothing. The operator is told the configuration must be migrated, why a command that changes nothing will not do it, and to run any other dashmate command first, which migrates under the lock. Declining aborts a stop-first upgrade before the node goes down, which is the direction that costs nothing. Verified against a real dashmate home stamped back to 0.25.0 with a legacy ssl directory in place: exit 1, the directory and its contents still there, the recorded version unchanged, no lock file created. The message is wrapped short because it reaches the operator through oclif's printer, which breaks mid-token at the terminal width less six. DECLINING STILL CLAIMED A CLIENT OUTCOME. The switch offer's failing branch said declining leaves the node without a certificate a standards-compliant client will accept. The checks read files: they never open a connection and never validate the chain to a public root, and some blocking findings - an unfinished provider switch - sit on a certificate a client would accept perfectly well. It now says the installed certificate is left unchanged and still failing the checks above. Two more strings carried the same equivalence and are reworded the same way: the guidance opening and doctor's on-disk consequence. One claim is deliberately kept: a self-signed certificate is described as not publicly trusted and rejected by standards-compliant clients. Self signature is proven structurally - the leaf verifies under its own public key - and a certificate signed by nothing else is in no public trust store by definition. That is a property of the file the check established, and it now has its own test so the boundary is stated rather than accidental. Tests: 6 new, all red before this commit - - the read-only path ran the migration and returned a migrated config, and against the shipped migration set it deleted the legacy ssl directory (2 red, the second driven by the real chain over a genuine 0.25.0 config with a non-migrating control proving the deletion is real) - the guidance, the switch offer and doctor each stated what a client would do (3 red) - the self-signed exception was untested (1 new, green on arrival, and it documents why the sweep above does not apply to it) All green after. 525 unit tests passing, 0 lint errors, 48 warnings unchanged from baseline. Co-Authored-By: Claude Opus 5 --- .../configFile/ConfigFileJsonRepository.js | 20 +++- .../ConfigFileMigrationRequiredError.js | 44 +++++++++ .../analyseGatewayCertificateFactory.js | 7 +- .../update/gatewayCertificateTaskFactory.js | 4 +- .../src/ssl/renderCertificateGuidance.js | 11 ++- .../ConfigFileJsonRepository.spec.js | 93 ++++++++++++++++--- .../analyseGatewayCertificateFactory.spec.js | 16 ++++ .../gatewayCertificateTaskFactory.spec.js | 10 +- .../ssl/renderCertificateGuidance.spec.js | 26 ++++++ 9 files changed, 203 insertions(+), 28 deletions(-) create mode 100644 packages/dashmate/src/config/errors/ConfigFileMigrationRequiredError.js diff --git a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js index a13d909a0fd..fe63d93459c 100644 --- a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js +++ b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js @@ -7,6 +7,7 @@ import semver from 'semver'; import writeFileAtomic from 'write-file-atomic'; import Config from '../Config.js'; import { PACKAGE_ROOT_DIR } from '../../constants.js'; +import ConfigFileMigrationRequiredError from '../errors/ConfigFileMigrationRequiredError.js'; import ConfigFileNotFoundError from '../errors/ConfigFileNotFoundError.js'; import InvalidConfigFileFormatError from '../errors/InvalidConfigFileFormatError.js'; import configFileJsonSchema from './configFileJsonSchema.js'; @@ -231,10 +232,11 @@ export default class ConfigFileJsonRepository { * write. * * @param {Object} [options={}] - passed through to read() - * @param {boolean} [options.readOnly=false] - migrate in memory and stop - * there: no lock, no render, no save. For a caller that has promised to - * change nothing, which has to hold even on the one run where a migration - * is due + * @param {boolean} [options.readOnly=false] - for a caller that has promised + * to change nothing. Reads a config file that is already current, and + * refuses outright when one is not: migrations move and delete files on + * disk, so running them on such a caller's behalf would break the promise + * and do it without the lock * @param {function(Config[]): void} [onMigrated] - runs before the migrated * config file is saved and while the lock is held * @returns {{configFile: ConfigFile}} @@ -251,6 +253,16 @@ export default class ConfigFileJsonRepository { // Migrations are not all pure - some move service files on disk and delete // the originals - so running them to find out would do that work outside // the lock, and again inside it. + // Reading is what runs the migrations, and some of them copy TLS material + // to a new location, remove the originals, and then delete the legacy ssl + // directory outright. A caller that promised to change nothing cannot read + // a config file that is not current: declining is the only honest answer, + // and it stops a stop-first upgrade before the node goes down rather than + // after. + if (options.readOnly === true && this.#isMigrationDue()) { + throw new ConfigFileMigrationRequiredError(this.configFilePath); + } + if (options.readOnly === true || !this.#isMigrationDue()) { return { configFile: this.read(options) }; } diff --git a/packages/dashmate/src/config/errors/ConfigFileMigrationRequiredError.js b/packages/dashmate/src/config/errors/ConfigFileMigrationRequiredError.js new file mode 100644 index 00000000000..003ef444ff6 --- /dev/null +++ b/packages/dashmate/src/config/errors/ConfigFileMigrationRequiredError.js @@ -0,0 +1,44 @@ +import AbstractError from '../../errors/AbstractError.js'; + +/** + * The configuration file needs migrating, and this caller promised not to + * change anything. + * + * Migrations are not all pure: one copies TLS material to a new location, + * removes the originals and then deletes the whole legacy ssl directory. Doing + * that on behalf of a command documented as safe to run against a node that is + * still up - without holding the configuration lock, and without recording the + * result, so it would happen again on the next run - is worse than declining. + */ +export default class ConfigFileMigrationRequiredError extends AbstractError { + /** + * @param {string} configFilePath + */ + constructor(configFilePath) { + // Wrapped short: this reaches the operator through oclif's error printer, + // which hard-wraps at the terminal width less six and breaks mid-token. + super(`This node's configuration was written by an older dashmate +and has to be migrated before it can be read: + + ${configFilePath} + +Migrating moves and removes files on disk, so a command +that changes nothing will not do it. + +Run any other dashmate command first, for example: + + dashmate status + +That migrates the configuration while holding the +configuration lock. Then run this one again.`); + + this.configFilePath = configFilePath; + } + + /** + * @return {string} + */ + getConfigFilePath() { + return this.configFilePath; + } +} diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index b40b586a659..8a2374370ec 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -22,10 +22,9 @@ const restartHint = (cfg) => chalk`Then restart Platform so the gateway picks it * does, and only refuses to report success. Leaving this out lets a client * reachability problem be read as a software delivery one. */ -const UPDATE_CONSEQUENCE = 'Standards-compliant clients reject a certificate in this state,' - + ' so this node may not be reachable. `dashmate update` still pulls new images, so protocol' - + ' upgrades and security patches continue to arrive - but it exits non-zero until the' - + ' certificate is fixed.'; +const UPDATE_CONSEQUENCE = 'The certificate installed for the gateway did not pass dashmate\'s' + + ' checks. `dashmate update` still pulls new images, so protocol upgrades and security patches' + + ' continue to arrive - but it exits non-zero until this is fixed.'; export default function analyseGatewayCertificateFactory() { /** diff --git a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js index a727bf36b5c..bf43c66bebe 100644 --- a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js @@ -48,8 +48,8 @@ function renderSwitchOffer(config, externalIp, { certificatePassedChecks = false ${certificatePassedChecks ? ' The certificate this node is running on now passed its checks and stays\n' + ' in place, so nothing changes if you decline.\n' - : ' Declining leaves this node without a certificate a standards-compliant\n' - + ' client will accept.\n'}`; + : ' Declining leaves the installed certificate exactly as it is: unchanged,\n' + + ' and still failing the checks above.\n'}`; } /** diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index fb92de91604..174c1330b7c 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -201,9 +201,10 @@ export default function renderCertificateGuidance({ Node: ${config.get('network')} (config "${config.getName()}", ${config.get('externalIp') ?? 'no external IP set'}) Certificate: ${renderObservation(verdict)} - A standards-compliant client rejects a certificate in this state. dashmate - did not open a connection to check what is actually on the wire, so it cannot - say what this node is serving; \`dashmate doctor ${cfg}\` does that. + These checks read the files installed for the gateway. dashmate did not open + a connection, so it cannot say what this node is actually serving, and it did + not validate the certificate against public trust stores either; + \`dashmate doctor ${cfg}\` does the first of those. Nothing broke just now. This is the first release of dashmate that checks the certificate, so this is the first time you are being told. @@ -235,8 +236,8 @@ export default function renderCertificateGuidance({ blocks.push(renderPortEightyPermanence()); blocks.push(` IF YOU CANNOT OPEN PORT 80. dashmate currently has no supported alternative - for an IP-address certificate. Without one this node has no certificate a - DAPI client will accept. Updates themselves are unaffected: images are + for an IP-address certificate, so there is no route from here to one issued + by a public authority. Updates themselves are unaffected: images are always pulled, whatever this check finds, so this node is not being held back from protocol upgrades or security patches. To suppress this check for one run: diff --git a/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js b/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js index c3dde7bc591..a4b5a2ac95e 100644 --- a/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js +++ b/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js @@ -1,10 +1,14 @@ import fs from 'fs'; +import path from 'path'; import { spawn } from 'child_process'; import { expect } from 'chai'; import HomeDir from '../../../../src/config/HomeDir.js'; import getBaseConfigFactory from '../../../../configs/defaults/getBaseConfigFactory.js'; import ConfigFile from '../../../../src/config/configFile/ConfigFile.js'; import ConfigFileJsonRepository from '../../../../src/config/configFile/ConfigFileJsonRepository.js'; +import ConfigFileMigrationRequiredError from '../../../../src/config/errors/ConfigFileMigrationRequiredError.js'; +import createDIContainer from '../../../../src/createDIContainer.js'; +import getConfigFileDataV0250 from '../../../../src/test/fixtures/getConfigFileDataV0250.js'; const CURRENT_FORMAT_VERSION = '4.1.0'; @@ -242,29 +246,94 @@ describe('ConfigFileJsonRepository', () => { }); // A command that promises to change nothing must keep that promise even on - // the one run where a migration is due - which, right after an upgrade, is - // the run it is most likely to be used on. It still gets the migrated - // shape; it just does not write it, render from it, or take the lock to do - // either, so it cannot abort on a lock timeout while renewal holds it. - it('should migrate in memory only when the caller changes nothing', () => { + // the one run where a migration is due. Migrations are not all pure - one + // copies TLS files to a new location and removes the originals, and then + // deletes the whole ssl directory - so migrating on its behalf would move + // and delete files outside any lock, from a command documented as safe to + // run against a node that is still up. + it('should refuse to migrate for a caller that changes nothing', () => { seedConfigFile(); - const migration = (data) => ({ ...data, configFormatVersion: '9.9.9' }); + let migrationRuns = 0; + const migration = (data) => { + migrationRuns += 1; + + return { ...data, configFormatVersion: '9.9.9' }; + }; const repository = new ConfigFileJsonRepository(migration, homeDir, createDefaults); const before = fs.readFileSync(configFilePath, 'utf8'); - let rendered = false; - const { configFile } = repository.readAndMigrate( - { readOnly: true }, - () => { rendered = true; }, + expect(() => repository.readAndMigrate({ readOnly: true })) + .to.throw(ConfigFileMigrationRequiredError); + + // Not "migrated in memory and discarded" - not run at all, because + // running it is what touches the disk. + expect(migrationRuns).to.equal(0); + expect(fs.readFileSync(configFilePath, 'utf8')).to.equal(before); + expect(fs.existsSync(homeDir.joinPath('.config.json.lock'))).to.be.false(); + }); + + // The common case, and the one that has to stay fast: nothing to migrate, + // so nothing to refuse and no lock to take. + it('should read without locking for a caller that changes nothing', () => { + seedConfigFile(); + + const repository = new ConfigFileJsonRepository( + identityMigration, + homeDir, + createDefaults, + CURRENT_FORMAT_VERSION, ); + const before = fs.readFileSync(configFilePath, 'utf8'); - expect(configFile.getConfigFormatVersion()).to.equal('9.9.9'); - expect(rendered).to.be.false(); + const { configFile } = repository.readAndMigrate({ readOnly: true }); + + expect(configFile.getConfig('base')).to.exist(); expect(fs.readFileSync(configFilePath, 'utf8')).to.equal(before); expect(fs.existsSync(homeDir.joinPath('.config.json.lock'))).to.be.false(); }); + // The migration this refuses to run really does delete things. Driven with + // the shipped migration set rather than a stand-in, so the guard is pinned + // against the behaviour it exists for and not against a mock of it. + it('should leave the ssl directory alone that migrating would delete', async () => { + const container = await createDIContainer(); + container.resolve('homeDir').change(homeDir); + + const migrateConfigFile = container.resolve('migrateConfigFile'); + const configFormatVersion = container.resolve('configFormatVersion'); + + // A genuine config of that era, so the migrations that follow it run + // against the shape they were written for. + const legacy = getConfigFileDataV0250(); + const [legacyName] = Object.keys(legacy.configs); + fs.writeFileSync(configFilePath, JSON.stringify(legacy, undefined, 2), 'utf8'); + + const legacySslDir = homeDir.joinPath('ssl', legacyName); + fs.mkdirSync(legacySslDir, { recursive: true }); + fs.writeFileSync(path.join(legacySslDir, 'bundle.crt'), 'certificate', 'utf8'); + + const repository = new ConfigFileJsonRepository( + migrateConfigFile, + homeDir, + createDefaults, + configFormatVersion, + ); + + expect(() => repository.readAndMigrate({ readOnly: true })) + .to.throw(ConfigFileMigrationRequiredError); + + expect(fs.existsSync(path.join(legacySslDir, 'bundle.crt'))).to.be.true(); + expect(fs.existsSync(homeDir.joinPath('ssl'))).to.be.true(); + expect(JSON.parse(fs.readFileSync(configFilePath, 'utf8')).configFormatVersion) + .to.equal('0.25.0'); + + // The control: a normal read migrates, and that is what removes them. + repository.readAndMigrate(); + + expect(fs.existsSync(homeDir.joinPath('ssl'))).to.be.false(); + }); + // Migrations are not all pure - one moves TLS files and deletes the // originals - so deciding whether one is due must not run them. Running // them to find out would do that work outside the lock, where another diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index 95fbdb4fca2..655a24761a0 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -200,6 +200,22 @@ describe('analyseGatewayCertificateFactory', () => { // An operator who reads this is deciding whether to stop updating. Images // keep arriving whatever the certificate does, and saying so is what keeps // a client-reachability problem from being read as a software-delivery one. + // Doctor collects a wire sample too, and the two can legitimately disagree: + // a gateway running with a healthy in-memory certificate and a stale bundle + // on disk is exactly the case this analyser exists alongside. So the + // on-disk problem states what the files show, not what a client would do. + it('should not state a client outcome from an on-disk check', () => { + const [problem] = analyseInstalled({ + status: 'INVALID', + reasons: [{ code: 'SWITCH_INCOMPLETE', message: 'a switch was interrupted' }], + warnings: [], + }); + + expect(problem.getSolution()).to.not.match(/clients? rejects?/i); + expect(problem.getSolution()).to.not.match(/clients (are|were|could not|cannot|unable)/i); + expect(problem.getSolution()).to.contain('did not pass'); + }); + it('should say that updates still deliver images', () => { const [problem] = analyseInstalled({ status: 'INVALID', diff --git a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js index 4fced64d0ad..d9ed31a4e01 100644 --- a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js @@ -177,7 +177,15 @@ describe('gatewayCertificateTaskFactory', () => { answers: [false], }); - expect(enquirer.options[0].header).to.contain('leaves this node without'); + // What declining costs, in terms of what was actually established: the + // installed pair stays as it is and still fails the checks. Not what a + // client would do with it - the checks read files, never the wire, and + // never the chain to a public root. + const { header } = enquirer.options[0]; + + expect(header).to.contain('still failing the checks'); + expect(header).to.not.match(/client (will|would|does not|will not)? ?(accept|reject)/i); + expect(header).to.not.match(/clients (are|were|could not|cannot|unable)/i); }); it('should exit cleanly when the courtesy switch is declined', async function it() { diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index e8b03b971ae..704199e415a 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -156,7 +156,33 @@ describe('renderCertificateGuidance', () => { expect(output).to.not.match(/clients (are|were|could not|cannot|unable)/i); expect(output).to.not.match(/(is|was|has been) unreachable/i); expect(output).to.not.match(/your node is (down|dark|offline)/i); + + // Nor what a client would make of the certificate itself. The chain to a + // public root is never validated either, and some blocking findings - an + // unfinished provider switch - sit on a certificate a client would + // accept perfectly well. + expect(output).to.not.match(/client (will|would|does not|will not)? ?(accept|reject)/i); + expect(output).to.not.match(/clients? rejects?/i); + }); + }); + + // The one exception, and it is a real one rather than an oversight. Self + // signature is proven structurally - the leaf verifies under its own public + // key - and a certificate signed by nothing else is in no public trust store + // by definition. That is a property of the file, established by the check, + // not an inference about the wire. + it('should still say a self-signed certificate is not publicly trusted', () => { + const output = render({ + verdict: verdict({ + reasons: [{ + code: CERTIFICATE_REASONS.SELF_SIGNED, + message: 'The installed certificate is self-signed. Self-signed TLS is not' + + ' publicly trusted and standards-compliant clients will reject it', + }], + }), }); + + expect(output).to.contain('not publicly trusted'); }); // A silent drop of an external probe is no information at all: 52 nodes that From 09f970987223900baa083848d32c12a849d7cc8d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 12:46:32 +0700 Subject: [PATCH 16/63] fix(dashmate): reserve the migration error for a version that is actually behind Two regressions introduced by the previous commit, plus the guard that stops the second class recurring. THE MIGRATION ERROR SWALLOWED EVERY OTHER PROBLEM. The read-only path refused using the same predicate that decides whether to take the lock, and that predicate answers "yes" whenever the state cannot be read at all: no config file, an unreadable one, malformed JSON, a missing or unparseable recorded version. Answering yes is right for locking - it costs a reader nothing it would not already pay - but it is wrong as grounds for telling an operator an older dashmate wrote their file. A missing config file has to report itself as missing, not least because that is the error first-run setup catches to create defaults. The two questions are now asked separately. The locking predicate is untouched and still fails safe; a second, narrower one answers true only when both versions could be read and compared and the recorded one is strictly behind. Everything else falls through to the read, where the file reports its own problem. THE ERROR SUGGESTED A COMMAND IT COULD NOT AIM. It told the operator to run `dashmate status`, with no --config, from a layer that has no idea which node was selected - so it named the default one. It now suggests no command at all and says why: it is raised before a node has been chosen, so any command written out would name the wrong one as often as the right one. The prose still tells them what to do. AND A CATEGORICAL GUARD, because this is the third time a bare command has appeared and been fixed at the sites that existed at the time. Fixing this one alone guarantees a fourth. Two halves, each proved to fail independently by removing a config from a rendered command: - Every operator-facing surface is driven for real - the guidance for all four providers and all three pull outcomes, every doctor prescription, the port-80 give-up, the written-pair verification, and every prompt the check can raise - from a NON-DEFAULT config name, so a missing --config cannot pass by silently hitting the default. Commands are found by anchoring on dashmate's actual subcommands, and a backticked mention of a command's own name is not treated as an instruction to run it. - A sweep of every file under src for a command laid out to be copied, with an explicit list of the seven pre-existing files that predate this convention. A new file cannot join that list without a visible edit, which is the recurrence mode this exists to close. A second test keeps the list honest by failing when an entry stops needing its exemption. One wording change fell out of it: the guidance opened with "dashmate update pulled images", a bare command name at the start of a sentence and indistinguishable from an instruction. It now says "This run pulled images". Tests: 6 new for the first regression and 1 for the second, all red before this commit - a missing file, a malformed file and two damaged version fields each reported migration-required, and the message carried a copyable bare command. 10 new in the categorical spec. Two earlier tests were strengthened: one had been relying on the fail-safe rather than a real version comparison, the other matched dashmate anywhere in prose. All green after: 540 unit tests passing, 0 lint errors, 48 warnings unchanged from baseline. Co-Authored-By: Claude Opus 5 --- .../configFile/ConfigFileJsonRepository.js | 50 ++- .../ConfigFileMigrationRequiredError.js | 11 +- .../src/ssl/renderCertificateGuidance.js | 10 +- .../ConfigFileJsonRepository.spec.js | 104 +++++- .../test/unit/renderedCommands.spec.js | 309 ++++++++++++++++++ .../ssl/renderCertificateGuidance.spec.js | 3 +- 6 files changed, 469 insertions(+), 18 deletions(-) create mode 100644 packages/dashmate/test/unit/renderedCommands.spec.js diff --git a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js index fe63d93459c..148953fb749 100644 --- a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js +++ b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js @@ -249,20 +249,26 @@ export default class ConfigFileJsonRepository { return { configFile, migrated }; }; - // Decide whether a migration is due from the recorded version alone. - // Migrations are not all pure - some move service files on disk and delete - // the originals - so running them to find out would do that work outside - // the lock, and again inside it. // Reading is what runs the migrations, and some of them copy TLS material // to a new location, remove the originals, and then delete the legacy ssl // directory outright. A caller that promised to change nothing cannot read // a config file that is not current: declining is the only honest answer, // and it stops a stop-first upgrade before the node goes down rather than // after. - if (options.readOnly === true && this.#isMigrationDue()) { + // + // Answering a different question from the one below. "Take the lock" is + // safe to answer yes to whenever the state cannot be read, but "tell the + // operator an older dashmate wrote this" has to be true - a file that is + // missing or damaged must report itself as missing or damaged, and one of + // those errors is what first-run setup catches to create defaults. + if (options.readOnly === true && this.#isRecordedVersionBehind()) { throw new ConfigFileMigrationRequiredError(this.configFilePath); } + // Decide whether a migration is due from the recorded version alone. + // Migrations are not all pure - some move service files on disk and delete + // the originals - so running them to find out would do that work outside + // the lock, and again inside it. if (options.readOnly === true || !this.#isMigrationDue()) { return { configFile: this.read(options) }; } @@ -290,6 +296,40 @@ export default class ConfigFileJsonRepository { }); } + /** + * Whether the recorded format version is demonstrably older than this build's. + * + * Narrower than the question below, and deliberately so: this one is only + * true when both versions could be read and compared. Anything that defeats + * the comparison - no file, unreadable file, a missing or unparseable + * version, no target to compare against - is not evidence that an older + * dashmate wrote the file, so it answers false and leaves the file to report + * its own problem. + * + * @returns {boolean} + */ + #isRecordedVersionBehind() { + if (typeof this.configFormatVersion !== 'string') { + return false; + } + + let recordedVersion; + + try { + recordedVersion = JSON.parse( + fs.readFileSync(this.configFilePath, 'utf8'), + ).configFormatVersion; + } catch { + return false; + } + + if (typeof recordedVersion !== 'string' || semver.valid(recordedVersion) === null) { + return false; + } + + return semver.lt(recordedVersion, this.configFormatVersion); + } + /** * Whether the file on disk records an older format than this build produces. * diff --git a/packages/dashmate/src/config/errors/ConfigFileMigrationRequiredError.js b/packages/dashmate/src/config/errors/ConfigFileMigrationRequiredError.js index 003ef444ff6..199213e9e8d 100644 --- a/packages/dashmate/src/config/errors/ConfigFileMigrationRequiredError.js +++ b/packages/dashmate/src/config/errors/ConfigFileMigrationRequiredError.js @@ -25,12 +25,13 @@ and has to be migrated before it can be read: Migrating moves and removes files on disk, so a command that changes nothing will not do it. -Run any other dashmate command first, for example: +Run any other dashmate command for this node first - any one +that is not this check. It migrates the configuration while +holding the configuration lock. Then run this one again. - dashmate status - -That migrates the configuration while holding the -configuration lock. Then run this one again.`); +No command is suggested here on purpose: this is raised before +a node has been selected, so any command written out would +name the wrong one as often as the right one.`); this.configFilePath = configFilePath; } diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index 174c1330b7c..5e4f7a5def6 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -22,17 +22,17 @@ function renderOpening(pull) { } if (!pull.ok) { - return ` dashmate update could not pull images, and stopped: this node's installed TLS + return ` This run could not pull images, and stopped: this node's installed TLS certificate did not pass dashmate's checks.`; } if (pull.failed > 0) { - return ` dashmate update pulled images - ${pull.failed} of ${pull.total} failed, see the - table above - then stopped: this node's installed TLS certificate did not - pass dashmate's checks.`; + return ` This run pulled images - ${pull.failed} of ${pull.total} failed, see the table + above - then stopped: this node's installed TLS certificate did not pass + dashmate's checks.`; } - return ` dashmate update pulled images, then stopped: this node's installed TLS + return ` This run pulled images, then stopped: this node's installed TLS certificate did not pass dashmate's checks.`; } diff --git a/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js b/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js index a4b5a2ac95e..192addc7deb 100644 --- a/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js +++ b/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js @@ -7,6 +7,8 @@ import getBaseConfigFactory from '../../../../configs/defaults/getBaseConfigFact import ConfigFile from '../../../../src/config/configFile/ConfigFile.js'; import ConfigFileJsonRepository from '../../../../src/config/configFile/ConfigFileJsonRepository.js'; import ConfigFileMigrationRequiredError from '../../../../src/config/errors/ConfigFileMigrationRequiredError.js'; +import ConfigFileNotFoundError from '../../../../src/config/errors/ConfigFileNotFoundError.js'; +import InvalidConfigFileFormatError from '../../../../src/config/errors/InvalidConfigFileFormatError.js'; import createDIContainer from '../../../../src/createDIContainer.js'; import getConfigFileDataV0250 from '../../../../src/test/fixtures/getConfigFileDataV0250.js'; @@ -254,13 +256,22 @@ describe('ConfigFileJsonRepository', () => { it('should refuse to migrate for a caller that changes nothing', () => { seedConfigFile(); + const seeded = JSON.parse(seedConfigFile()); + seeded.configFormatVersion = '0.25.0'; + fs.writeFileSync(configFilePath, JSON.stringify(seeded, undefined, 2), 'utf8'); + let migrationRuns = 0; const migration = (data) => { migrationRuns += 1; - return { ...data, configFormatVersion: '9.9.9' }; + return { ...data, configFormatVersion: CURRENT_FORMAT_VERSION }; }; - const repository = new ConfigFileJsonRepository(migration, homeDir, createDefaults); + const repository = new ConfigFileJsonRepository( + migration, + homeDir, + createDefaults, + CURRENT_FORMAT_VERSION, + ); const before = fs.readFileSync(configFilePath, 'utf8'); expect(() => repository.readAndMigrate({ readOnly: true })) @@ -273,6 +284,59 @@ describe('ConfigFileJsonRepository', () => { expect(fs.existsSync(homeDir.joinPath('.config.json.lock'))).to.be.false(); }); + // Refusing has to mean "the recorded version is genuinely behind", not + // "something about this file defeated the probe". A missing or damaged + // config file has its own errors, and one of them is what first-run setup + // catches to create defaults - reporting a migration instead breaks that + // and tells the operator something untrue about a file that may not exist. + it('should let a missing config file report itself', () => { + const repository = new ConfigFileJsonRepository( + identityMigration, + homeDir, + createDefaults, + CURRENT_FORMAT_VERSION, + ); + + expect(() => repository.readAndMigrate({ readOnly: true })) + .to.throw(ConfigFileNotFoundError); + }); + + it('should let a malformed config file report itself', () => { + fs.writeFileSync(configFilePath, '{ not json at all', 'utf8'); + + const repository = new ConfigFileJsonRepository( + identityMigration, + homeDir, + createDefaults, + CURRENT_FORMAT_VERSION, + ); + + expect(() => repository.readAndMigrate({ readOnly: true })) + .to.throw(InvalidConfigFileFormatError); + }); + + [ + ['no recorded version', ({ configFormatVersion, ...rest }) => rest], + ['an unparseable recorded version', (data) => ({ ...data, configFormatVersion: 'not-a-version' })], + ].forEach(([name, damage]) => { + it(`should not claim a migration is due from ${name}`, () => { + const damaged = damage(JSON.parse(seedConfigFile())); + fs.writeFileSync(configFilePath, JSON.stringify(damaged, undefined, 2), 'utf8'); + + const repository = new ConfigFileJsonRepository( + identityMigration, + homeDir, + createDefaults, + CURRENT_FORMAT_VERSION, + ); + + // Whatever this file's own problem turns out to be, it is not that an + // older dashmate wrote it - nothing here establishes that. + expect(() => repository.readAndMigrate({ readOnly: true })) + .to.not.throw(ConfigFileMigrationRequiredError); + }); + }); + // The common case, and the one that has to stay fast: nothing to migrate, // so nothing to refuse and no lock to take. it('should read without locking for a caller that changes nothing', () => { @@ -293,6 +357,42 @@ describe('ConfigFileJsonRepository', () => { expect(fs.existsSync(homeDir.joinPath('.config.json.lock'))).to.be.false(); }); + // Every command dashmate prints falls back to the default node when it + // carries no --config, and this error is raised from a layer that has no + // idea which node was selected. So it names none: prose an operator cannot + // paste at the wrong machine. + it('should suggest no command it cannot aim at the right node', () => { + const seeded = JSON.parse(seedConfigFile()); + seeded.configFormatVersion = '0.25.0'; + fs.writeFileSync(configFilePath, JSON.stringify(seeded, undefined, 2), 'utf8'); + + const repository = new ConfigFileJsonRepository( + identityMigration, + homeDir, + createDefaults, + CURRENT_FORMAT_VERSION, + ); + + const error = (() => { + try { + repository.readAndMigrate({ readOnly: true }); + } catch (e) { + return e; + } + + return null; + })(); + + expect(error).to.be.an.instanceOf(ConfigFileMigrationRequiredError); + expect(error.message).to.contain(configFilePath); + + // Prose may name dashmate; nothing may be laid out as a command to copy. + error.message.split('\n').forEach((line) => { + expect(line, line).to.not.match(/^\s+dashmate\s/); + expect(line, line).to.not.match(/`dashmate\s/); + }); + }); + // The migration this refuses to run really does delete things. Driven with // the shipped migration set rather than a stand-in, so the guard is pinned // against the behaviour it exists for and not against a mock of it. diff --git a/packages/dashmate/test/unit/renderedCommands.spec.js b/packages/dashmate/test/unit/renderedCommands.spec.js new file mode 100644 index 00000000000..51b94d0330d --- /dev/null +++ b/packages/dashmate/test/unit/renderedCommands.spec.js @@ -0,0 +1,309 @@ +import fs from 'fs'; +import path from 'path'; +import { Listr } from 'listr2'; +import HomeDir from '../../src/config/HomeDir.js'; +import Config from '../../src/config/Config.js'; +import getBaseConfigFactory from '../../configs/defaults/getBaseConfigFactory.js'; +import analyseGatewayCertificateFactory from '../../src/doctor/analyse/analyseGatewayCertificateFactory.js'; +import Samples from '../../src/doctor/Samples.js'; +import gatewayCertificateTaskFactory from '../../src/listr/tasks/update/gatewayCertificateTaskFactory.js'; +import obtainLetsEncryptCertificateTaskFactory from '../../src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js'; +import saveCertificateTaskFactory from '../../src/listr/tasks/ssl/saveCertificateTask.js'; +import renderCertificateGuidance from '../../src/ssl/renderCertificateGuidance.js'; +import { CERTIFICATE_REASONS, CERTIFICATE_STATUS } from '../../src/ssl/checkGatewayCertificateFactory.js'; +import { issueCertificate } from '../../src/test/certificateFixtures.js'; +import getEnquirerMock from '../../src/test/mock/getEnquirerMock.js'; + +// Deliberately not the default. A command missing its config still works +// against the default node, so a test driven from the default config passes +// whether or not the config is there at all. +const CONFIG_NAME = 'testnet_2'; +const EXTERNAL_IP = '1.2.3.4'; + +/** + * The subcommands dashmate actually has. Anchoring on these separates a command + * from prose that merely names dashmate ("dashmate did not open a connection"), + * without having to guess at sentence shape. + */ +const SUBCOMMANDS = [ + 'config', 'core', 'doctor', 'group', 'logs', 'reset', 'restart', 'setup', + 'ssl', 'start', 'status', 'stop', 'update', 'wallet', +]; + +const COMMAND = new RegExp(`dashmate\\s+(?:${SUBCOMMANDS.join('|')})\\b[^\\n}\`'"]*`, 'g'); + +/** + * A backticked mention of nothing but the command's own name, as in "this does + * not block `dashmate start`". Naming a command is not telling someone to run + * it, and there is nothing for a node name to attach to. + */ +const BARE_REFERENCE = new RegExp(`\`dashmate\\s+(?:${SUBCOMMANDS.join('|')})\``, 'g'); + +/** + * Presentation forms used in source: a chalk-highlighted command, or an + * indented line in a message template. + */ +const PRESENTED_IN_SOURCE = new RegExp( + `(?:\\{bold\\.cyanBright\\s+|^[ \\t]{4,})(dashmate\\s+(?:${SUBCOMMANDS.join('|')})\\b[^\\n}\`'"]*)`, + 'gm', +); + +/** + * Files that render an operator-copyable command and predate this work, where + * every command is bare by existing convention. They are listed rather than + * skipped by pattern so a NEW file cannot join them silently - which is the + * whole point of the sweep below. + */ +const PRE_EXISTING_BARE_COMMANDS = [ + 'src/commands/doctor/index.js', + 'src/commands/setup.js', + 'src/doctor/analyse/analyseConfigFactory.js', + 'src/doctor/analyse/analyseCoreFactory.js', + 'src/doctor/analyse/analyseServiceContainersFactory.js', + 'src/listr/tasks/setup/regular/getConfigurationOutputFromContext.js', + 'src/listr/tasks/setup/setupRegularPresetTaskFactory.js', +]; + +/** + * @param {string} directory + * @param {string[]} [found] + * @return {string[]} + */ +function javascriptFilesIn(directory, found = []) { + fs.readdirSync(directory, { withFileTypes: true }).forEach((entry) => { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + javascriptFilesIn(entryPath, found); + } else if (entry.name.endsWith('.js')) { + found.push(entryPath); + } + }); + + return found; +} + +/** + * Every command in rendered output that an operator could copy and run. + * + * @param {string} text + * @return {string[]} + */ +function commandsIn(text) { + const references = new Set( + [...text.matchAll(BARE_REFERENCE)].map((match) => match[0].replace(/`/g, '')), + ); + + return [...text.matchAll(COMMAND)] + .map((match) => match[0].trim().replace(/[.,;:]+$/, '')) + .filter((command) => !references.has(command)); +} + +/** + * @param {string} source + * @return {string[]} + */ +function presentedCommandsIn(source) { + return [...source.matchAll(PRESENTED_IN_SOURCE)].map((match) => match[1].trim()); +} + +describe('every command dashmate tells an operator to run', () => { + let homeDir; + let config; + + beforeEach(() => { + homeDir = HomeDir.createTemp(); + config = new Config(CONFIG_NAME, getBaseConfigFactory(homeDir)().getOptions()); + config.set('network', 'mainnet'); + config.set('externalIp', EXTERNAL_IP); + }); + + afterEach(() => homeDir.remove()); + + /** + * @param {Object} [overrides] + * @return {Object} + */ + const verdict = (overrides = {}) => ({ + status: CERTIFICATE_STATUS.INVALID, + reasons: [{ code: CERTIFICATE_REASONS.EXPIRED, message: 'the certificate expired' }], + warnings: [], + skipped: [], + provider: config.get('platform.gateway.ssl.provider'), + installed: { validTo: new Date(Date.now() + 6 * 864e5) }, + expiresInDays: 6, + ...overrides, + }); + + /** + * @param {string} label + * @param {string} text + */ + function expectEveryCommandNamesTheNode(label, text) { + const commands = commandsIn(text); + + expect(commands, `${label} rendered no command to check`).to.have.length.greaterThan(0); + + commands.forEach((command) => { + expect(command, `${label}: ${command}`).to.contain(`--config ${CONFIG_NAME}`); + }); + } + + // The rendered surfaces, driven for real rather than read from source, so a + // command assembled at runtime is checked too. + describe('as rendered', () => { + ['zerossl', 'letsencrypt', 'file', 'self-signed'].forEach((provider) => { + it(`names the node in the guidance for a ${provider} node`, () => { + config.set('platform.gateway.ssl.provider', provider); + + [ + renderCertificateGuidance({ + config, verdict: verdict(), isNodeRunning: false, pull: null, + }), + renderCertificateGuidance({ + config, verdict: verdict(), isNodeRunning: true, pull: { ok: true, failed: 0, total: 3 }, + }), + renderCertificateGuidance({ + config, + verdict: verdict({ + reasons: [{ + code: CERTIFICATE_REASONS.SWITCH_INCOMPLETE, + message: 'a switch was interrupted', + }], + }), + isNodeRunning: false, + pull: null, + }), + ].forEach((output, index) => expectEveryCommandNamesTheNode(`guidance ${provider}/${index}`, output)); + }); + }); + + it('names the node in every doctor prescription', () => { + const samples = new Samples(); + samples.setDashmateConfig(config); + samples.setServiceInfo('gateway', 'installedCertificate', { + status: 'INVALID', + reasons: [{ code: 'EXPIRED', message: 'expired' }], + warnings: [{ code: 'EXPIRING_SOON', message: 'expires tomorrow' }], + }); + samples.setServiceInfo('gateway', 'servedCertificate', { + state: 'served', + port: 443, + certificate: { fingerprint256: 'AA:BB', validTo: new Date(Date.now() - 864e5).toUTCString() }, + chainVerified: false, + chainError: 'untrusted', + identityVerified: true, + matchesOnDisk: false, + }); + + const problems = analyseGatewayCertificateFactory()(samples); + + expect(problems).to.have.length.greaterThan(2); + problems.forEach((problem, index) => expectEveryCommandNamesTheNode( + `doctor problem ${index}`, + problem.getSolution(), + )); + }); + + it('names the node when the operator gives up on port 80', async function it() { + const missing = Object.assign(new Error('no container'), { statusCode: 404 }); + const tasks = obtainLetsEncryptCertificateTaskFactory( + { + getContainer: this.sinon.stub().rejects(missing), + createContainer: this.sinon.stub().resolves({ + start: this.sinon.stub().resolves(), + logs: this.sinon.stub().resolves(Buffer.from('Timeout during connect')), + wait: this.sinon.stub().resolves({ StatusCode: 1 }), + }), + }, + this.sinon.stub().resolves(), + { addContainer: this.sinon.stub() }, + homeDir, + this.sinon.stub().resolves({ error: 'CERTIFICATE_NOT_FOUND', data: {} }), + this.sinon.stub(), + null, + {}, + )(config); + + const error = await tasks.run({ force: true }).catch((e) => e); + + expectEveryCommandNamesTheNode('give-up guidance', error.message); + }); + + it('names the node when a written pair does not match', async () => { + const certificate = issueCertificate({ ip: EXTERNAL_IP }); + const other = issueCertificate({ ip: EXTERNAL_IP }); + + const error = await saveCertificateTaskFactory(homeDir)(config).run({ + certificateFile: certificate.pem, + privateKeyFile: other.keyPem, + }).catch((e) => e); + + expectEveryCommandNamesTheNode('save verification', error.message); + }); + + it('names the node in every prompt the check can raise', async function it() { + const enquirer = getEnquirerMock(this.sinon, false, false); + + const gatewayCertificateTask = gatewayCertificateTaskFactory( + () => verdict(), + this.sinon.stub().callsFake(() => new Listr([{ task: () => {} }], { renderer: 'silent' })), + this.sinon.stub().callsFake(() => new Listr([{ task: () => {} }], { renderer: 'silent' })), + { isExclusive: () => true, write: this.sinon.stub() }, + {}, + this.sinon.stub(), + { execCommand: this.sinon.stub().resolves() }, + ); + + const tasks = new Listr( + [{ task: gatewayCertificateTask(config, { interactive: true }) }], + { renderer: 'silent', exitOnError: false }, + ); + tasks.options.injectWrapper = { enquirer }; + + await tasks.run({}); + + expect(enquirer.options).to.have.length.greaterThan(0); + enquirer.options.forEach((option, index) => { + commandsIn(option.header ?? '').forEach((command) => { + expect(command, `prompt ${index}: ${command}`).to.contain(`--config ${CONFIG_NAME}`); + }); + }); + }); + }); + + // The backstop. Rendering can only check surfaces a test knows about, and + // this class of defect has recurred by arriving in a place nobody thought to + // check. Anything under src/ that lays out a command has to name the node, + // and a file joining the exemption list is a visible edit rather than a + // silent one. + describe('as written', () => { + it('lays out no command anywhere in src that cannot name the node', () => { + const offenders = []; + + javascriptFilesIn('src').forEach((file) => { + if (PRE_EXISTING_BARE_COMMANDS.includes(file)) { + return; + } + + presentedCommandsIn(fs.readFileSync(file, 'utf8')).forEach((command) => { + if (!/--config|\$\{cfg|renderConfigFlag/.test(command)) { + offenders.push(`${file}: ${command}`); + } + }); + }); + + expect(offenders, offenders.join('\n')).to.be.empty(); + }); + + it('keeps the exemption list honest', () => { + PRE_EXISTING_BARE_COMMANDS.forEach((file) => { + expect(fs.existsSync(file), `${file} is listed but gone`).to.be.true(); + expect( + presentedCommandsIn(fs.readFileSync(file, 'utf8')), + `${file} no longer needs an exemption`, + ).to.have.length.greaterThan(0); + }); + }); + }); +}); diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index 704199e415a..ff3f2a91ae3 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -236,7 +236,7 @@ describe('renderCertificateGuidance', () => { .to.contain('2 of 7 failed'); expect(render({ pull: { ok: false, failed: 0, total: 0 } })) .to.contain('could not pull images'); - expect(render()).to.contain('pulled images, then stopped'); + expect(render()).to.contain('This run pulled images, then stopped'); }); // The read-only preflight starts no pull at all, so it must not say anything @@ -247,6 +247,7 @@ describe('renderCertificateGuidance', () => { expect(output).to.not.contain('could not pull images'); expect(output).to.not.contain('pulled images'); + expect(output).to.not.contain('This run pulled'); expect(output).to.contain("This node's installed TLS certificate did not pass"); }); From 6dfc75ace4466423f582fc971d0181ac89f5362f Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 14:24:07 +0700 Subject: [PATCH 17/63] fix(dashmate): mask the operator's name even when the environment omits it Review findings worth acting on. MASKING SILENTLY DID NOTHING WITHOUT $USER. Every certificate and container sample was scrubbed with `replaceAll(process.env.USER, ...)`, which replaces the literal string "undefined" with itself when the variable is unset - a no-op, leaving absolute paths and container logs carrying the operator's home directory into the report they hand to whoever is helping them. Doctor runs unattended often enough - cron, a service manager, a container - that the variable cannot be relied on. The name now comes from the operating system, falling back to the environment, and when no name can be determined the data is left alone rather than having "undefined" replaced in it. Routed through one helper so all six sites share it: fixing only the one this branch added would have left the same leak in five siblings of the same archive, which is not a fix at all. The container-log sites are strings rather than objects and had the same bug; they go through the same source now. DOCTOR TOLD OPERATORS TO RESTART AFTER A COMMAND THAT RELOADS ITSELF. `dashmate ssl obtain` signals the gateway once it has the files, so the restart appended to the two on-disk prescriptions bought an outage and nothing else. Removed from those two, and they now say why none is needed. The stale-served-certificate prescriptions keep theirs: nothing reloads on those paths. THE RETRY PROMPT BLAMED PORT 80 FOR EVERY FAILURE. lego fails for reasons a firewall change cannot fix - a rate limit, an account problem, a bad directory - and its own output says which. The prompt now presents that output and names port 80 as the common case rather than the cause, which is the same discipline the rest of this work applies to anything it did not observe. A SCOPE TEST ASSERTED NOTHING. "should not check the certificate on local/devnet" built a stub and never looked at it, so it could not fail. It now asserts what the rule means: the certificate is never inspected and the images are still pulled. Rewriting it showed the task factory is invoked on those networks even though the task never runs - inert, since it only builds a closure, so the assertion is on the behaviour rather than on the construction. Also two test-isolation fixes. The Pebble switch-incomplete case restored the provider at the end of its body, so a failure part way through would have taken the contactless renewal down with it and hidden which of the two broke; it restores in a hook now. And the interactive `ssl obtain` case built its stubs after mutating the process streams, leaving a window - narrow, but pointless - in which a throw would have leaked TTY state into every later test. Tests: 5 new or rewritten, red before this commit - the archive kept the username with USER unset, doctor appended a restart to both obtain prescriptions, the retry prompt named port 80 as the cause, and the scope test could not fail. Plus a guard proving the read-only path runs no migration when the recorded version cannot be parsed: driven against the shipped migration set with a legacy ssl directory present, asserting it survives. All green after: 545 unit passing, 0 lint errors, 48 warnings unchanged, Pebble 13 passing. Co-Authored-By: Claude Opus 5 --- .../analyseGatewayCertificateFactory.js | 8 +- .../tasks/doctor/collectSamplesTaskFactory.js | 98 +++++++++++------ ...obtainLetsEncryptCertificateTaskFactory.js | 8 +- .../integration/ssl/letsencryptPebble.spec.js | 11 +- .../test/unit/commands/ssl/obtain.spec.js | 8 +- .../test/unit/commands/update.spec.js | 13 ++- .../ConfigFileJsonRepository.spec.js | 36 ++++++ .../analyseGatewayCertificateFactory.spec.js | 17 +++ .../doctor/collectSamplesTaskFactory.spec.js | 103 ++++++++++-------- ...nLetsEncryptCertificateTaskFactory.spec.js | 18 +++ 10 files changed, 231 insertions(+), 89 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 8a2374370ec..ba5ebd618c4 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -58,9 +58,8 @@ export default function analyseGatewayCertificateFactory() { message, chalk`${UPDATE_CONSEQUENCE} -Obtain a new certificate: -{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt} -${restartHint(cfg)}`, +Obtain a new certificate - it signals the gateway itself, so no restart is needed: +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, SEVERITY.HIGH, )); }); @@ -69,8 +68,7 @@ ${restartHint(cfg)}`, problems.push(new Problem( message, chalk`Nothing is broken yet. If it needs attention, obtain a new certificate: -{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt} -${restartHint(cfg)}`, +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, SEVERITY.LOW, )); }); diff --git a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js index ab95ad57b89..aed5cb54a9b 100644 --- a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js @@ -1,4 +1,5 @@ import fs from 'fs'; +import os from 'os'; import { Listr } from 'listr2'; import path from 'path'; import process from 'process'; @@ -42,6 +43,65 @@ async function fetchTextOrError(url) { * @param {checkGatewayCertificate} checkGatewayCertificate * @return {collectSamplesTask} */ +/** + * Mask the name of whoever is running dashmate out of collected data. + * + * A report is the artefact an operator hands to whoever is helping them, and + * the paths in it are absolute, so they carry a home directory. The paths stay + * - they are what makes a problem actionable - and the name in them does not. + * + * The name is read from the operating system rather than the environment. + * Doctor runs unattended often enough - from cron, from a service manager - + * that USER cannot be relied on, and replacing an undefined needle silently + * masks nothing at all. When no name can be determined there is nothing to + * mask and the data is left alone rather than having "undefined" replaced in + * it. + * + * @return {string|null} + */ +function getOperatorName() { + try { + const { username } = os.userInfo(); + + if (username) { + return username; + } + } catch { + // A process running under a uid with no passwd entry has no name to read. + } + + return process.env.USER || process.env.USERNAME || null; +} + +/** + * @param {Object} data - mutated in place + */ +function obfuscateOperatorName(data) { + const username = getOperatorName(); + + if (!username) { + return; + } + + obfuscateObjectRecursive(data, (_field, value) => (typeof value === 'string' + ? value.replaceAll(username, hideString(username)) + : value)); +} + +/** + * @param {string|undefined} text + * @return {string|undefined} + */ +function hideOperatorNameIn(text) { + const username = getOperatorName(); + + if (!username || typeof text !== 'string') { + return text; + } + + return text.replaceAll(username, hideString(username)); +} + export default function collectSamplesTaskFactory( dockerCompose, createRpcClient, @@ -114,10 +174,7 @@ export default function collectSamplesTaskFactory( Certificate.EXPIRATION_LIMIT_DAYS, ); - obfuscateObjectRecursive(data, (_field, value) => (typeof value === 'string' ? value.replaceAll( - process.env.USER, - hideString(process.env.USER), - ) : value)); + obfuscateOperatorName(data); ctx.samples.setServiceInfo('gateway', 'ssl', { error, @@ -135,10 +192,7 @@ export default function collectSamplesTaskFactory( LegoCertificate.EXPIRATION_LIMIT_DAYS, ); - obfuscateObjectRecursive(data, (_field, value) => (typeof value === 'string' ? value.replaceAll( - process.env.USER, - hideString(process.env.USER), - ) : value)); + obfuscateOperatorName(data); ctx.samples.setServiceInfo('gateway', 'ssl', { error, @@ -164,10 +218,7 @@ export default function collectSamplesTaskFactory( privateFilePath, }; - obfuscateObjectRecursive(data, (_field, value) => (typeof value === 'string' ? value.replaceAll( - process.env.USER, - hideString(process.env.USER), - ) : value)); + obfuscateOperatorName(data); if (!fs.existsSync(chainFilePath) || !fs.existsSync(privateFilePath)) { ctx.samples.setServiceInfo('gateway', 'ssl', { @@ -223,9 +274,7 @@ export default function collectSamplesTaskFactory( // hands to whoever is helping them, so the path stays - it is // what makes the problem actionable - and the name in it does // not. - obfuscateObjectRecursive(installed, (_field, value) => (typeof value === 'string' - ? value.replaceAll(process.env.USER, hideString(process.env.USER)) - : value)); + obfuscateOperatorName(installed); ctx.samples.setServiceInfo('gateway', 'installedCertificate', installed); }, @@ -461,28 +510,15 @@ export default function collectSamplesTaskFactory( if (logs?.out) { // Hide username & external ip from logs - logs.out = logs.out.replaceAll( - process.env.USER, - hideString(process.env.USER), - ); + logs.out = hideOperatorNameIn(logs.out); } if (logs?.err) { - logs.err = logs.err.replaceAll( - process.env.USER, - hideString(process.env.USER), - ); + logs.err = hideOperatorNameIn(logs.err); } // Hide username & external ip from inspect - obfuscateObjectRecursive(inspect, (_field, value) => ( - typeof value === 'string' - ? value.replaceAll( - process.env.USER, - hideString(process.env.USER), - ) - : value - )); + obfuscateOperatorName(inspect); ctx.samples.setServiceInfo(service.name, 'stdOut', logs?.out); ctx.samples.setServiceInfo(service.name, 'stdErr', logs?.err); diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index e6028ef6afe..a73630c81cd 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -338,12 +338,14 @@ export default function obtainLetsEncryptCertificateTaskFactory( // per hour this node shares with its own automatic renewal. const retry = canRetry && await promptOrThrow(task, { type: 'toggle', - header: ` Let's Encrypt could not reach ${ctx.externalIp} on port 80: + header: ` Let's Encrypt did not issue a certificate for ${ctx.externalIp}: ${e.message} - Retrying without changing anything will fail again. Fix the port first, then - answer Yes - or answer No and try again once port 80 is open.`, + Whatever the output above says is the reason - most often inbound port 80, + but a rate limit or an account problem looks different and is not fixed by + opening a firewall. Retrying without changing anything will fail the same + way, so read it first, then answer Yes once something has changed.`, message: `Try again? [attempt ${attempt + 1} of ${MAX_OBTAIN_ATTEMPTS}]`, enabled: 'Yes', disabled: 'No', diff --git a/packages/dashmate/test/integration/ssl/letsencryptPebble.spec.js b/packages/dashmate/test/integration/ssl/letsencryptPebble.spec.js index d0633e76baa..f1698e04c90 100644 --- a/packages/dashmate/test/integration/ssl/letsencryptPebble.spec.js +++ b/packages/dashmate/test/integration/ssl/letsencryptPebble.spec.js @@ -440,6 +440,15 @@ describe('Let\'s Encrypt certificate against a local ACME server', function main let contactless; let withContact; + // The renewal below refuses to run unless the provider says letsencrypt, so + // a test that leaves it changed - including one that fails part way through + // - would take the renewal down with it and hide which of the two broke. + afterEach(() => { + if (contactless) { + contactless.set('platform.gateway.ssl.provider', 'letsencrypt'); + } + }); + before(async () => { const obtainLetsEncryptCertificateTask = container.resolve('obtainLetsEncryptCertificateTask'); @@ -528,8 +537,6 @@ describe('Let\'s Encrypt certificate against a local ACME server', function main expect(verdict.status).to.equal('INVALID'); expect(verdict.reasons.map(({ code }) => code)).to.deep.equal(['SWITCH_INCOMPLETE']); - - contactless.set('platform.gateway.ssl.provider', 'letsencrypt'); }); // Renewal is where a missing account would surface, and it runs unattended diff --git a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js index 1931e458df8..dd30cf45a08 100644 --- a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js +++ b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js @@ -182,6 +182,11 @@ describe('SSL obtain command', () => { }); it('should offer to prompt an operator at a terminal', async function it() { + // Built before the streams are touched, so nothing can throw between the + // change and the restore that undoes it. + const dependencies = obtainDependencies(this.sinon); + const context = captureContext(this.sinon, dependencies); + // A stream that is not a terminal has no isTTY property at all - not a // false one - so it is assigned rather than stubbed. const restore = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY, ci: process.env.CI }; @@ -199,9 +204,6 @@ describe('SSL obtain command', () => { } }; - const dependencies = obtainDependencies(this.sinon); - const context = captureContext(this.sinon, dependencies); - try { await runObtain(dependencies); } finally { diff --git a/packages/dashmate/test/unit/commands/update.spec.js b/packages/dashmate/test/unit/commands/update.spec.js index 626ca34b0fe..a45153e1aeb 100644 --- a/packages/dashmate/test/unit/commands/update.spec.js +++ b/packages/dashmate/test/unit/commands/update.spec.js @@ -349,11 +349,20 @@ describe('Update command', () => { describe('scope', () => { ['local', 'devnet'].forEach((network) => { it(`should not check the certificate on ${network}`, async function it() { - const gatewayCertificateTask = this.sinon.stub().returns(async () => {}); + const innerTask = this.sinon.stub().resolves(); + const checkGatewayCertificate = this.sinon.stub().returns(passingVerdict()); config.set('network', network); - await runUpdate({ gatewayCertificateTask }); + await runUpdate({ + checkGatewayCertificate, + gatewayCertificateTask: () => innerTask, + }); + // Nothing about the certificate is looked at, and the images are still + // pulled - which is the whole of the rule for a network whose + // certificate is self-signed by design. + expect(innerTask).to.not.have.been.called(); + expect(checkGatewayCertificate).to.not.have.been.called(); expect(mockDocker.pull).to.have.been.calledOnce(); }); }); diff --git a/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js b/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js index 192addc7deb..eda32f5a81c 100644 --- a/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js +++ b/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js @@ -337,6 +337,42 @@ describe('ConfigFileJsonRepository', () => { }); }); + // Falling through to read() on an undetermined version is only safe because + // the version comparison inside the migration chain rejects a version it + // cannot parse before any migration body runs. Proven against the shipped + // migrations rather than argued, because one of those bodies deletes a + // directory of TLS material. + [ + ['no recorded version', ({ configFormatVersion, ...rest }) => rest], + ['an unparseable recorded version', (data) => ({ ...data, configFormatVersion: 'bad' })], + ].forEach(([name, damage]) => { + it(`should run no migration for a read-only caller given ${name}`, async () => { + const container = await createDIContainer(); + container.resolve('homeDir').change(homeDir); + + const legacy = damage(getConfigFileDataV0250()); + const [legacyName] = Object.keys(legacy.configs); + fs.writeFileSync(configFilePath, JSON.stringify(legacy, undefined, 2), 'utf8'); + + const legacySslDir = homeDir.joinPath('ssl', legacyName); + fs.mkdirSync(legacySslDir, { recursive: true }); + fs.writeFileSync(path.join(legacySslDir, 'bundle.crt'), 'certificate', 'utf8'); + + const repository = new ConfigFileJsonRepository( + container.resolve('migrateConfigFile'), + homeDir, + createDefaults, + container.resolve('configFormatVersion'), + ); + + expect(() => repository.readAndMigrate({ readOnly: true })).to.throw(); + + expect(fs.existsSync(path.join(legacySslDir, 'bundle.crt'))).to.be.true(); + expect(fs.existsSync(homeDir.joinPath('ssl'))).to.be.true(); + expect(fs.existsSync(homeDir.joinPath('.config.json.lock'))).to.be.false(); + }); + }); + // The common case, and the one that has to stay fast: nothing to migrate, // so nothing to refuse and no lock to take. it('should read without locking for a caller that changes nothing', () => { diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index 655a24761a0..517e45f6582 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -216,6 +216,23 @@ describe('analyseGatewayCertificateFactory', () => { expect(problem.getSolution()).to.contain('did not pass'); }); + // `dashmate ssl obtain` signals the gateway itself once it has the files, + // so telling the operator to restart afterwards buys an outage and nothing + // else. Restart guidance belongs on the paths where nothing else reloads. + it('should not ask for a restart after a command that reloads by itself', () => { + const problems = analyseInstalled({ + status: 'INVALID', + reasons: [{ code: 'EXPIRED', message: 'expired' }], + warnings: [{ code: 'EXPIRING_SOON', message: 'expires tomorrow' }], + }); + + expect(problems).to.have.lengthOf(2); + problems.forEach((problem) => { + expect(problem.getSolution()).to.contain('dashmate ssl obtain'); + expect(problem.getSolution()).to.not.match(/dashmate restart/); + }); + }); + it('should say that updates still deliver images', () => { const [problem] = analyseInstalled({ status: 'INVALID', 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 d527a92a04d..dc1b763bd3d 100644 --- a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js @@ -1,4 +1,5 @@ import fs from 'fs'; +import os from 'os'; import path from 'path'; import tls from 'node:tls'; import { Listr } from 'listr2'; @@ -177,49 +178,65 @@ describe('collectSamplesTaskFactory', () => { // certificate problem names the file it could not read - an absolute path // under the operator's home directory. Every neighbouring certificate branch // masks the username before storing; this one has to as well. - it('should not put the operator\'s username in a shared archive', async function it() { - const leakyPath = `/Users/${process.env.USER}/.dashmate/base/platform/gateway/ssl/bundle.crt`; - - const task = 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({}), - () => ({ - status: 'INVALID', - reasons: [{ - code: 'BUNDLE_MISSING', - message: `dashmate could not find the certificate bundle at ${leakyPath}`, - }], - warnings: [], - skipped: [], - provider: 'zerossl', - installed: null, - expiresInDays: null, - }), - ); - - getCertificate.resolves(new Certificate({ - id: 'certificate-id', - common_name: EXTERNAL_IP, - status: 'issued', - created: toZeroSslDate(daysFromNow(-1)), - expires: toZeroSslDate(daysFromNow(89)), - })); - - await new Listr([{ task: () => task(config) }], { renderer: 'silent' }).run({ samples }); - - const serialised = JSON.stringify(samples.getServiceInfo('gateway', 'installedCertificate')); - - // The path is still there - it is what makes the problem actionable - but - // the name in it is not. - expect(serialised).to.contain('bundle.crt'); - expect(serialised).to.not.contain(process.env.USER); + // Read from the operating system rather than the environment, because the + // point of the test is that masking still happens when the environment does + // not say who is running. + const operator = os.userInfo().username; + + [ + ['with USER set', operator], + ['with USER unset', undefined], + ].forEach(([name, userValue]) => { + it(`should not put the operator's username in a shared archive, ${name}`, async function it() { + if (userValue === undefined) { + delete process.env.USER; + } else { + process.env.USER = userValue; + } + + const leakyPath = `/Users/${operator}/.dashmate/base/platform/gateway/ssl/bundle.crt`; + + const task = 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({}), + () => ({ + status: 'INVALID', + reasons: [{ + code: 'BUNDLE_MISSING', + message: `dashmate could not find the certificate bundle at ${leakyPath}`, + }], + warnings: [], + skipped: [], + provider: 'zerossl', + installed: null, + expiresInDays: null, + }), + ); + + getCertificate.resolves(new Certificate({ + id: 'certificate-id', + common_name: EXTERNAL_IP, + status: 'issued', + created: toZeroSslDate(daysFromNow(-1)), + expires: toZeroSslDate(daysFromNow(89)), + })); + + await new Listr([{ task: () => task(config) }], { renderer: 'silent' }).run({ samples }); + + const serialised = JSON.stringify(samples.getServiceInfo('gateway', 'installedCertificate')); + + // The path is still there - it is what makes the problem actionable - but + // the name in it is not. + expect(serialised).to.contain('bundle.crt'); + expect(serialised).to.not.contain(operator); + }); }); it('should collect the certificate the gateway actually serves', async () => { diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js index d5c687aeff9..36055ce2c36 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js @@ -483,6 +483,24 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { expect(enquirer.options[1].message).to.contain('[attempt 3 of 3]'); }); + // lego fails for reasons that have nothing to do with the firewall - a rate + // limit, an account problem, a bad directory - and naming port 80 as the + // cause of all of them sends an operator to check something that is fine. + // Its own output says what happened; the prompt should not overrule it. + it('should not blame port 80 for every failure', async function it() { + const docker = getFailingDockerMock(this.sinon); + const enquirer = getEnquirerMock(this.sinon, false); + + const tasks = inject(buildFailingTask(this.sinon, docker)(config), enquirer); + + await expect(tasks.run({ force: true, interactive: true })).to.be.rejected(); + + const { header } = enquirer.options[0]; + + expect(header).to.contain('Timeout during connect'); + expect(header).to.not.match(/could not reach [0-9.]+ on port 80/i); + }); + it('should stop as soon as the operator declines', async function it() { const docker = getFailingDockerMock(this.sinon); const enquirer = getEnquirerMock(this.sinon, false); From 3c6bad7c194ae681ea3603a883ec0650b289d30f Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 17:12:21 +0700 Subject: [PATCH 18/63] fix(dashmate): let the preflight judge a config it would have to migrate `--check-certificate` is sold as the safe thing to run before `dashmate stop`, and on a config written by the previous dashmate it did not run at all - it exited 1 with a message that deliberately named no command. That is every node's first upgrade, which is the one run the preflight exists for: the operator has not stopped anything yet and is checking whether it is safe to. Refusing was right for the reason it was introduced. Reading a config runs the migrations, and two of them relocate TLS material and delete the originals - work a command that promises to change nothing must not do, least of all without the lock. What was wrong was the scope: 2 of 77 migrations touch the filesystem, both from before 1.0, and the rest only reshape the configuration object. Applying those in memory and throwing them away changes nothing at all. So the refusal now keys on whether a filesystem-mutating migration falls between the recorded version and this build's, rather than on a migration being due. A 4.1 config migrates in memory, the certificate is judged, no lock is taken and nothing is written; a pre-1.0 config still declines, because there the refusal is the honest answer. The list of which migrations touch the disk is the load-bearing part, and a list nobody maintains is worse than none. It is checked against the migrations themselves: a test scans each migration body for filesystem calls and fails if one is not declared. Verified by adding an undeclared `fs.rmSync` to the 4.2.0 migration - the test names it - and removing it again. Verified against a real dashmate home stamped back to 4.1.0: the preflight ran, reported the certificate, exited on the verdict, and left config.json byte-identical with the recorded version untouched and no lock file created. Tests: 2 new, red before this commit - a 4.1.0 config was refused instead of judged. The pre-1.0 refusal, the missing/malformed cases and the no-migration-runs guarantee all still hold. 564 unit passing, 0 lint errors, Pebble 13 passing. Co-Authored-By: Claude Opus 5 --- .../configs/getConfigFileMigrationsFactory.js | 13 +++++ .../configFile/ConfigFileJsonRepository.js | 32 +++++++++-- .../ConfigFileJsonRepository.spec.js | 57 +++++++++++++++++++ .../migrateConfigFileFactory.spec.js | 25 ++++++++ 4 files changed, 121 insertions(+), 6 deletions(-) diff --git a/packages/dashmate/configs/getConfigFileMigrationsFactory.js b/packages/dashmate/configs/getConfigFileMigrationsFactory.js index c768f85e67f..e6deeb3197b 100644 --- a/packages/dashmate/configs/getConfigFileMigrationsFactory.js +++ b/packages/dashmate/configs/getConfigFileMigrationsFactory.js @@ -16,6 +16,19 @@ import { stockImagePattern, historicalStockImagePattern } from '../src/config/st * @param {DefaultConfigs} defaultConfigs * @returns {getConfigFileMigrations} */ +/** + * Migrations that move, copy or delete files, as opposed to reshaping data. + * + * Almost every migration only rewrites the configuration object, which can be + * applied in memory and discarded. These two relocate TLS material and remove + * the originals, so a caller that has promised to change nothing cannot run + * them and has to decline instead. + * + * A migration added here must be added to this set, and a test fails if one + * touches the filesystem without being declared. + */ +export const FILESYSTEM_MUTATING_MIGRATIONS = ['0.25.7', '1.0.0-dev.12']; + export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) { /** * @typedef {function} getConfigFileMigrations diff --git a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js index 148953fb749..53bd184c150 100644 --- a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js +++ b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js @@ -5,6 +5,7 @@ import { randomUUID } from 'crypto'; import lockfile from 'proper-lockfile'; import semver from 'semver'; import writeFileAtomic from 'write-file-atomic'; +import { FILESYSTEM_MUTATING_MIGRATIONS } from '../../../configs/getConfigFileMigrationsFactory.js'; import Config from '../Config.js'; import { PACKAGE_ROOT_DIR } from '../../constants.js'; import ConfigFileMigrationRequiredError from '../errors/ConfigFileMigrationRequiredError.js'; @@ -256,12 +257,17 @@ export default class ConfigFileJsonRepository { // and it stops a stop-first upgrade before the node goes down rather than // after. // + // Only the migrations that touch the filesystem are refused. The rest + // reshape the configuration object, which can be applied in memory and + // thrown away - and has to be, because the first run after an upgrade is + // both the run where a migration is due and the run this mode exists for. + // // Answering a different question from the one below. "Take the lock" is // safe to answer yes to whenever the state cannot be read, but "tell the // operator an older dashmate wrote this" has to be true - a file that is // missing or damaged must report itself as missing or damaged, and one of // those errors is what first-run setup catches to create defaults. - if (options.readOnly === true && this.#isRecordedVersionBehind()) { + if (options.readOnly === true && this.#hasFilesystemMigrationDue()) { throw new ConfigFileMigrationRequiredError(this.configFilePath); } @@ -308,11 +314,23 @@ export default class ConfigFileJsonRepository { * * @returns {boolean} */ - #isRecordedVersionBehind() { - if (typeof this.configFormatVersion !== 'string') { + #hasFilesystemMigrationDue() { + const recordedVersion = this.#recordedVersion(); + + if (recordedVersion === null || typeof this.configFormatVersion !== 'string') { return false; } + return FILESYSTEM_MUTATING_MIGRATIONS.some((version) => semver.gt(version, recordedVersion) + && semver.lte(version, this.configFormatVersion)); + } + + /** + * The format version the file records, or null when it cannot be read. + * + * @returns {string|null} + */ + #recordedVersion() { let recordedVersion; try { @@ -320,14 +338,16 @@ export default class ConfigFileJsonRepository { fs.readFileSync(this.configFilePath, 'utf8'), ).configFormatVersion; } catch { - return false; + // An unreadable or malformed file is read()'s to report, with the error + // that names the file and the reason. + return null; } if (typeof recordedVersion !== 'string' || semver.valid(recordedVersion) === null) { - return false; + return null; } - return semver.lt(recordedVersion, this.configFormatVersion); + return recordedVersion; } /** diff --git a/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js b/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js index eda32f5a81c..5910241ccf9 100644 --- a/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js +++ b/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js @@ -373,6 +373,63 @@ describe('ConfigFileJsonRepository', () => { }); }); + // The run this mode exists for is the first one after an upgrade, which is + // exactly when a migration is due. Refusing there fails the operator at the + // moment they were told the command was safe - before they stop a healthy + // node. Almost every migration only reshapes data, so it can be applied in + // memory and thrown away. + it('should migrate in memory when no migration touches the disk', async () => { + const container = await createDIContainer(); + container.resolve('homeDir').change(homeDir); + + const configFormatVersion = container.resolve('configFormatVersion'); + + const seeded = JSON.parse(seedConfigFile()); + seeded.configFormatVersion = '4.1.0'; + fs.writeFileSync(configFilePath, JSON.stringify(seeded, undefined, 2), 'utf8'); + + const before = fs.readFileSync(configFilePath, 'utf8'); + + const repository = new ConfigFileJsonRepository( + container.resolve('migrateConfigFile'), + homeDir, + createDefaults, + configFormatVersion, + ); + + let rendered = false; + const { configFile } = repository.readAndMigrate( + { readOnly: true }, + () => { rendered = true; }, + ); + + // The caller gets current data to judge, and the disk is untouched. + expect(configFile.getConfigFormatVersion()).to.equal(configFormatVersion); + expect(rendered).to.be.false(); + expect(fs.readFileSync(configFilePath, 'utf8')).to.equal(before); + expect(fs.existsSync(homeDir.joinPath('.config.json.lock'))).to.be.false(); + }); + + // The two migrations that move and delete TLS material are the only reason + // this mode ever refuses, so it has to refuse when one of them is in range. + it('should still refuse when a migration in range touches the disk', async () => { + const container = await createDIContainer(); + container.resolve('homeDir').change(homeDir); + + const legacy = getConfigFileDataV0250(); + fs.writeFileSync(configFilePath, JSON.stringify(legacy, undefined, 2), 'utf8'); + + const repository = new ConfigFileJsonRepository( + container.resolve('migrateConfigFile'), + homeDir, + createDefaults, + container.resolve('configFormatVersion'), + ); + + expect(() => repository.readAndMigrate({ readOnly: true })) + .to.throw(ConfigFileMigrationRequiredError); + }); + // The common case, and the one that has to stay fast: nothing to migrate, // so nothing to refuse and no lock to take. it('should read without locking for a caller that changes nothing', () => { diff --git a/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js index 020b2454f1a..1f43125e04c 100644 --- a/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js +++ b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js @@ -5,6 +5,7 @@ import HomeDir from '../../../../src/config/HomeDir.js'; import { PACKAGE_ROOT_DIR } from '../../../../src/constants.js'; import createDIContainer from '../../../../src/createDIContainer.js'; import getConfigFileDataV0250 from '../../../../src/test/fixtures/getConfigFileDataV0250.js'; +import { FILESYSTEM_MUTATING_MIGRATIONS } from '../../../../configs/getConfigFileMigrationsFactory.js'; describe('migrateConfigFileFactory', () => { let mockConfigFileData; @@ -23,6 +24,30 @@ describe('migrateConfigFileFactory', () => { mockConfigFileData = getConfigFileDataV0250(); }); + // The read-only preflight applies migrations in memory and discards them, so + // it may only refuse when one of them would touch the disk. That decision is + // driven by a declared list, and a list nobody maintains is worse than none - + // so the list is checked against the migrations themselves. + it('should declare every migration that touches the filesystem', () => { + const source = fs.readFileSync( + path.join(PACKAGE_ROOT_DIR, 'configs', 'getConfigFileMigrationsFactory.js'), + 'utf8', + ); + + const keys = [...source.matchAll(/^ {6}'([^']+)': \(configFile\)/gm)]; + + expect(keys).to.have.length.greaterThan(50); + + const touchesFilesystem = keys.filter(({ index }, position) => { + const end = position + 1 < keys.length ? keys[position + 1].index : source.length; + + return /\bfs\.\w+/.test(source.slice(index, end)); + }).map(([, version]) => version); + + expect(touchesFilesystem.sort()) + .to.deep.equal([...FILESYSTEM_MUTATING_MIGRATIONS].sort()); + }); + // lego keys its on-disk ACME account directory by the contact address, so // that string decides which account a renewal runs under. A migration that // nulled, normalised or removed it would silently register a brand new From 4ab84e4f91be62ed310aa1f74ad2e89e4fb85591 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 17:12:42 +0700 Subject: [PATCH 19/63] fix(dashmate): tell a port conflict apart from a firewall, and say port 80 is permanent Three defects on the obtain path, found by running it on a real node. A PORT CONFLICT WAS REPORTED AS A FIREWALL PROBLEM. With another process holding port 80, Docker refuses the port binding and lego never starts - no request reaches the certificate authority. The operator was told to fix inbound port 80, which is reachable and merely occupied; that retrying spends a renewal budget shared with the helper, when nothing was spent; and that the address might be PAUSED and need the authority's self-service portal, over a local port conflict. Only the raw Docker string, printed between two copies of the firewall advice, carried the truth. The branch is on the request never having been attempted, not on what the authority said - container creation and start are wrapped, and a failure there means nothing was validated and no budget was spent, whatever the cause. Classifying provider output is exactly what this design refuses to do; this needs no classification. The message shows Docker's own error, says plainly that no request was made, and points at the port being occupied rather than blocked, with a command to find what holds it. PORT 80's PERMANENCE WAS NEVER STATED BY THIS COMMAND. Zero occurrences of "permanent", "stay open" or "survive a reboot" on either the failure or the success path - and this is the exact command the certificate check's own remediation tells the operator to run. Someone who opens port 80 for one migration, runs it, succeeds and closes the port again was told nothing, which is the six-day dark-node failure the whole feature exists to prevent, reached through the feature's own advice. Both paths carry it now; success only when something was actually issued, so a cron renewal stays quiet. THE CERTIFICATE AUTHORITY WAS NEVER NAMED BEFORE THE REQUEST. The directory appeared only inside lego's output, after the fact, so a node pointed at production when staging was meant could not be told apart until an authorization had been spent - which is precisely how one was spent during testing. It is now printed before the request, and marked when it is not the production directory. Tests: 3 new, red before this commit - a bind refusal produced the firewall and rate-limit guidance, and a successful obtain said nothing about permanence. A companion test pins that a failure the authority did return keeps the rate-limit guidance, so the branch cannot swallow both. Co-Authored-By: Claude Opus 5 --- packages/dashmate/src/commands/ssl/obtain.js | 29 ++++-- ...obtainLetsEncryptCertificateTaskFactory.js | 96 ++++++++++++++++++- .../src/ssl/errors/LegoDidNotStartError.js | 23 +++++ .../test/unit/commands/ssl/obtain.spec.js | 36 +++++++ ...nLetsEncryptCertificateTaskFactory.spec.js | 49 ++++++++++ 5 files changed, 220 insertions(+), 13 deletions(-) create mode 100644 packages/dashmate/src/ssl/errors/LegoDidNotStartError.js diff --git a/packages/dashmate/src/commands/ssl/obtain.js b/packages/dashmate/src/commands/ssl/obtain.js index db2fa7f0c9f..2b9005cdf46 100644 --- a/packages/dashmate/src/commands/ssl/obtain.js +++ b/packages/dashmate/src/commands/ssl/obtain.js @@ -2,6 +2,7 @@ import { Listr } from 'listr2'; import { Flags } from '@oclif/core'; import ServiceIsNotRunningError from '../../docker/errors/ServiceIsNotRunningError.js'; import ConfigBaseCommand from '../../oclif/command/ConfigBaseCommand.js'; +import { PORT_80_PERMANENCE } from '../../listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js'; import isInteractiveSession from '../../util/isInteractiveSession.js'; import MuteOneLineError from '../../oclif/errors/MuteOneLineError.js'; import Certificate from '../../ssl/zerossl/Certificate.js'; @@ -147,18 +148,28 @@ Certificate will be renewed if it is about to expire (see 'expiration-days' flag }, ); + const context = { + noRetry, + force, + expirationDays, + // Whether the obtain may ask a question is decided here rather than + // inside the shared task, so a caller that never opts in - the helper's + // unattended renewal - cannot enable prompting by omission. + interactive: isInteractiveSession({ flags }), + }; + try { - await tasks.run({ - noRetry, - force, - expirationDays, - // Whether the obtain may ask a question is decided here rather than - // inside the shared task, so a caller that never opts in - the helper's - // unattended renewal - cannot enable prompting by omission. - interactive: isInteractiveSession({ flags }), - }); + await tasks.run(context); } catch (e) { throw new MuteOneLineError(e); } + + // Only when something was actually issued. This is the command the + // certificate check tells an operator to run, and an operator who opened + // port 80 for this one migration is the one who most needs to hear that it + // has to stay open - they never saw a failure that would have said so. + if (context.certificateObtained && provider === SSL_PROVIDERS.LETSENCRYPT) { + process.stderr.write(`\n${PORT_80_PERMANENCE}\n`); + } } } diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index a73630c81cd..b59cdc1214f 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -5,6 +5,8 @@ import os from 'os'; import { ERRORS } from '../../../../ssl/letsencrypt/validateLetsEncryptCertificateFactory.js'; import LegoCertificate from '../../../../ssl/letsencrypt/LegoCertificate.js'; +import { LETSENCRYPT_ACME_DIRECTORY_URL } from '../../../../constants.js'; +import LegoDidNotStartError from '../../../../ssl/errors/LegoDidNotStartError.js'; import promptOrThrow from '../../../../util/promptOrThrow.js'; import renderConfigFlag from '../../../../util/renderConfigFlag.js'; @@ -19,6 +21,23 @@ const LEGO_IMAGE = 'goacme/lego:v4.31.0'; */ const MAX_OBTAIN_ATTEMPTS = 3; +/** + * Port 80 is a standing requirement, not a step. + * + * An IP-address certificate lasts about six days and every renewal performs a + * fresh challenge, so a rule opened once for a migration and closed afterwards + * - or one that does not survive a reboot - takes the node dark within a week, + * and nothing reports it. The operator who has just succeeded is the one least + * likely to hear this otherwise, because they never saw a failure. + */ +export const PORT_80_PERMANENCE = `LEAVE PORT 80 OPEN. This is not a one-time requirement. Certificates for +IP addresses last about six days, and dashmate keeps renewing this one for as +long as the node runs - every renewal needs inbound port 80 again. + +If you opened port 80 just to make this work, make the rule permanent and make +sure it survives a reboot. If it lapses, this node goes dark within six days +and nothing will tell you.`; + /** * What to tell an operator who has run out of attempts. * @@ -43,7 +62,35 @@ Let's Encrypt's Self-Service Portal to unpause it: https://letsencrypt.org/docs/rate-limits/ Fix inbound port 80 first, then: ` - + `dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider letsencrypt`; + + `dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider letsencrypt` + + `\n\n${PORT_80_PERMANENCE}`; +} + +/** + * What to tell an operator whose helper never started. + * + * Nothing reached the certificate authority, so none of the authority-side + * consequences apply: nothing was validated, no issuance budget was spent, and + * no address can have been paused. Saying otherwise would send them to a + * rate-limit portal over a local port conflict. + * + * @param {Config} config + * @param {Error} cause + * @return {string} + */ +function renderHelperDidNotStartGuidance(config, cause) { + return `dashmate could not start the certificate helper, so no request was +made to Let's Encrypt. Nothing was issued, nothing was validated, and no +rate limit was spent. + +${cause.message} + +The usual cause is that something is already listening on port 80 here. The +port being reachable is not the problem - it is occupied. Find what holds it, +stop that, then retry: + + sudo ss -lntp 'sport = :80' + dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider letsencrypt`; } const LEGO_CA_CERTIFICATE_MOUNT_PATH = '/acme-ca.pem'; @@ -72,6 +119,29 @@ export default function obtainLetsEncryptCertificateTaskFactory( legoCaCertificatePath, legoContainerOptions, ) { + /** + * Create and start the lego container, reporting a failure to do either as + * distinct from a failure the certificate authority returned. + * + * Nothing here has spoken to the authority yet, so a failure means no + * validation was attempted and no issuance budget was spent. + * + * @param {Object} options - Docker create-container options + * @return {Promise} the started container + */ + async function startLegoContainer(options) { + let container; + + try { + container = await docker.createContainer(options); + await container.start(); + } catch (e) { + throw new LegoDidNotStartError(e); + } + + return container; + } + /** * @typedef {obtainLetsEncryptCertificateTask} * @param {Config} config @@ -199,6 +269,17 @@ export default function obtainLetsEncryptCertificateTaskFactory( throw new Error('ACME directory URL must use HTTPS'); } + // Named before the request rather than after it. Until now the + // directory only appeared inside lego's own output, so a node + // pointed at staging - or at production when staging was meant - + // could not be told apart until an authorization had been spent. + const isProductionDirectory = acmeDirectoryUrl.toString() + === LETSENCRYPT_ACME_DIRECTORY_URL; + + // eslint-disable-next-line no-param-reassign + task.output = `Certificate authority: ${acmeDirectoryUrl.toString()}` + + `${isProductionDirectory ? '' : ' (NOT the production directory)'}`; + // Determine if this is initial run or renewal const command = ctx.isRenewal ? 'renew' : 'run'; @@ -264,7 +345,9 @@ export default function obtainLetsEncryptCertificateTaskFactory( env.push(`LEGO_CA_CERTIFICATES=${LEGO_CA_CERTIFICATE_MOUNT_PATH}`); } - const container = await docker.createContainer({ + // From here to the container running, any failure means the helper + // never ran and nothing reached the authority. + const container = await startLegoContainer({ name: containerName, Image: LEGO_IMAGE, Cmd: legoArgs, @@ -285,8 +368,6 @@ export default function obtainLetsEncryptCertificateTaskFactory( // eslint-disable-next-line no-param-reassign task.output = `Running lego ${command}...`; - await container.start(); - // Wait for container to finish const result = await container.wait(); @@ -324,6 +405,12 @@ export default function obtainLetsEncryptCertificateTaskFactory( break; } catch (e) { + // The helper never ran, so there is nothing the authority could + // tell us and nothing to retry against - the fix is local. + if (e instanceof LegoDidNotStartError) { + throw new Error(renderHelperDidNotStartGuidance(config, e.cause)); + } + // Prompting needs a positive opt-in from the entry point. The // helper renews inside a container with no terminal, where a // prompt would never settle and would hold the config lock - @@ -359,6 +446,7 @@ export default function obtainLetsEncryptCertificateTaskFactory( } ctx.configurationUpdateRequired = true; + ctx.certificateObtained = true; // eslint-disable-next-line no-param-reassign task.output = 'Certificate obtained successfully'; diff --git a/packages/dashmate/src/ssl/errors/LegoDidNotStartError.js b/packages/dashmate/src/ssl/errors/LegoDidNotStartError.js new file mode 100644 index 00000000000..08be6268d2b --- /dev/null +++ b/packages/dashmate/src/ssl/errors/LegoDidNotStartError.js @@ -0,0 +1,23 @@ +import AbstractError from '../../errors/AbstractError.js'; + +/** + * The certificate helper could not be started, so no request ever reached the + * certificate authority. + * + * Worth distinguishing from a request the authority refused, because the two + * have nothing in common: nothing was issued, nothing was validated, no rate + * limit was spent and no address can have been paused. The most common cause is + * another process already holding port 80, which is the opposite of the + * firewall problem a failed validation usually means - the port is reachable, + * it is occupied. + */ +export default class LegoDidNotStartError extends AbstractError { + /** + * @param {Error} cause - what Docker reported + */ + constructor(cause) { + super(`The certificate helper could not be started: ${cause.message}`); + + this.cause = cause; + } +} diff --git a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js index dd30cf45a08..92281f87147 100644 --- a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js +++ b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js @@ -3,6 +3,16 @@ import ObtainCommand from '../../../../src/commands/ssl/obtain.js'; import ServiceIsNotRunningError from '../../../../src/docker/errors/ServiceIsNotRunningError.js'; describe('SSL obtain command', () => { + let stderr; + + beforeEach(function beforeEach() { + stderr = ''; + this.sinon.stub(process.stderr, 'write').callsFake((chunk) => { + stderr += chunk; + return true; + }); + }); + /** * @param {Object} sinon * @param {string} [provider] @@ -169,6 +179,32 @@ describe('SSL obtain command', () => { expect(configFileRepository.write).to.have.been.calledOnceWith(configFile); }); + // The gate's own remediation tells the operator to run this command, and + // §the permanence requirement is the whole reason the eight dark nodes went + // dark. An operator who opens port 80 for one migration, runs this, succeeds + // and closes the port again must not be able to do so silently. + it('should state that port 80 has to stay open after obtaining', async function it() { + const dependencies = obtainDependencies(this.sinon); + dependencies.obtainTask = this.sinon.stub().callsFake(() => new Listr([{ + task: (ctx) => { ctx.certificateObtained = true; }, + }])); + + await runObtain(dependencies); + + expect(stderr).to.contain('LEAVE PORT 80 OPEN'); + expect(stderr).to.contain('survives a reboot'); + }); + + // Nothing was issued, so there is nothing to warn about and a cron run stays + // quiet. + it('should stay silent when no new certificate was obtained', async function it() { + const dependencies = obtainDependencies(this.sinon); + + await runObtain(dependencies); + + expect(stderr).to.not.contain('LEAVE PORT 80 OPEN'); + }); + // The retry loop lives in the shared obtain task, so `ssl obtain` gains it // too. Its --no-retry defaults to false, which would turn an obtain run from // cron into a hang if the flag were what decided whether to prompt. diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js index 36055ce2c36..c8cdc5c12e3 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js @@ -483,6 +483,55 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { expect(enquirer.options[1].message).to.contain('[attempt 3 of 3]'); }); + // When another process holds port 80, Docker refuses the port binding and + // lego never starts: no ACME request is made, so nothing is rate-limited, + // nothing can be paused, and the firewall is not the problem - the port is + // reachable, it is occupied. Branching on the request never having been + // attempted, rather than on what the authority said, is what keeps this + // out of the business of classifying provider output. + it('should not blame the firewall or the authority when the helper never started', async function it() { + const missing = Object.assign(new Error('container not found'), { statusCode: 404 }); + const bindRefused = Object.assign( + new Error('(HTTP code 500) server error - failed to set up container networking:' + + ' failed to bind host port 0.0.0.0:80/tcp: address already in use'), + { statusCode: 500 }, + ); + const docker = { + getContainer: this.sinon.stub().rejects(missing), + createContainer: this.sinon.stub().resolves({ + start: this.sinon.stub().rejects(bindRefused), + logs: this.sinon.stub().resolves(Buffer.from('')), + wait: this.sinon.stub().resolves({ StatusCode: 0 }), + }), + }; + + const error = await buildFailingTask(this.sinon, docker)(config) + .run({ force: true }).catch((e) => e); + + // The Docker error is shown, because it is the only thing that says what + // actually happened. + expect(error.message).to.contain('address already in use'); + expect(error.message).to.contain('already listening on port 80'); + + // And none of the authority-side consequences are claimed. + expect(error.message).to.not.contain('PAUSED'); + expect(error.message).to.not.contain('rate-limit'); + expect(error.message).to.not.contain('failed attempts are shared'); + expect(error.message).to.not.contain('Fix inbound port 80 first'); + }); + + // A failure the authority did return keeps the guidance that is about the + // authority. + it('should keep the rate-limit guidance when the request did reach the authority', async function it() { + const docker = getFailingDockerMock(this.sinon); + + const error = await buildFailingTask(this.sinon, docker)(config) + .run({ force: true }).catch((e) => e); + + expect(error.message).to.contain('PAUSED'); + expect(error.message).to.contain('failed attempts are shared'); + }); + // lego fails for reasons that have nothing to do with the firewall - a rate // limit, an account problem, a bad directory - and naming port 80 as the // cause of all of them sends an operator to check something that is fine. From ce2b710226302f85853dc067739c0b0652af9582 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 17:13:03 +0700 Subject: [PATCH 20/63] fix(dashmate): stop offering a switch to the provider the node already uses A node already on Let's Encrypt was told, three paragraphs apart, that there is no provider to switch to and then that THE FIX is to switch to Let's Encrypt. Eight mainnet nodes are in exactly this state today, and that is the message they would have received. The heading is now chosen by provider. The commands underneath were already right for this path - obtaining again is the correct next step - so only the framing changes. Test: red before this commit. Co-Authored-By: Claude Opus 5 --- .../src/ssl/renderCertificateGuidance.js | 14 +++++++--- .../ssl/renderCertificateGuidance.spec.js | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index 5e4f7a5def6..623ed540f1a 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -146,8 +146,16 @@ function renderLetsEncryptDiagnosis(cfg) { * @param {boolean} isNodeRunning * @return {string} */ -function renderFix(cfg, isNodeRunning) { - return ` THE FIX - switch to Let's Encrypt, which issues IP-address certificates free. +function renderFix(cfg, isNodeRunning, isAlreadyLetsEncrypt) { + // A node already on Let's Encrypt has nothing to switch to - it is the only + // authority that issues IP-address certificates over ACME - so the heading + // that offers a switch would contradict the diagnosis printed above it. The + // commands are the same either way. + const heading = isAlreadyLetsEncrypt + ? ` THE FIX - obtain a new certificate from Let's Encrypt.` + : ` THE FIX - switch to Let's Encrypt, which issues IP-address certificates free.`; + + return `${heading} Let's Encrypt proves this node owns its IP by connecting to it on inbound port 80. Check that first; it limits how often you may fail, so a blind @@ -232,7 +240,7 @@ export default function renderCertificateGuidance({ blocks.push(renderLetsEncryptDiagnosis(cfg)); } - blocks.push(renderFix(cfg, isNodeRunning)); + blocks.push(renderFix(cfg, isNodeRunning, provider === SSL_PROVIDERS.LETSENCRYPT)); blocks.push(renderPortEightyPermanence()); blocks.push(` IF YOU CANNOT OPEN PORT 80. dashmate currently has no supported alternative diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index ff3f2a91ae3..ebcdc1d4e03 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -185,6 +185,32 @@ describe('renderCertificateGuidance', () => { expect(output).to.contain('not publicly trusted'); }); + // Eight mainnet nodes are in this state. Telling them to switch to the + // provider they are already on is the message they would get. + it('should not offer a switch to a node already on that provider', () => { + config.set('platform.gateway.ssl.provider', 'letsencrypt'); + + const output = render({ verdict: verdict({ provider: 'letsencrypt' }) }); + + expect(output).to.contain('already configured for Let\'s Encrypt'); + expect(output).to.contain('THE FIX - obtain a new certificate'); + expect(output).to.not.contain('THE FIX - switch to'); + + // The remediation itself is still the right next step and stays. + expect(output).to.contain('dashmate ssl obtain --config base --provider letsencrypt'); + }); + + // Saying the same thing three times in one message is how an operator learns + // to skim past it. + it('should state the port 80 argument once', () => { + const output = render({ verdict: verdict({ provider: 'letsencrypt' }) }); + + const occurrences = (needle) => output.split(needle).length - 1; + + expect(occurrences('issued certificates on the same day')).to.equal(1); + expect(occurrences('PORT 80 MUST STAY OPEN PERMANENTLY')).to.equal(1); + }); + // A silent drop of an external probe is no information at all: 52 nodes that // dropped the same probe hold Let's Encrypt certificates issued within four // days, which is only possible over port 80. Asserting the port is blocked From 7ec07242df21d8d055a16cd78f426059021399e4 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 17:13:03 +0700 Subject: [PATCH 21/63] fix(dashmate): stop doctor advising a restart that would take a node dark DOCTOR ASSERTED WHICH CERTIFICATE WAS OLDER WITHOUT COMPARING THEM. The branch fired on the served and on-disk certificates merely differing, and then claimed a direction and acted on it. Both directions were observed on one real node. In the wrong one, doctor reported that the installed certificate expired 158 days ago and, in the same output, told the operator to restart Platform so the gateway would pick up the disk copy - which would replace a valid served certificate with an expired one and take a working node dark. That is the outcome this feature exists to prevent, produced by its own remediation, and it is reachable from a restored bundle backup or a half-written save. The two are compared now. Only when the disk copy outlives the served one is a restart advised; otherwise the problem says the disk copy is not the newer of the two and points at obtaining a current certificate, which installs and signals without loading the stale file. When there is nothing to compare against, no direction is claimed. THE PORT-80 CLAIM MEASURED SOMETHING ELSE. `Inbound port 80 is not reachable` came from a connect test, which measures whether something is listening - and nothing listens on port 80 on a healthy node except for the seconds a renewal takes. It reported closed on a node whose port 80 answered a direct probe with a refusal, proving the SYN arrived, and which had renewed successfully through that port four days earlier. The mitigation of only printing it alongside another certificate problem made it worse: suppressed where it would look obviously wrong, and shown to every operator who already has a certificate problem and is least able to tell a real firewall from a phantom one. It sends them to rewrite rules that are already correct. The project's own census established that a drop carries no information and only an answer or a refusal proves anything, so the claim is deleted rather than reworded - a hint that has to explain it means nothing is not worth printing. Tests: 3 new for the comparison, red before this commit. The test that pinned the port-80 claim is removed with the claim, and the renewed-certificate case now supplies the newer disk copy its name implies rather than relying on the unchecked branch. Co-Authored-By: Claude Opus 5 --- .../analyseGatewayCertificateFactory.js | 60 +++++++++------ .../analyseGatewayCertificateFactory.spec.js | 74 ++++++++++++++++--- 2 files changed, 98 insertions(+), 36 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index ba5ebd618c4..2c5fe37cfce 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -142,15 +142,35 @@ ${restartHint(cfg)}`, 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. + // Which of the two is newer decides both the description and whether a + // restart is safe to advise. A restart makes the gateway load the disk + // copy, so advising one without checking can replace a valid served + // certificate with an expired one and take a working node dark. + const onDiskExpiresAt = served.onDisk + ? new Date(served.onDisk.validTo).getTime() + : null; + + if (onDiskExpiresAt !== null && onDiskExpiresAt > servedExpiresAt) { + // 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 ${cfg} --platform}`, - SEVERITY.HIGH, - )); + SEVERITY.HIGH, + )); + } else { + problems.push(new Problem( + 'The gateway is serving a different certificate from the one on disk, and the one ' + + 'on disk is not the newer of the two', + chalk`Whatever is on the wire is currently the better of the two, so do not restart +Platform to load the file - that would replace it with the older one. Obtain a +current certificate first, which also installs it and signals the gateway: +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, + SEVERITY.HIGH, + )); + } } // Reported separately from expiry because the connection surfaces only its first @@ -167,22 +187,14 @@ ${restartHint(cfg)}`, )); } - // 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 && 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', - 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, - )); - } + // Nothing is said about inbound port 80 here on purpose. The sample comes + // from a connect test, which measures whether something is listening - and + // nothing listens on port 80 on a healthy node except for the seconds a + // renewal takes, so it reports closed on healthy nodes by construction. + // Reporting it alongside certificate problems put the claim in front of + // exactly the operators least able to tell a real firewall problem from a + // phantom one, and sent them to rewrite rules that were already correct. + // A drop carries no information; only an answer or a refusal does. return problems; } diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index 517e45f6582..2bd4b3eae94 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -85,7 +85,12 @@ describe('analyseGatewayCertificateFactory', () => { }); it('should warn before the outage when a renewed certificate has not been picked up', () => { - const problems = analyse(served({ matchesOnDisk: false })); + // Renewed means the disk copy outlives the served one; that is what makes + // a restart the right advice here. + const problems = analyse(served({ + matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD', validTo: validTo(60) }, + })); expect(problems).to.have.lengthOf(1); expect(problems[0].getDescription()).to.include('older certificate than the one on disk'); @@ -128,17 +133,6 @@ describe('analyseGatewayCertificateFactory', () => { expect(problems).to.be.empty(); }); - it('should report a closed port 80 as a likely cause when a certificate problem exists', () => { - samples.setServiceInfo('gateway', 'validationHttpPort', '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. @@ -165,6 +159,62 @@ describe('analyseGatewayCertificateFactory', () => { expect(analyse(served({ certificate: { validTo: validTo(-100) } }))).to.be.empty(); }); + // The branch fires on the two certificates merely differing. Asserting a + // direction without comparing them, and then advising a restart on that + // basis, swaps a valid served certificate for an expired one and takes a + // working node dark - which is the outcome this whole feature exists to + // prevent, produced by its own remediation. + describe('when the served and on-disk certificates differ', () => { + it('should advise a restart only when the disk copy is the newer one', () => { + const problems = analyse(served({ + matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD', validTo: validTo(60) }, + })); + + const [problem] = problems.filter((p) => p.getDescription().includes('on disk')); + + expect(problem.getDescription()).to.include('older certificate than the one on disk'); + expect(problem.getSolution()).to.include('dashmate restart'); + }); + + it('should not advise a restart when the disk copy is the stale one', () => { + const problems = analyse(served({ + matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD', validTo: validTo(-158) }, + })); + + const [problem] = problems.filter((p) => p.getDescription().includes('disk')); + + expect(problem.getDescription()).to.not.include('older certificate than the one on disk'); + expect(problem.getSolution()).to.not.match(/dashmate restart/); + }); + + it('should claim no direction when it cannot compare them', () => { + const problems = analyse(served({ matchesOnDisk: false, onDisk: null })); + + const [problem] = problems.filter((p) => p.getDescription().includes('disk')); + + expect(problem.getDescription()).to.not.include('older certificate than the one on disk'); + expect(problem.getSolution()).to.not.match(/dashmate restart/); + }); + }); + + // An external connect test measures whether something is listening, and + // nothing listens on port 80 on a healthy node except for the seconds a + // renewal takes. So it reports CLOSED on healthy nodes by construction, and + // attaching it to every certificate problem sends operators to rewrite + // firewall rules that are already correct. + it('should not claim port 80 is unreachable from a listener probe', () => { + samples.setServiceInfo('gateway', 'validationHttpPort', 'CLOSED'); + + const problems = analyse(served({ certificate: { fingerprint256: 'AA:BB', validTo: validTo(-1) } })); + + expect(problems).to.have.length.greaterThan(0); + problems.forEach((problem) => { + expect(problem.getDescription()).to.not.match(/port 80 is not reachable/i); + }); + }); + describe('the certificate on disk', () => { /** * @param {Object} installed From 51508ec379019106ddbb0e47a666414231323153 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 17:13:20 +0700 Subject: [PATCH 22/63] fix(dashmate): stop redaction blanking dashmate out of its own messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a package-installed node the service account is called `dashmate`, so the username and the product name are the same string. Substring replacement turned every occurrence into asterisks: ******** could not find the certificate bundle at /home/********/.********-ssltest/…/bundle.crt The sentence lost its subject, and the path lost the one thing that made it actionable. The solution text on the same problem was untouched, because it is composed at analysis time rather than carried in a sample - so the output mixed redacted and unredacted occurrences of the same word in adjacent lines. Two changes. The name is matched as a whole word, so a short account name no longer mangles every word that happens to contain it. And the home directory - which is what actually discloses who is running dashmate - is rewritten to `~` rather than blanked, so the path stays readable and, more usefully, still resolves when pasted. That is strictly better than the masked directory the archive carried before. Words dashmate writes about itself are then left alone. When the account is called `dashmate` the token carries nothing the home path has not already removed, while replacing it destroys every sentence dashmate writes and the directories it creates. The archive requirement is unchanged and still met: no username and no absolute home path leave the machine. Extracted so it can be tested directly, including the case this branch cannot reproduce on a developer machine - an operator actually named after the product. Tests: 7 new, covering the home path, whole-word matching, a word that merely contains the name, the product-name collision, and no identity being determinable. Red before this commit for the collision case. Co-Authored-By: Claude Opus 5 --- .../tasks/doctor/collectSamplesTaskFactory.js | 38 +++++------ .../dashmate/src/util/maskOperatorIdentity.js | 63 +++++++++++++++++++ .../unit/util/maskOperatorIdentity.spec.js | 60 ++++++++++++++++++ 3 files changed, 137 insertions(+), 24 deletions(-) create mode 100644 packages/dashmate/src/util/maskOperatorIdentity.js create mode 100644 packages/dashmate/test/unit/util/maskOperatorIdentity.spec.js diff --git a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js index aed5cb54a9b..58d899b9193 100644 --- a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js @@ -11,7 +11,7 @@ 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 maskOperatorIdentity from '../../../util/maskOperatorIdentity.js'; import obfuscateObjectRecursive from '../../../util/obfuscateObjectRecursive.js'; import validateSslCertificateFiles from '../../prompts/validators/validateSslCertificateFiles.js'; @@ -57,35 +57,31 @@ async function fetchTextOrError(url) { * mask and the data is left alone rather than having "undefined" replaced in * it. * - * @return {string|null} + * @return {{username: string|null, homePath: string|null}} */ -function getOperatorName() { - try { - const { username } = os.userInfo(); +function getOperatorIdentity() { + let username = null; + let homePath = null; - if (username) { - return username; - } + try { + ({ username, homedir: homePath } = os.userInfo()); } catch { // A process running under a uid with no passwd entry has no name to read. } - return process.env.USER || process.env.USERNAME || null; + return { + username: username || process.env.USER || process.env.USERNAME || null, + homePath: homePath || os.homedir() || null, + }; } /** * @param {Object} data - mutated in place */ function obfuscateOperatorName(data) { - const username = getOperatorName(); - - if (!username) { - return; - } + const identity = getOperatorIdentity(); - obfuscateObjectRecursive(data, (_field, value) => (typeof value === 'string' - ? value.replaceAll(username, hideString(username)) - : value)); + obfuscateObjectRecursive(data, (_field, value) => maskOperatorIdentity(value, identity)); } /** @@ -93,13 +89,7 @@ function obfuscateOperatorName(data) { * @return {string|undefined} */ function hideOperatorNameIn(text) { - const username = getOperatorName(); - - if (!username || typeof text !== 'string') { - return text; - } - - return text.replaceAll(username, hideString(username)); + return maskOperatorIdentity(text, getOperatorIdentity()); } export default function collectSamplesTaskFactory( diff --git a/packages/dashmate/src/util/maskOperatorIdentity.js b/packages/dashmate/src/util/maskOperatorIdentity.js new file mode 100644 index 00000000000..6f711411a8d --- /dev/null +++ b/packages/dashmate/src/util/maskOperatorIdentity.js @@ -0,0 +1,63 @@ +import hideString from './hideString.js'; + +/** + * Words dashmate writes about itself, which must survive masking even when the + * operator's account happens to be named after one of them. + * + * On a package-installed node the service account is called `dashmate`, so the + * username and the product name are the same string. Replacing it blanks the + * subject out of every sentence dashmate writes - "******** could not find the + * certificate bundle" - and mangles the directories dashmate itself creates, + * while hiding nothing: the home directory below is what actually discloses who + * is running it, and that is removed regardless. + */ +const PRODUCT_WORDS = ['dashmate']; + +/** + * A name is only masked where it stands alone. Substring replacement turns + * every word that happens to contain it into nonsense, and the collisions are + * not rare - short account names are common. + * + * @param {string} name + * @return {RegExp} + */ +function wholeWord(name) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + return new RegExp(`(? { + // What actually discloses who is running dashmate is the home directory in + // an absolute path, not the word itself. + it('should mask the home directory while leaving the path usable', () => { + const masked = maskOperatorIdentity( + '/home/alice/.dashmate/base/platform/gateway/ssl/bundle.crt', + { username: 'alice', homePath: '/home/alice' }, + ); + + expect(masked).to.not.contain('alice'); + expect(masked).to.equal('~/.dashmate/base/platform/gateway/ssl/bundle.crt'); + }); + + it('should mask the name as a whole word elsewhere', () => { + expect(maskOperatorIdentity('user alice ran it', { username: 'alice', homePath: '/home/alice' })) + .to.not.contain('alice'); + }); + + // Substring masking turned "malice" into "m********" and any word containing + // the name into nonsense. + it('should not mask a word that merely contains the name', () => { + expect(maskOperatorIdentity('malice and alicia', { username: 'alice', homePath: '/home/alice' })) + .to.equal('malice and alicia'); + }); + + // On a deb-installed node the service account is called dashmate, so the + // username and the product name are the same string. Masking it blanks the + // subject out of every sentence dashmate writes about itself - "******** could + // not find the certificate bundle" - and mangles the directories dashmate + // itself creates, while hiding nothing the home path has not already hidden. + describe('when the operator is named after the product', () => { + const identity = { username: 'dashmate', homePath: '/home/dashmate' }; + + it('should still remove the home directory', () => { + const masked = maskOperatorIdentity( + '/home/dashmate/.dashmate-ssltest/ssltest/platform/gateway/ssl/bundle.crt', + identity, + ); + + expect(masked).to.not.contain('/home/dashmate'); + expect(masked).to.equal('~/.dashmate-ssltest/ssltest/platform/gateway/ssl/bundle.crt'); + }); + + it('should leave the sentence readable', () => { + expect(maskOperatorIdentity('dashmate could not find the certificate bundle', identity)) + .to.equal('dashmate could not find the certificate bundle'); + }); + }); + + it('should leave text alone when no identity is known', () => { + expect(maskOperatorIdentity('/home/alice/x', { username: null, homePath: null })) + .to.equal('/home/alice/x'); + }); + + it('should pass through anything that is not a string', () => { + expect(maskOperatorIdentity(42, { username: 'alice', homePath: '/home/alice' })).to.equal(42); + }); +}); From 3dbadce038b2acc9a169be940772121518383aec Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 17:48:10 +0700 Subject: [PATCH 23/63] fix(dashmate): judge and migrate the configuration from one snapshot The read-only path decided whether a migration was safe from one read of the config file and then migrated from a second, independent one. Between them the file can change - a restore, a rollback, an older helper writing - so a legacy config substituted after being judged safe would take the migrating path anyway and run the two migrations that relocate TLS material and delete the originals, with no lock held. That is the same defect as before, reached by a different route: a path that promises to change nothing performing destructive filesystem work unlocked. Classifying correctly is not enough if the thing classified is not the thing acted on. The file is now read once. The recorded version is taken from that snapshot, the decision is made from it, and the ConfigFile is built from the same parsed object - reading is split from building so the two cannot drift apart. There is no second read to substitute anything into. The scanner that keeps the filesystem-migration list honest also matched only `fs.`-prefixed calls, so an aliased import, a destructured binding, a local helper or bracket access would have gone undeclared. It matches the operation names now, and all three evasions were tried against it - `const { rmSync } = fs`, `fs['rmSync']`, and a helper bound to `fs.rmSync` - each caught. Tests: 1 new, red before this commit - the file was read twice, and the test counts the reads while substituting a pre-1.0 config after the first. The scanner strengthening was verified by the three probes above. Co-Authored-By: Claude Opus 5 --- .../configFile/ConfigFileJsonRepository.js | 77 +++++++++++-------- .../ConfigFileJsonRepository.spec.js | 41 ++++++++++ .../migrateConfigFileFactory.spec.js | 10 ++- 3 files changed, 94 insertions(+), 34 deletions(-) diff --git a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js index 53bd184c150..f01796751e0 100644 --- a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js +++ b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js @@ -111,19 +111,40 @@ export default class ConfigFileJsonRepository { * @returns {ConfigFile} */ read(options = {}) { - const { skipValidation = false } = options; + return this.#buildConfigFile(this.#readRawConfigFile(), options); + } + + /** + * The bytes on disk, parsed, and nothing more. + * + * Separate from building a ConfigFile so a caller that has to decide + * something about the file before migrating it can decide and migrate from + * the same snapshot. Reading twice leaves a window in which the file can be + * replaced between the decision and the work it authorised. + * + * @returns {Object} + */ + #readRawConfigFile() { if (!fs.existsSync(this.configFilePath)) { throw new ConfigFileNotFoundError(this.configFilePath); } const configFileJSON = fs.readFileSync(this.configFilePath, 'utf8'); - let configFileData; try { - configFileData = JSON.parse(configFileJSON); + return JSON.parse(configFileJSON); } catch (e) { throw new InvalidConfigFileFormatError(this.configFilePath, e); } + } + + /** + * @param {Object} configFileData - already read and parsed + * @param {Object} [options={}] + * @returns {ConfigFile} + */ + #buildConfigFile(configFileData, options = {}) { + const { skipValidation = false } = options; const { version } = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT_DIR, 'package.json'), 'utf8')); @@ -267,15 +288,25 @@ export default class ConfigFileJsonRepository { // operator an older dashmate wrote this" has to be true - a file that is // missing or damaged must report itself as missing or damaged, and one of // those errors is what first-run setup catches to create defaults. - if (options.readOnly === true && this.#hasFilesystemMigrationDue()) { - throw new ConfigFileMigrationRequiredError(this.configFilePath); + if (options.readOnly === true) { + // Read once. Deciding from one read and then migrating from another + // leaves a window in which the file can be swapped for a legacy one + // after it has been judged safe, and the destructive migrations would + // then run with no lock held. + const configFileData = this.#readRawConfigFile(); + + if (this.#hasFilesystemMigrationDue(configFileData.configFormatVersion)) { + throw new ConfigFileMigrationRequiredError(this.configFilePath); + } + + return { configFile: this.#buildConfigFile(configFileData, options) }; } // Decide whether a migration is due from the recorded version alone. // Migrations are not all pure - some move service files on disk and delete // the originals - so running them to find out would do that work outside // the lock, and again inside it. - if (options.readOnly === true || !this.#isMigrationDue()) { + if (!this.#isMigrationDue()) { return { configFile: this.read(options) }; } @@ -312,10 +343,15 @@ export default class ConfigFileJsonRepository { * dashmate wrote the file, so it answers false and leaves the file to report * its own problem. * + * @param {*} rawRecordedVersion - the version recorded in the snapshot being + * judged, so the decision and the migration cannot disagree * @returns {boolean} */ - #hasFilesystemMigrationDue() { - const recordedVersion = this.#recordedVersion(); + #hasFilesystemMigrationDue(rawRecordedVersion) { + const recordedVersion = typeof rawRecordedVersion === 'string' + && semver.valid(rawRecordedVersion) !== null + ? rawRecordedVersion + : null; if (recordedVersion === null || typeof this.configFormatVersion !== 'string') { return false; @@ -325,31 +361,6 @@ export default class ConfigFileJsonRepository { && semver.lte(version, this.configFormatVersion)); } - /** - * The format version the file records, or null when it cannot be read. - * - * @returns {string|null} - */ - #recordedVersion() { - let recordedVersion; - - try { - recordedVersion = JSON.parse( - fs.readFileSync(this.configFilePath, 'utf8'), - ).configFormatVersion; - } catch { - // An unreadable or malformed file is read()'s to report, with the error - // that names the file and the reason. - return null; - } - - if (typeof recordedVersion !== 'string' || semver.valid(recordedVersion) === null) { - return null; - } - - return recordedVersion; - } - /** * Whether the file on disk records an older format than this build produces. * diff --git a/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js b/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js index 5910241ccf9..46454851300 100644 --- a/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js +++ b/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js @@ -410,6 +410,47 @@ describe('ConfigFileJsonRepository', () => { expect(fs.existsSync(homeDir.joinPath('.config.json.lock'))).to.be.false(); }); + // Classifying from one read of the file and then migrating from another is + // a window: a restore, a rollback, or an older helper writing between the + // two substitutes a legacy config after it has been judged safe, and the + // destructive migrations then run with no lock held - which is the whole + // thing this mode exists to prevent, reached by a different route. + it('should judge and migrate the same bytes', async function it() { + const container = await createDIContainer(); + container.resolve('homeDir').change(homeDir); + + const current = JSON.parse(seedConfigFile()); + current.configFormatVersion = '4.1.0'; + const currentJson = JSON.stringify(current, undefined, 2); + const legacyJson = JSON.stringify(getConfigFileDataV0250(), undefined, 2); + + fs.writeFileSync(configFilePath, currentJson, 'utf8'); + + // A pre-1.0 config is substituted the moment the file has been read once. + let configReads = 0; + const readFileSync = this.sinon.stub(fs, 'readFileSync'); + readFileSync.callThrough(); + readFileSync.withArgs(configFilePath).callsFake(() => { + configReads += 1; + + return configReads === 1 ? currentJson : legacyJson; + }); + + const repository = new ConfigFileJsonRepository( + container.resolve('migrateConfigFile'), + homeDir, + createDefaults, + container.resolve('configFormatVersion'), + ); + + const { configFile } = repository.readAndMigrate({ readOnly: true }); + + // One read leaves no window to substitute anything into, so what was + // judged is what was migrated. + expect(configReads).to.equal(1); + expect(configFile.getConfig('base')).to.exist(); + }); + // The two migrations that move and delete TLS material are the only reason // this mode ever refuses, so it has to refuse when one of them is in range. it('should still refuse when a migration in range touches the disk', async () => { diff --git a/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js index 1f43125e04c..13bc9a061f1 100644 --- a/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js +++ b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js @@ -38,10 +38,18 @@ describe('migrateConfigFileFactory', () => { expect(keys).to.have.length.greaterThan(50); + // Matched on the operation names rather than on `fs.`, so an aliased + // import, a local helper or bracket access cannot slip a filesystem write + // past the scan by not spelling the module out. + const mutatingCall = new RegExp(`\\b(?:${[ + 'appendFile', 'chmod', 'chown', 'copyFile', 'cp', 'link', 'mkdir', 'mkdtemp', + 'rename', 'rm', 'rmdir', 'symlink', 'truncate', 'unlink', 'utimes', 'writeFile', + ].map((name) => `${name}(?:Sync)?`).join('|')})\\b`); + const touchesFilesystem = keys.filter(({ index }, position) => { const end = position + 1 < keys.length ? keys[position + 1].index : source.length; - return /\bfs\.\w+/.test(source.slice(index, end)); + return mutatingCall.test(source.slice(index, end)); }).map(([, version]) => version); expect(touchesFilesystem.sort()) From 9a080b46addb3dc082128579c1329450163837b7 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 17:48:11 +0700 Subject: [PATCH 24/63] fix(dashmate): state the port 80 argument once, and test what is rendered The message a node already on Let's Encrypt receives repeated the one-operator-three-nodes anecdote twice, in the diagnosis and again in the permanence block. Removed from the diagnosis, which keeps the honest half - that half the nodes in this state had port 80 open and stopped renewing anyway - and leaves the anecdote where it argues for permanence. The test that was supposed to have caught this could not fail. It passed a hand-built verdict with provider overridden to letsencrypt while the config still said zerossl, and the renderer reads the config - so the Let's Encrypt blocks never rendered and the test measured a message no operator would ever see. It drives the configured provider now, and runs across all four, so a message assembled from the wrong combination cannot pass unnoticed. This is the third test in this change written so it could not fail. The other two were caught the same way and by the same reviewer. Tests: red before this commit, reproducing the reported count of two occurrences on a real render. Co-Authored-By: Claude Opus 5 --- .../src/ssl/renderCertificateGuidance.js | 3 +-- .../ssl/renderCertificateGuidance.spec.js | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index 623ed540f1a..377d0eef9ed 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -130,8 +130,7 @@ function renderLetsEncryptDiagnosis(cfg) { The most likely cause is inbound port 80. Let's Encrypt re-checks it on every renewal - roughly every four days, permanently - and a firewall rule that was opened once and later closed, or that did not survive a reboot, produces - exactly this pattern. Three mainnet nodes issued certificates on the same day - went dark together six days later - one operator, one change, three nodes. + exactly this pattern. It is not always port 80: half the nodes in this state have port 80 open and stopped renewing regardless. Check the renewal logs as well: diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index ebcdc1d4e03..c51bff87452 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -190,7 +190,7 @@ describe('renderCertificateGuidance', () => { it('should not offer a switch to a node already on that provider', () => { config.set('platform.gateway.ssl.provider', 'letsencrypt'); - const output = render({ verdict: verdict({ provider: 'letsencrypt' }) }); + const output = render(); expect(output).to.contain('already configured for Let\'s Encrypt'); expect(output).to.contain('THE FIX - obtain a new certificate'); @@ -202,13 +202,21 @@ describe('renderCertificateGuidance', () => { // Saying the same thing three times in one message is how an operator learns // to skim past it. - it('should state the port 80 argument once', () => { - const output = render({ verdict: verdict({ provider: 'letsencrypt' }) }); + // Driven from the configured provider, which is what the renderer actually + // reads. Overriding the verdict's own provider field leaves the config saying + // something else, so the Let's Encrypt blocks never render and the test + // measures a message no operator will ever see. + ['zerossl', 'letsencrypt', 'file', 'self-signed'].forEach((provider) => { + it(`should state the port 80 argument once for a ${provider} node`, () => { + config.set('platform.gateway.ssl.provider', provider); - const occurrences = (needle) => output.split(needle).length - 1; + const output = render(); - expect(occurrences('issued certificates on the same day')).to.equal(1); - expect(occurrences('PORT 80 MUST STAY OPEN PERMANENTLY')).to.equal(1); + const occurrences = (needle) => output.split(needle).length - 1; + + expect(occurrences('issued certificates on the same day')).to.equal(1); + expect(occurrences('PORT 80 MUST STAY OPEN PERMANENTLY')).to.equal(1); + }); }); // A silent drop of an external probe is no information at all: 52 nodes that From 657529d89b2e6e9c444f2251a2210eded625ce12 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 17:48:29 +0700 Subject: [PATCH 25/63] fix(dashmate): separate what the authority said from what it was never asked Splitting the obtain failure in two was not enough - the boundary was in the wrong place and one outcome was missing. CLEARING A STALE CONTAINER HAPPENED BEFORE THE SPLIT. A failure removing a container left by a previous run - a permission problem, a daemon that will not answer - is as far from a certificate authority response as a refused port binding is, but it fell through to the retry branch and was reported with rate-limit and paused-address guidance for a request that was never made. A RESULT NOBODY READ IS NOT A RESULT. If the container starts and the wait fails on a transport error, a request may well have been made, so saying nothing reached the authority would be wrong - and nothing was read, so reporting what the authority said is equally wrong. It has its own outcome now: dashmate says the helper started, that it could not read how it finished, and that a request which did reach Let's Encrypt counts against this node's limits whether or not dashmate saw the answer. Both claims withheld rather than one guessed. THE PORT CONFLICT WAS STILL ASSERTED. The guidance named an occupied port as the cause of every start failure, when a daemon that is not running and a user without permission produce the same failure. Docker's own error is presented as the diagnosis, and the port conflict is offered as one common cause alongside the others. THE PERMANENCE WARNING WAS LOST ON TWO PATHS. It printed only after the whole command succeeded, so an issuance followed by a failed save or reload rethrew before saying anything - and the operator who opened port 80 for that issuance is about to close it again, with a certificate that already counts against this node's limits. It is printed from a finally now. It also keyed on lego having run, so a later run installing an already-issued pair - recovering from an interruption - never printed it; the flag moves to where the gateway's certificate actually changes. Tests: 4 new, all red before this commit - a stale-container failure and a daemon failure both produced authority guidance, a lost wait was reported as an authority response, and permanence vanished when a later step failed. An earlier assertion that expected the port conflict to be asserted is corrected to expect it offered. Co-Authored-By: Claude Opus 5 --- packages/dashmate/src/commands/ssl/obtain.js | 21 +++--- ...obtainLetsEncryptCertificateTaskFactory.js | 62 ++++++++++++++--- .../ssl/errors/LegoResultNotObservedError.js | 20 ++++++ .../test/unit/commands/ssl/obtain.spec.js | 17 +++++ ...nLetsEncryptCertificateTaskFactory.spec.js | 68 ++++++++++++++++++- 5 files changed, 170 insertions(+), 18 deletions(-) create mode 100644 packages/dashmate/src/ssl/errors/LegoResultNotObservedError.js diff --git a/packages/dashmate/src/commands/ssl/obtain.js b/packages/dashmate/src/commands/ssl/obtain.js index 2b9005cdf46..d74cf3218d2 100644 --- a/packages/dashmate/src/commands/ssl/obtain.js +++ b/packages/dashmate/src/commands/ssl/obtain.js @@ -162,14 +162,19 @@ Certificate will be renewed if it is about to expire (see 'expiration-days' flag await tasks.run(context); } catch (e) { throw new MuteOneLineError(e); - } - - // Only when something was actually issued. This is the command the - // certificate check tells an operator to run, and an operator who opened - // port 80 for this one migration is the one who most needs to hear that it - // has to stay open - they never saw a failure that would have said so. - if (context.certificateObtained && provider === SSL_PROVIDERS.LETSENCRYPT) { - process.stderr.write(`\n${PORT_80_PERMANENCE}\n`); + } finally { + // Only when the gateway's certificate actually changed. This is the + // command the certificate check tells an operator to run, and an + // operator who opened port 80 for this one migration is the one who most + // needs to hear that it has to stay open - they never saw a failure that + // would have said so. + // + // Printed even when a later step fails: a certificate that was issued + // and then failed to install still counts against this node's limits, + // and the operator is about to close the port either way. + if (context.certificateObtained && provider === SSL_PROVIDERS.LETSENCRYPT) { + process.stderr.write(`\n${PORT_80_PERMANENCE}\n`); + } } } } diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index b59cdc1214f..03057401cf4 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -7,6 +7,7 @@ import { ERRORS } from '../../../../ssl/letsencrypt/validateLetsEncryptCertifica import LegoCertificate from '../../../../ssl/letsencrypt/LegoCertificate.js'; import { LETSENCRYPT_ACME_DIRECTORY_URL } from '../../../../constants.js'; import LegoDidNotStartError from '../../../../ssl/errors/LegoDidNotStartError.js'; +import LegoResultNotObservedError from '../../../../ssl/errors/LegoResultNotObservedError.js'; import promptOrThrow from '../../../../util/promptOrThrow.js'; import renderConfigFlag from '../../../../util/renderConfigFlag.js'; @@ -83,16 +84,45 @@ function renderHelperDidNotStartGuidance(config, cause) { made to Let's Encrypt. Nothing was issued, nothing was validated, and no rate limit was spent. +Docker reported: + ${cause.message} -The usual cause is that something is already listening on port 80 here. The -port being reachable is not the problem - it is occupied. Find what holds it, -stop that, then retry: +That message is the diagnosis - dashmate did not look further than it. One +common cause is another process already holding port 80, which is the +opposite of a blocked port: it is reachable and occupied. Others are the +Docker daemon being unreachable, or the current user not being permitted to +use it. sudo ss -lntp 'sport = :80' dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider letsencrypt`; } +/** + * What to tell an operator when the helper ran and its result was never read. + * + * A request may have been made, so it would be wrong to say nothing reached the + * authority - and nothing was read, so it is equally wrong to report what the + * authority said. Both claims are withheld and the state is described instead. + * + * @param {Config} config + * @param {Error} cause + * @return {string} + */ +function renderResultNotObservedGuidance(config, cause) { + return `The certificate helper started, but dashmate could not read how it +finished: + +${cause.message} + +So dashmate does not know whether a certificate was requested. Check whether +one arrived before trying again - a request that did reach Let's Encrypt +counts against this node's limits whether or not dashmate saw the answer: + + dashmate doctor ${renderConfigFlag(config.getName())} + dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider letsencrypt`; +} + const LEGO_CA_CERTIFICATE_MOUNT_PATH = '/acme-ca.pem'; /** @@ -314,7 +344,9 @@ export default function obtainLetsEncryptCertificateTaskFactory( const containerName = 'dashmate-letsencrypt-lego'; const runLego = async () => { - // Remove any existing container with the same name + // Clearing a stale container from a previous run happens before + // lego exists, so a failure here is as far from a response by the + // certificate authority as a refused port binding is. try { const existingContainer = await docker.getContainer(containerName); await existingContainer.remove({ force: true }); @@ -330,7 +362,7 @@ export default function obtainLetsEncryptCertificateTaskFactory( } catch (e) { // Container doesn't exist, that's fine if (e.statusCode !== 404) { - throw e; + throw new LegoDidNotStartError(e); } } @@ -368,8 +400,14 @@ export default function obtainLetsEncryptCertificateTaskFactory( // eslint-disable-next-line no-param-reassign task.output = `Running lego ${command}...`; - // Wait for container to finish - const result = await container.wait(); + // The container is running, so a request may have been made - but a + // result nobody read is not a result that can be reported. + let result; + try { + result = await container.wait(); + } catch (e) { + throw new LegoResultNotObservedError(e); + } if (result.StatusCode !== 0) { // lego's own output is the best account of what went wrong - @@ -411,6 +449,10 @@ export default function obtainLetsEncryptCertificateTaskFactory( throw new Error(renderHelperDidNotStartGuidance(config, e.cause)); } + if (e instanceof LegoResultNotObservedError) { + throw new Error(renderResultNotObservedGuidance(config, e.cause)); + } + // Prompting needs a positive opt-in from the entry point. The // helper renews inside a container with no terminal, where a // prompt would never settle and would hold the config lock - @@ -446,7 +488,6 @@ export default function obtainLetsEncryptCertificateTaskFactory( } ctx.configurationUpdateRequired = true; - ctx.certificateObtained = true; // eslint-disable-next-line no-param-reassign task.output = 'Certificate obtained successfully'; @@ -461,6 +502,11 @@ export default function obtainLetsEncryptCertificateTaskFactory( ctx.privateKeyFile = fs.readFileSync(ctx.legoKeyPath, 'utf8'); ctx.configurationUpdateRequired = true; + // Recorded here rather than after the issuance, so installing a + // certificate that was already issued - a run recovering from an + // interrupted one - counts as the gateway's certificate changing. + ctx.certificateObtained = true; + // Save to gateway SSL directory return saveCertificateTask(config); }, diff --git a/packages/dashmate/src/ssl/errors/LegoResultNotObservedError.js b/packages/dashmate/src/ssl/errors/LegoResultNotObservedError.js new file mode 100644 index 00000000000..577dd8e589a --- /dev/null +++ b/packages/dashmate/src/ssl/errors/LegoResultNotObservedError.js @@ -0,0 +1,20 @@ +import AbstractError from '../../errors/AbstractError.js'; + +/** + * The certificate helper ran, but dashmate never saw how it finished. + * + * Distinct from both of the other outcomes. A request may well have been made, + * so it would be wrong to say nothing reached the authority - and no result was + * read, so it is equally wrong to report what the authority said or to draw + * conclusions about rate limits from a response nobody saw. + */ +export default class LegoResultNotObservedError extends AbstractError { + /** + * @param {Error} cause + */ + constructor(cause) { + super(`dashmate could not read the result of the certificate helper: ${cause.message}`); + + this.cause = cause; + } +} diff --git a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js index 92281f87147..25357929ee2 100644 --- a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js +++ b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js @@ -195,6 +195,23 @@ describe('SSL obtain command', () => { expect(stderr).to.contain('survives a reboot'); }); + // A certificate that was issued and then failed to install is still a + // certificate this node now holds against its limits, and the operator who + // opened port 80 for it is about to close it again. The failure must not + // swallow the one thing that stops the node going dark in six days. + it('should state permanence even when a later step fails', async function it() { + const dependencies = obtainDependencies(this.sinon); + dependencies.obtainTask = this.sinon.stub().callsFake(() => new Listr([{ + task: (ctx) => { ctx.certificateObtained = true; }, + }])); + dependencies.dockerCompose.execCommand = this.sinon.stub() + .rejects(new Error('reload failed')); + + await expect(runObtain(dependencies)).to.be.rejected(); + + expect(stderr).to.contain('LEAVE PORT 80 OPEN'); + }); + // Nothing was issued, so there is nothing to warn about and a cron run stays // quiet. it('should stay silent when no new certificate was obtained', async function it() { diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js index c8cdc5c12e3..f33e4a8e6fb 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js @@ -509,9 +509,11 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { .run({ force: true }).catch((e) => e); // The Docker error is shown, because it is the only thing that says what - // actually happened. + // actually happened, and the port conflict is offered as a possible + // cause rather than asserted as the cause. expect(error.message).to.contain('address already in use'); - expect(error.message).to.contain('already listening on port 80'); + expect(error.message).to.contain('holding port 80'); + expect(error.message).to.contain('no request was'); // And none of the authority-side consequences are claimed. expect(error.message).to.not.contain('PAUSED'); @@ -520,6 +522,68 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { expect(error.message).to.not.contain('Fix inbound port 80 first'); }); + // Clearing a stale container from a previous run happens before lego is + // even created, so a failure there is as far from a certificate authority + // response as a bind refusal is. + it('should not blame the authority when clearing a stale container fails', async function it() { + const denied = Object.assign(new Error('permission denied while removing container'), { statusCode: 403 }); + const docker = { + getContainer: this.sinon.stub().resolves({ + remove: this.sinon.stub().rejects(denied), + wait: this.sinon.stub().resolves(), + }), + createContainer: this.sinon.stub(), + }; + + const error = await buildFailingTask(this.sinon, docker)(config) + .run({ force: true }).catch((e) => e); + + expect(error.message).to.contain('permission denied while removing container'); + expect(error.message).to.not.contain('PAUSED'); + expect(error.message).to.not.contain('failed attempts are shared'); + expect(docker.createContainer).to.not.have.been.called(); + }); + + // The Docker error is shown because only it says what happened; asserting + // the port is occupied for a daemon, permission or configuration failure + // would send the operator after the wrong thing. + it('should not assert a port conflict it did not observe', async function it() { + const daemonGone = Object.assign(new Error('Cannot connect to the Docker daemon'), { statusCode: 500 }); + const missing = Object.assign(new Error('container not found'), { statusCode: 404 }); + const docker = { + getContainer: this.sinon.stub().rejects(missing), + createContainer: this.sinon.stub().rejects(daemonGone), + }; + + const error = await buildFailingTask(this.sinon, docker)(config) + .run({ force: true }).catch((e) => e); + + expect(error.message).to.contain('Cannot connect to the Docker daemon'); + expect(error.message).to.not.match(/the port is occupied/i); + expect(error.message).to.not.match(/is already listening on port 80/i); + }); + + // The container ran, so a request may well have been made - but dashmate + // never saw the result, so it cannot report what the authority said either. + it('should say the result was never read when the wait fails', async function it() { + const missing = Object.assign(new Error('container not found'), { statusCode: 404 }); + const docker = { + getContainer: this.sinon.stub().rejects(missing), + createContainer: this.sinon.stub().resolves({ + start: this.sinon.stub().resolves(), + logs: this.sinon.stub().resolves(Buffer.from('')), + wait: this.sinon.stub().rejects(new Error('connection reset by peer')), + }), + }; + + const error = await buildFailingTask(this.sinon, docker)(config) + .run({ force: true }).catch((e) => e); + + expect(error.message).to.contain('connection reset by peer'); + expect(error.message).to.match(/could not read|did not see/i); + expect(error.message).to.not.contain('PAUSED'); + }); + // A failure the authority did return keeps the guidance that is about the // authority. it('should keep the rate-limit guidance when the request did reach the authority', async function it() { From 2c37c634bfa970de0779a5a2a3ef950fe9ab2292 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 17:48:47 +0700 Subject: [PATCH 26/63] fix(dashmate): compare the two certificates on the expired branch too The comparison was added to one of the two branches that act on the served and on-disk certificates differing. The other - where the served certificate has already expired - still called any differing disk copy "a newer one" and advised a restart on that basis. A node serving an expired certificate with an equally dead one on disk was told a restart would fix it; a node whose disk copy is older would be moved further backwards. Both branches share one comparison now. When the disk copy really is newer the advice is unchanged. When it is older, equal, or there is nothing to compare against, the problem says neither copy is usable and points at obtaining a current certificate, which installs and signals without loading the stale file. The test that should have caught this was blessing it: it asserted "a newer one is already present on disk" while supplying no on-disk sample at all, so it passed precisely because the branch never checked. It supplies the newer copy its own name implies now. Tests: 3 new, red before this commit, plus the corrected existing one. Co-Authored-By: Claude Opus 5 --- .../analyseGatewayCertificateFactory.js | 31 ++++++++----- .../analyseGatewayCertificateFactory.spec.js | 43 +++++++++++++++++++ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 2c5fe37cfce..5cae7e6bb0a 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -123,7 +123,16 @@ ${restartHint(cfg)}`, const isServedExpired = servedExpiresAt <= now; const onDiskDiffers = served.matchesOnDisk === false; - if (isServedExpired && onDiskDiffers) { + // Which of the two is newer decides both the description and whether a + // restart is safe to advise, on either branch below. A restart makes the + // gateway load the disk copy, so advising one without checking can replace + // what is on the wire with something no better - or worse. + const onDiskExpiresAt = served.onDisk + ? new Date(served.onDisk.validTo).getTime() + : null; + const isOnDiskNewer = onDiskExpiresAt !== null && onDiskExpiresAt > servedExpiresAt; + + if (isServedExpired && onDiskDiffers && isOnDiskNewer) { 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', @@ -131,6 +140,16 @@ ${restartHint(cfg)}`, {bold.cyanBright dashmate restart ${cfg} --platform}`, SEVERITY.HIGH, )); + } else if (isServedExpired && onDiskDiffers) { + problems.push(new Problem( + `The gateway is serving a certificate that expired on ${served.certificate.validTo}. ` + + 'The copy on disk is a different one, and it is no newer', + chalk`Neither the certificate on the wire nor the one on disk is usable, so restarting +Platform would not help. Obtain a current certificate, which installs it and +signals the gateway: +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, + SEVERITY.HIGH, + )); } else if (isServedExpired) { problems.push(new Problem( `The gateway is serving a certificate that expired on ${served.certificate.validTo}. ` @@ -142,15 +161,7 @@ ${restartHint(cfg)}`, SEVERITY.HIGH, )); } else if (onDiskDiffers) { - // Which of the two is newer decides both the description and whether a - // restart is safe to advise. A restart makes the gateway load the disk - // copy, so advising one without checking can replace a valid served - // certificate with an expired one and take a working node dark. - const onDiskExpiresAt = served.onDisk - ? new Date(served.onDisk.validTo).getTime() - : null; - - if (onDiskExpiresAt !== null && onDiskExpiresAt > servedExpiresAt) { + if (isOnDiskNewer) { // 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( diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index 2bd4b3eae94..ecbf02db2b2 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -74,9 +74,12 @@ describe('analyseGatewayCertificateFactory', () => { }); it('should distinguish a certificate that was renewed but never reached the gateway', () => { + // Renewed means the disk copy outlives the served one. Leaving that out + // let the branch claim a direction it had never checked. const problems = analyse(served({ certificate: { fingerprint256: 'AA:BB', validTo: validTo(-2) }, matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD', validTo: validTo(30) }, })); expect(problems).to.have.lengthOf(1); @@ -199,6 +202,46 @@ describe('analyseGatewayCertificateFactory', () => { }); }); + // The expired-served branch makes the same unchecked claim: it calls any + // differing disk copy newer and advises a restart, so a node serving an + // expired certificate with an equally dead one on disk is told a restart + // will fix it. + describe('when the served certificate has expired and the disk copy differs', () => { + it('should advise a restart only when the disk copy really is newer', () => { + const [problem] = analyse(served({ + certificate: { fingerprint256: 'AA:BB', validTo: validTo(-1) }, + matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD', validTo: validTo(30) }, + })); + + expect(problem.getDescription()).to.include('newer one is already present on disk'); + expect(problem.getSolution()).to.include('dashmate restart'); + }); + + it('should not advise a restart when the disk copy is no better', () => { + const [problem] = analyse(served({ + certificate: { fingerprint256: 'AA:BB', validTo: validTo(-1) }, + matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD', validTo: validTo(-158) }, + })); + + expect(problem.getDescription()).to.not.include('newer one is already present on disk'); + expect(problem.getSolution()).to.not.match(/dashmate restart/); + expect(problem.getSolution()).to.include('dashmate ssl obtain'); + }); + + it('should not advise a restart when there is nothing to compare', () => { + const [problem] = analyse(served({ + certificate: { fingerprint256: 'AA:BB', validTo: validTo(-1) }, + matchesOnDisk: false, + onDisk: null, + })); + + expect(problem.getDescription()).to.not.include('newer one is already present on disk'); + expect(problem.getSolution()).to.not.match(/dashmate restart/); + }); + }); + // An external connect test measures whether something is listening, and // nothing listens on port 80 on a healthy node except for the seconds a // renewal takes. So it reports CLOSED on healthy nodes by construction, and From 86eb80c1a28b05911374faf2a86f35a55d24e075 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 17:48:47 +0700 Subject: [PATCH 27/63] fix(dashmate): rewrite the home path only where it ends Replacing every occurrence of the home path turned a sibling directory into a path pointing somewhere else - /home/alice2/x became ~2/x, which resolves, and resolves wrongly. It is only rewritten where the path actually ends now. Matching is case-insensitive for the home path, because macOS and Windows resolve paths that way and the same directory reaches a report in more than one spelling. The whole-word username match stays case-sensitive: account names are compared exactly by the systems that issue them. Tests: 3 new, red before this commit for the sibling directory and the case variant. Co-Authored-By: Claude Opus 5 --- .../dashmate/src/util/maskOperatorIdentity.js | 23 ++++++++++++++---- .../unit/util/maskOperatorIdentity.spec.js | 24 +++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/dashmate/src/util/maskOperatorIdentity.js b/packages/dashmate/src/util/maskOperatorIdentity.js index 6f711411a8d..402a4d14ac9 100644 --- a/packages/dashmate/src/util/maskOperatorIdentity.js +++ b/packages/dashmate/src/util/maskOperatorIdentity.js @@ -13,6 +13,14 @@ import hideString from './hideString.js'; */ const PRODUCT_WORDS = ['dashmate']; +/** + * @param {string} value + * @return {string} + */ +function escapeForRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * A name is only masked where it stands alone. Substring replacement turns * every word that happens to contain it into nonsense, and the collisions are @@ -22,9 +30,7 @@ const PRODUCT_WORDS = ['dashmate']; * @return {RegExp} */ function wholeWord(name) { - const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - - return new RegExp(`(? { }); }); + // A sibling directory that merely starts with the home path is a different + // directory, and rewriting it to `~2` produces a path that resolves to + // something else entirely. + it('should not rewrite a directory that merely starts with the home path', () => { + expect(maskOperatorIdentity('/home/alice2/x', { username: 'bob', homePath: '/home/alice' })) + .to.equal('/home/alice2/x'); + expect(maskOperatorIdentity('/home/alice.bak/x', { username: 'bob', homePath: '/home/alice' })) + .to.equal('/home/alice.bak/x'); + }); + + it('should still rewrite the home path itself and its children', () => { + expect(maskOperatorIdentity('/home/alice', { username: 'bob', homePath: '/home/alice' })) + .to.equal('~'); + expect(maskOperatorIdentity('at /home/alice, then', { username: 'bob', homePath: '/home/alice' })) + .to.equal('at ~, then'); + }); + + // macOS and Windows resolve paths case-insensitively, so the same directory + // reaches a report in more than one spelling. + it('should rewrite the home path whatever case it arrives in', () => { + expect(maskOperatorIdentity('/Home/Alice/x', { username: 'bob', homePath: '/home/alice' })) + .to.equal('~/x'); + }); + it('should leave text alone when no identity is known', () => { expect(maskOperatorIdentity('/home/alice/x', { username: null, homePath: null })) .to.equal('/home/alice/x'); From ac759a03d2bcf6129523429934b51bf5269f252a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 18:13:11 +0700 Subject: [PATCH 28/63] fix(dashmate): stop retrying an issuance that already succeeded The worst of the failure outcomes to get wrong, and it was still landing in the retry branch. When lego exits cleanly but the files it should have written are not there, that was an ordinary error: the loop treated it as a refusal by the certificate authority and asked for another certificate, up to three times. Each attempt spends one of five issuances per address per week, for a problem that is entirely local - a full disk, a permission, a bind mount. The message then told the operator no certificate had been obtained and discussed failed authorizations, rate limits and port 80, when a certificate had in fact been issued every time. It is its own outcome now. No retry, and the guidance leads with the fact that matters: a certificate exists and counts against the limit whether or not dashmate can find it, so obtaining another is not free and will not fix this. The remedy is local, and installing what was already issued is the next step. The issuance is recorded the moment lego reports success, before anything else can fail. Previously it was recorded only after both files had been read, so a file that vanished in between suppressed the port-80 permanence warning on a run that really had issued a certificate - and that operator is the one about to close the port again. Tests: 1 new, red before this commit - three container starts where there should be one, no mention of the issuance, and the flag never set. The daemon-failure assertion is widened to reject the paused-address and shared-attempts guidance too, not just the port-conflict claim, while still permitting the honest statement that no limit was spent. Co-Authored-By: Claude Opus 5 --- ...obtainLetsEncryptCertificateTaskFactory.js | 45 ++++++++++++++++++- .../ssl/errors/LegoArtifactsMissingError.js | 21 +++++++++ ...nLetsEncryptCertificateTaskFactory.spec.js | 40 +++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 packages/dashmate/src/ssl/errors/LegoArtifactsMissingError.js diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index 03057401cf4..8eb508fdb11 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -6,6 +6,7 @@ import os from 'os'; import { ERRORS } from '../../../../ssl/letsencrypt/validateLetsEncryptCertificateFactory.js'; import LegoCertificate from '../../../../ssl/letsencrypt/LegoCertificate.js'; import { LETSENCRYPT_ACME_DIRECTORY_URL } from '../../../../constants.js'; +import LegoArtifactsMissingError from '../../../../ssl/errors/LegoArtifactsMissingError.js'; import LegoDidNotStartError from '../../../../ssl/errors/LegoDidNotStartError.js'; import LegoResultNotObservedError from '../../../../ssl/errors/LegoResultNotObservedError.js'; import promptOrThrow from '../../../../util/promptOrThrow.js'; @@ -98,6 +99,34 @@ use it. dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider letsencrypt`; } +/** + * What to tell an operator whose certificate was issued but never landed. + * + * The issuance is the fact that matters: it is spent whether or not the files + * arrived, so the one thing this must not do is invite another attempt. + * + * @param {Config} config + * @param {string} missingPath + * @return {string} + */ +function renderArtifactsMissingGuidance(config, missingPath) { + return `Let's Encrypt issued a certificate, but dashmate could not find the +file it should have written: + + ${missingPath} + +The certificate exists and counts against this node's issuance limit - five +per address per week - so obtaining another one is not free and will not fix +this. The problem is local: check the disk for space and permissions, and that +the helper is allowed to write there. + +Once that is sorted, install what was already issued: + + dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider letsencrypt + +${PORT_80_PERMANENCE}`; +} + /** * What to tell an operator when the helper ran and its result was never read. * @@ -427,13 +456,19 @@ export default function obtainLetsEncryptCertificateTaskFactory( throw new Error(`Failed to obtain Let's Encrypt certificate: ${errorMessage}`); } + // The authority has issued by this point, so the issuance counts + // against this node's weekly limit however the rest of this run + // goes. Recorded before anything else can fail, so a later problem + // cannot hide it. + ctx.certificateObtained = true; + // Verify certificate and key were created if (!fs.existsSync(ctx.legoCertPath)) { - throw new Error('Certificate file was not created by lego'); + throw new LegoArtifactsMissingError(ctx.legoCertPath); } if (!fs.existsSync(ctx.legoKeyPath)) { - throw new Error('Private key file was not created by lego'); + throw new LegoArtifactsMissingError(ctx.legoKeyPath); } }; @@ -453,6 +488,12 @@ export default function obtainLetsEncryptCertificateTaskFactory( throw new Error(renderResultNotObservedGuidance(config, e.cause)); } + // A certificate exists. Retrying would ask for another one for a + // problem that is entirely local to this machine. + if (e instanceof LegoArtifactsMissingError) { + throw new Error(renderArtifactsMissingGuidance(config, e.missingPath)); + } + // Prompting needs a positive opt-in from the entry point. The // helper renews inside a container with no terminal, where a // prompt would never settle and would hold the config lock - diff --git a/packages/dashmate/src/ssl/errors/LegoArtifactsMissingError.js b/packages/dashmate/src/ssl/errors/LegoArtifactsMissingError.js new file mode 100644 index 00000000000..6222b578b77 --- /dev/null +++ b/packages/dashmate/src/ssl/errors/LegoArtifactsMissingError.js @@ -0,0 +1,21 @@ +import AbstractError from '../../errors/AbstractError.js'; + +/** + * The certificate helper succeeded, but the files it should have written are + * not there. + * + * The most consequential of the failure outcomes to get right. A certificate + * was issued, so it counts against this node's weekly issuance limit whether or + * not dashmate can find it; retrying as though the authority had refused spends + * that limit again for a problem that is entirely local. + */ +export default class LegoArtifactsMissingError extends AbstractError { + /** + * @param {string} missingPath + */ + constructor(missingPath) { + super(`The certificate was issued, but ${missingPath} was not written`); + + this.missingPath = missingPath; + } +} diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js index f33e4a8e6fb..745fa0848ad 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js @@ -522,6 +522,38 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { expect(error.message).to.not.contain('Fix inbound port 80 first'); }); + // lego exited successfully, so a certificate was issued and counts against + // this node's weekly limit whether or not dashmate can find the files. + // Retrying that as though the authority had refused spends the limit again, + // up to three times, and then reports that nothing was obtained. + it('should not retry or blame the authority when the issued files are missing', async function it() { + const missing = Object.assign(new Error('container not found'), { statusCode: 404 }); + const docker = { + getContainer: this.sinon.stub().rejects(missing), + createContainer: this.sinon.stub().resolves({ + start: this.sinon.stub().resolves(), + logs: this.sinon.stub().resolves(Buffer.from('')), + // Exits cleanly, but writes nothing. + wait: this.sinon.stub().resolves({ StatusCode: 0 }), + }), + }; + + const context = { force: true, interactive: true }; + const error = await buildFailingTask(this.sinon, docker)(config) + .run(context).catch((e) => e); + + // Issued once, and only once. + expect(docker.createContainer).to.have.been.calledOnce(); + + expect(error.message).to.match(/was issued|counts against/i); + expect(error.message).to.not.contain('PAUSED'); + expect(error.message).to.not.contain('failed attempts are shared'); + expect(error.message).to.not.match(/did not obtain a certificate after/i); + + // And the operator still hears the requirement that keeps the node up. + expect(context.certificateObtained).to.be.true(); + }); + // Clearing a stale container from a previous run happens before lego is // even created, so a failure there is as far from a certificate authority // response as a bind refusal is. @@ -561,6 +593,14 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { expect(error.message).to.contain('Cannot connect to the Docker daemon'); expect(error.message).to.not.match(/the port is occupied/i); expect(error.message).to.not.match(/is already listening on port 80/i); + // Nor any of the guidance that belongs to a response from the authority. + expect(error.message).to.not.contain('PAUSED'); + expect(error.message).to.not.contain('failed attempts are shared'); + expect(error.message).to.not.contain('Fix inbound port 80 first'); + // It may say no limit was spent - that is the honest statement. What it + // must not do is discuss a limit as though one had been. + expect(error.message).to.contain('no request was'); + expect(error.message).to.not.match(/may be PAUSED|Self-Service Portal/i); }); // The container ran, so a request may well have been made - but dashmate From 50fb902d4b593f23a1ed5660b67aa4807f6727de Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 18:13:11 +0700 Subject: [PATCH 29/63] fix(dashmate): advise a restart only when the disk copy can actually be served Comparing expiry dates was necessary and not sufficient. The wire sample carries a fingerprint and a date; whether the pair on disk matches its key, names this node's address, or is self-signed comes from the checks run over the files in the same collection - and those were not consulted. So doctor could report that no certificate in the bundle belongs to the private key and, in the same output, advise a restart that would load exactly that pair over a certificate the gateway is serving happily. A disk copy that outlived the served one but has itself expired got the same advice. The restart is now gated on the disk copy being newer, not itself expired, and not failing the installed-pair checks. Everything else points at obtaining a current certificate, which installs and signals without loading the file. Also corrects the read-only migration docblock, which still said every stale config is refused after only the two filesystem-touching migrations remained refused. Tests: 3 new, red before this commit - a key mismatch, a wrong address, and a newer-but-expired disk copy each drew restart advice. Co-Authored-By: Claude Opus 5 --- .../configFile/ConfigFileJsonRepository.js | 8 ++-- .../analyseGatewayCertificateFactory.js | 17 +++++--- .../analyseGatewayCertificateFactory.spec.js | 41 +++++++++++++++++++ 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js index f01796751e0..4f06c829427 100644 --- a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js +++ b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js @@ -255,10 +255,10 @@ export default class ConfigFileJsonRepository { * * @param {Object} [options={}] - passed through to read() * @param {boolean} [options.readOnly=false] - for a caller that has promised - * to change nothing. Reads a config file that is already current, and - * refuses outright when one is not: migrations move and delete files on - * disk, so running them on such a caller's behalf would break the promise - * and do it without the lock + * to change nothing. Migrations that only reshape data are applied in + * memory and discarded; the two that move and delete files on disk are + * refused outright, because running those on such a caller's behalf would + * break the promise and do it without the lock * @param {function(Config[]): void} [onMigrated] - runs before the migrated * config file is saved and while the lock is held * @returns {{configFile: ConfigFile}} diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 5cae7e6bb0a..1ae306682bf 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -123,16 +123,21 @@ ${restartHint(cfg)}`, const isServedExpired = servedExpiresAt <= now; const onDiskDiffers = served.matchesOnDisk === false; - // Which of the two is newer decides both the description and whether a - // restart is safe to advise, on either branch below. A restart makes the - // gateway load the disk copy, so advising one without checking can replace - // what is on the wire with something no better - or worse. + // A restart makes the gateway load whatever is on disk, so it may only be + // advised once the disk copy is known to be better on every count that + // matters. Outliving what is on the wire is necessary and nowhere near + // sufficient: the wire sample carries a fingerprint and a date, while + // whether the pair matches its key, names this address, or is self-signed + // comes from the checks run over the files in the same collection. const onDiskExpiresAt = served.onDisk ? new Date(served.onDisk.validTo).getTime() : null; const isOnDiskNewer = onDiskExpiresAt !== null && onDiskExpiresAt > servedExpiresAt; + const isOnDiskUsable = isOnDiskNewer + && onDiskExpiresAt > now + && (!installed || installed.status !== 'INVALID'); - if (isServedExpired && onDiskDiffers && isOnDiskNewer) { + if (isServedExpired && onDiskDiffers && isOnDiskUsable) { 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', @@ -161,7 +166,7 @@ ${restartHint(cfg)}`, SEVERITY.HIGH, )); } else if (onDiskDiffers) { - if (isOnDiskNewer) { + if (isOnDiskUsable) { // 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( diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index ecbf02db2b2..eb2b448988c 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -242,6 +242,47 @@ describe('analyseGatewayCertificateFactory', () => { }); }); + // A later expiry says nothing about whether the disk pair can be served. The + // sample carries only a fingerprint and a date; key pairing, address and + // self-signature come from the installed verdict, which is collected in the + // same run. Loading a later-expiring but unusable pair over a working one is + // the same outage the date comparison was added to prevent. + describe('when the disk copy is newer but not usable', () => { + [ + ['the pair does not match its key', 'KEY_MISMATCH'], + ['it names another address', 'IP_MISMATCH'], + ].forEach(([name, code]) => { + it(`should not advise a restart when ${name}`, () => { + samples.setServiceInfo('gateway', 'installedCertificate', { + status: 'INVALID', + reasons: [{ code, message: 'the installed pair is unusable' }], + warnings: [], + }); + + const problems = analyse(served({ + matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD', validTo: validTo(60) }, + })); + + problems.forEach((problem) => { + expect(problem.getSolution()).to.not.match(/dashmate restart/); + }); + }); + }); + + it('should not advise a restart when the disk copy has itself expired', () => { + const problems = analyse(served({ + certificate: { fingerprint256: 'AA:BB', validTo: validTo(-40) }, + matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD', validTo: validTo(-10) }, + })); + + problems.forEach((problem) => { + expect(problem.getSolution()).to.not.match(/dashmate restart/); + }); + }); + }); + // An external connect test measures whether something is listening, and // nothing listens on port 80 on a healthy node except for the seconds a // renewal takes. So it reports CLOSED on healthy nodes by construction, and From c920176866872a886018bde489bf8db974885290 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 18:37:19 +0700 Subject: [PATCH 30/63] fix(dashmate): require proof the disk pair is sound before advising a restart The usability gate was fail-open. It accepted an absent verdict, which is what every report collected by an older dashmate carries, and accepted a verdict that had merely stopped short of failing. It also never checked that the verdict was about the pair the wire probe measured - the two samples are taken moments apart, so a renewal landing between them means the file that was judged is not the file a restart would load. A restart is now advised only on an affirmative CHECKS_PASSED verdict whose fingerprint matches the one the probe recorded for the file on disk. Anything less - no verdict, a warning, a different pair - points at obtaining instead, which installs and signals without loading the file. The collected sample carries the fingerprint of the pair it judged so the correlation is possible at all. Two corrections to guidance written in the previous commit. The missing-artifact message said the obtain command would install what was already issued; it would not, because a file that was never written cannot be read back and the authority does not re-send it - running it again requests a replacement and spends the weekly limit a second time, which contradicts the sentence above it. And it carried the port-80 permanence block itself while the command prints the same block from its finally, so that one path said it twice. Tests: 3 new for the gate, red before this commit - an absent verdict, a warning verdict, and a verdict about another pair each drew restart advice. The two positive restart fixtures now supply the affirmative verdict they had been relying on the gate not to ask for. Plus two on the message and one at the command level pinning that permanence is stated exactly once on the path where both could print it. Co-Authored-By: Claude Opus 5 --- .../analyseGatewayCertificateFactory.js | 11 ++- .../tasks/doctor/collectSamplesTaskFactory.js | 7 ++ ...obtainLetsEncryptCertificateTaskFactory.js | 15 ++-- .../test/unit/commands/ssl/obtain.spec.js | 18 +++++ .../analyseGatewayCertificateFactory.spec.js | 80 ++++++++++++++++++- ...nLetsEncryptCertificateTaskFactory.spec.js | 13 ++- 6 files changed, 130 insertions(+), 14 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 1ae306682bf..287f640893e 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -133,9 +133,18 @@ ${restartHint(cfg)}`, ? new Date(served.onDisk.validTo).getTime() : null; const isOnDiskNewer = onDiskExpiresAt !== null && onDiskExpiresAt > servedExpiresAt; + // + // Fails closed. An absent verdict is not a passing one - a report collected + // by an older dashmate carries none at all - and neither is one that merely + // stopped short of failing. The verdict must also be about the pair the + // probe measured: the two samples are taken moments apart, and a renewal + // landing between them means the file that was judged is not the file that + // would be loaded. const isOnDiskUsable = isOnDiskNewer && onDiskExpiresAt > now - && (!installed || installed.status !== 'INVALID'); + && installed?.status === 'CHECKS_PASSED' + && Boolean(installed.fingerprint256) + && installed.fingerprint256 === served.onDisk?.fingerprint256; if (isServedExpired && onDiskDiffers && isOnDiskUsable) { problems.push(new Problem( diff --git a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js index 58d899b9193..3edda30b13d 100644 --- a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js @@ -256,6 +256,13 @@ export default function collectSamplesTaskFactory( validTo: verdict.installed ? verdict.installed.validTo.toUTCString() : null, + // Which pair was judged. The wire probe records the same + // fingerprint for the file it read, so an analyser can tell + // whether the two samples describe the same certificate + // before acting on the verdict. + fingerprint256: verdict.installed + ? verdict.installed.fingerprint256 + : null, }; // A problem with the files names the file it could not read, diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index 8eb508fdb11..1af98280936 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -115,16 +115,15 @@ file it should have written: ${missingPath} -The certificate exists and counts against this node's issuance limit - five -per address per week - so obtaining another one is not free and will not fix -this. The problem is local: check the disk for space and permissions, and that -the helper is allowed to write there. +That certificate counted against this node's issuance limit - five per address +per week - and it cannot be recovered: a file that was never written cannot be +read back, and the authority does not re-send it. Running the command again +requests a replacement, which spends the limit a second time. -Once that is sorted, install what was already issued: +So fix the local cause first. Check the disk for space and for permissions, and +that the helper is allowed to write there. Only then: - dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider letsencrypt - -${PORT_80_PERMANENCE}`; + dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider letsencrypt`; } /** diff --git a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js index 25357929ee2..f54cf008e3c 100644 --- a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js +++ b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js @@ -212,6 +212,24 @@ describe('SSL obtain command', () => { expect(stderr).to.contain('LEAVE PORT 80 OPEN'); }); + // The permanence block belongs to the command, so a task that also carries it + // would print it twice on the one path where both run. + it('should state permanence once when the issued files never landed', async function it() { + const dependencies = obtainDependencies(this.sinon); + dependencies.obtainTask = this.sinon.stub().callsFake(() => new Listr([{ + task: (ctx) => { + ctx.certificateObtained = true; + + throw new Error('Let\'s Encrypt issued a certificate, but dashmate could not find' + + ' the file it should have written'); + }, + }])); + + await expect(runObtain(dependencies)).to.be.rejected(); + + expect(stderr.split('LEAVE PORT 80 OPEN')).to.have.lengthOf(2); + }); + // Nothing was issued, so there is nothing to warn about and a cron run stays // quiet. it('should stay silent when no new certificate was obtained', async function it() { diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index eb2b448988c..db054694d54 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -28,6 +28,21 @@ describe('analyseGatewayCertificateFactory', () => { return analyseGatewayCertificate(samples); } + /** + * Record that the files on disk were judged sound, for the exact pair the + * wire probe sampled. + * + * @param {string} fingerprint256 + */ + function installedIsUsable(fingerprint256) { + samples.setServiceInfo('gateway', 'installedCertificate', { + status: 'CHECKS_PASSED', + reasons: [], + warnings: [], + fingerprint256, + }); + } + /** * @param {Object} overrides * @return {Object} @@ -74,8 +89,11 @@ describe('analyseGatewayCertificateFactory', () => { }); it('should distinguish a certificate that was renewed but never reached the gateway', () => { - // Renewed means the disk copy outlives the served one. Leaving that out - // let the branch claim a direction it had never checked. + // Renewed means the disk copy outlives the served one, and that it was + // judged sound. Leaving either out let the branch claim a direction and a + // safety it had never checked. + installedIsUsable('CC:DD'); + const problems = analyse(served({ certificate: { fingerprint256: 'AA:BB', validTo: validTo(-2) }, matchesOnDisk: false, @@ -88,8 +106,10 @@ describe('analyseGatewayCertificateFactory', () => { }); it('should warn before the outage when a renewed certificate has not been picked up', () => { - // Renewed means the disk copy outlives the served one; that is what makes - // a restart the right advice here. + // Renewed means the disk copy outlives the served one and was judged + // sound; together that is what makes a restart the right advice here. + installedIsUsable('CC:DD'); + const problems = analyse(served({ matchesOnDisk: false, onDisk: { fingerprint256: 'CC:DD', validTo: validTo(60) }, @@ -169,6 +189,8 @@ describe('analyseGatewayCertificateFactory', () => { // prevent, produced by its own remediation. describe('when the served and on-disk certificates differ', () => { it('should advise a restart only when the disk copy is the newer one', () => { + installedIsUsable('CC:DD'); + const problems = analyse(served({ matchesOnDisk: false, onDisk: { fingerprint256: 'CC:DD', validTo: validTo(60) }, @@ -208,6 +230,8 @@ describe('analyseGatewayCertificateFactory', () => { // will fix it. describe('when the served certificate has expired and the disk copy differs', () => { it('should advise a restart only when the disk copy really is newer', () => { + installedIsUsable('CC:DD'); + const [problem] = analyse(served({ certificate: { fingerprint256: 'AA:BB', validTo: validTo(-1) }, matchesOnDisk: false, @@ -242,6 +266,54 @@ describe('analyseGatewayCertificateFactory', () => { }); }); + // A verdict that is absent, merely not-invalid, or about a different pair is + // not evidence that the file is safe to load. A report collected by an older + // dashmate carries no verdict at all, and a renewal landing between the two + // samples means the pair judged is not the pair measured. + describe('when the disk copy cannot be shown to be usable', () => { + it('should not advise a restart when nothing judged the pair', () => { + const problems = analyse(served({ + matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD', validTo: validTo(60) }, + })); + + problems.forEach((problem) => { + expect(problem.getSolution()).to.not.match(/dashmate restart/); + }); + }); + + it('should not advise a restart when the verdict only fell short of failing', () => { + samples.setServiceInfo('gateway', 'installedCertificate', { + status: 'WARN', + reasons: [], + warnings: [{ code: 'PROVIDER_MISMATCH', message: 'issuer disagrees' }], + fingerprint256: 'CC:DD', + }); + + const problems = analyse(served({ + matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD', validTo: validTo(60) }, + })); + + problems.forEach((problem) => { + expect(problem.getSolution()).to.not.match(/dashmate restart/); + }); + }); + + it('should not advise a restart when the verdict judged a different pair', () => { + installedIsUsable('EE:FF'); + + const problems = analyse(served({ + matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD', validTo: validTo(60) }, + })); + + problems.forEach((problem) => { + expect(problem.getSolution()).to.not.match(/dashmate restart/); + }); + }); + }); + // A later expiry says nothing about whether the disk pair can be served. The // sample carries only a fingerprint and a date; key pairing, address and // self-signature come from the installed verdict, which is collected in the diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js index 745fa0848ad..a1a987aa619 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js @@ -545,7 +545,18 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { // Issued once, and only once. expect(docker.createContainer).to.have.been.calledOnce(); - expect(error.message).to.match(/was issued|counts against/i); + // The issuance is the fact the operator needs: it happened, and it cost + // one of the five this address gets each week. + expect(error.message).to.match(/issued a certificate/i); + expect(error.message).to.match(/issuance limit/i); + + // Rerunning obtains a replacement, because files that were never written + // cannot be recovered. Saying it reinstalls what already exists would + // contradict the sentence above it about the limit. + expect(error.message).to.not.match(/install what was already issued/i); + + // Printed once, by the command, not also here. + expect(error.message).to.not.contain('LEAVE PORT 80 OPEN'); expect(error.message).to.not.contain('PAUSED'); expect(error.message).to.not.contain('failed attempts are shared'); expect(error.message).to.not.match(/did not obtain a certificate after/i); From 8d1edec71decd6218f36a7f3d2cd8d43c307ee38 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 18:47:28 +0700 Subject: [PATCH 31/63] test(dashmate): pin that the verdict and the wire sample describe one file Doctor will only advise loading the file on disk over a certificate the gateway is serving when the checks that judged that file describe the same file the probe measured. Both fingerprints were being produced and compared, and nothing held them to agreeing. They come from different selectors: the probe takes the first non-CA block in the bundle, the checks take the block whose key material matches the installed private key. For an ordinary pair those are the same certificate, which is why the comparison works - but that is a property, not an assumption, and it was untested end to end. Had it stopped holding, the gate would have failed closed and the advice would simply have stopped being given, silently, on exactly the case it was added for. Driven through the real collection - a planted pair, a live TLS listener, the production probe and the production checks - rather than the synthetic sample the analyser tests use. Verified it can fail by having the collector record a different fingerprint. Co-Authored-By: Claude Opus 5 --- .../doctor/collectSamplesTaskFactory.spec.js | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) 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 dc1b763bd3d..2a85af1d032 100644 --- a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js @@ -239,6 +239,50 @@ describe('collectSamplesTaskFactory', () => { }); }); + // Doctor will only advise loading the file over a working served certificate + // when the checks that judged the file describe the same file the probe + // measured. The two fingerprints come from different selectors - the probe + // takes the first non-CA block, the checks take the block matching the + // private key - so that they agree for an ordinary pair is a property worth + // holding, not an assumption. If it ever stops holding the advice silently + // stops being given. + it('should record the same certificate in the verdict and the wire sample', async () => { + const { cert, key } = createCertificateForTest({ ip: EXTERNAL_IP, days: 30 }); + const sslDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'ssl'); + + fs.writeFileSync(path.join(sslDir, 'bundle.crt'), cert, 'utf8'); + fs.writeFileSync(path.join(sslDir, 'private.key'), key, '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); }); + + 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); }); + } + + const installed = samples.getServiceInfo('gateway', 'installedCertificate'); + const servedSample = samples.getServiceInfo('gateway', 'servedCertificate'); + + expect(installed.fingerprint256).to.be.a('string'); + expect(servedSample.onDisk.fingerprint256).to.equal(installed.fingerprint256); + }); + it('should collect the certificate the gateway actually serves', async () => { const { cert, key } = createCertificateForTest({ ip: EXTERNAL_IP, days: 30 }); From 1371288d30780a110dfe8aaf0a25690e12f080c8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 18:54:50 +0700 Subject: [PATCH 32/63] fix(dashmate): say what failed the check, and stop tests inheriting ambient state Three findings from the review pass that need no decision. The two messages for a disk copy that cannot be shown safe to load said it was not the newer of the two. It often is newer - the objection may be that it has expired, that nothing judged it, that the judgement was about another pair, or that the pair failed its checks - so denying it is newer replaces one wrong claim with another. They say it is not known to be a usable replacement now, which is what was actually established. The unattended `ssl obtain` test inherited the runner's terminal state, so run from a developer's terminal with CI unset it would have proved nothing about the path it is named for. Both streams are set and restored now, and the terminal case passes the command's real --no-retry default rather than the helper's test-only one, since the point is that interactivity does not come from that flag. The masking tests deleted USER and never put it back, leaving it deleted for every later test in the process. Saved and restored around each. Tests: 1 new, red before this commit. 588 unit passing, 0 lint errors. Co-Authored-By: Claude Opus 5 --- .../analyseGatewayCertificateFactory.js | 10 +++++----- .../test/unit/commands/ssl/obtain.spec.js | 18 ++++++++++++++++-- .../analyseGatewayCertificateFactory.spec.js | 15 +++++++++++++++ .../doctor/collectSamplesTaskFactory.spec.js | 10 ++++++++++ 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 287f640893e..016b6e9ed7c 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -157,7 +157,7 @@ ${restartHint(cfg)}`, } else if (isServedExpired && onDiskDiffers) { problems.push(new Problem( `The gateway is serving a certificate that expired on ${served.certificate.validTo}. ` - + 'The copy on disk is a different one, and it is no newer', + + 'The copy on disk is a different one, and is not known to be a usable replacement', chalk`Neither the certificate on the wire nor the one on disk is usable, so restarting Platform would not help. Obtain a current certificate, which installs it and signals the gateway: @@ -188,10 +188,10 @@ ${restartHint(cfg)}`, } else { problems.push(new Problem( 'The gateway is serving a different certificate from the one on disk, and the one ' - + 'on disk is not the newer of the two', - chalk`Whatever is on the wire is currently the better of the two, so do not restart -Platform to load the file - that would replace it with the older one. Obtain a -current certificate first, which also installs it and signals the gateway: + + 'on disk is not known to be a usable replacement for it', + chalk`What is on the wire is working and the file has not been shown to be a safe +replacement, so do not restart Platform to load it. Obtain a current +certificate instead, which installs it and signals the gateway: {bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, SEVERITY.HIGH, )); diff --git a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js index f54cf008e3c..32a43521ef7 100644 --- a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js +++ b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js @@ -244,10 +244,22 @@ describe('SSL obtain command', () => { // too. Its --no-retry defaults to false, which would turn an obtain run from // cron into a hang if the flag were what decided whether to prompt. it('should not offer to prompt when run without a terminal', async function it() { + // Set rather than inherited: run from a developer's terminal with CI + // unset, the ambient streams are terminals and this stops proving + // anything about the unattended path. + const restore = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }; + delete process.stdin.isTTY; + delete process.stdout.isTTY; + const dependencies = obtainDependencies(this.sinon); const context = captureContext(this.sinon, dependencies); - await runObtain({ ...dependencies, 'no-retry': false }); + try { + await runObtain({ ...dependencies, 'no-retry': false }); + } finally { + process.stdin.isTTY = restore.stdin; + process.stdout.isTTY = restore.stdout; + } expect(context.interactive).to.equal(false); }); @@ -276,7 +288,9 @@ describe('SSL obtain command', () => { }; try { - await runObtain(dependencies); + // The command's own default, not the helper's test-only one, since the + // point is that interactivity does not come from this flag. + await runObtain({ ...dependencies, 'no-retry': false }); } finally { this.restoreStreams(); } diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index db054694d54..0e3d17b80ff 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -314,6 +314,21 @@ describe('analyseGatewayCertificateFactory', () => { }); }); + // The disk copy can fail the usability gate while genuinely being the newer + // of the two, so saying it is not newer would be a second wrong claim in + // place of the one that was removed. + it('should not deny the disk copy is newer when the objection is something else', () => { + installedIsUsable('EE:FF'); + + const [problem] = analyse(served({ + matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD', validTo: validTo(60) }, + })); + + expect(problem.getDescription()).to.not.match(/is no newer|not the newer/i); + expect(problem.getSolution()).to.not.match(/dashmate restart/); + }); + // A later expiry says nothing about whether the disk pair can be served. The // sample carries only a fingerprint and a date; key pairing, address and // self-signature come from the installed verdict, which is collected in the 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 2a85af1d032..0bad62c5055 100644 --- a/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js @@ -51,6 +51,7 @@ describe('collectSamplesTaskFactory', () => { let samples; let dockerCompose; let rpcClient; + let originalUser; /** * Run the sample collection the same way the doctor command does: as a subtask @@ -68,6 +69,7 @@ describe('collectSamplesTaskFactory', () => { } beforeEach(function beforeEach() { + originalUser = process.env.USER; homeDir = HomeDir.createTemp(); config = getBaseConfigFactory()(); @@ -131,6 +133,14 @@ describe('collectSamplesTaskFactory', () => { afterEach(() => { homeDir.remove(); + + // The masking cases below mutate and delete USER, and one of them leaves it + // deleted for every later test in the process otherwise. + if (originalUser === undefined) { + delete process.env.USER; + } else { + process.env.USER = originalUser; + } }); it('should report a problem for a ZeroSSL certificate that expired months ago', async () => { From 0623347f7e9a3b4b06d6ff9414fce54bbdc069a0 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 17:12:41 +0700 Subject: [PATCH 33/63] fix(dashmate): drop the removed Commit override for prerelease configs A config records the version of the build that wrote it, and a development build records its own prerelease version. Semver orders `4.2.0-dev.1` above `4.1.1`, so the migration keyed `4.1.1` -- the one that deletes the Tenderdash `unsafeOverride.commit` block this release removed from the base config and the schema -- never runs for a config a development build stamped. The schema accepts no property it does not define, so the override survives and every command dies before it starts: InvalidConfigFileFormatError: config/platform/drive/tenderdash/consensus/ unsafeOverride must NOT have additional properties That is every devnet and internal testnet node, i.e. the whole population this branch is validated on; it was hit on hp-masternode-2 (devnet-moutai), where the config had to be edited by hand before any command would run. Mainnet and testnet operators are unaffected -- v4.1.1 shipped the same migration and their configs crossed it there. It breaks both ways: the installed build requires `commit` while the newer code forbids it, so once either has run the other cannot load the config. Repeat the deletion from the `4.2.0` key, which every `4.2.0-dev.N` stamp is below, via a shared helper that carries the reason. Deleting a key that is not there does nothing, so the release lineage is unchanged. The `4.1.1` migration keeps doing everything it did, so a config stamped at or below `4.1.0` is unaffected too. Test would have caught this in CI: should load a config a development build stamped with its own prerelease version before fix: FAIL - "migrated base config does not load: config/platform/drive/ tenderdash/consensus/unsafeOverride must NOT have additional properties" after fix: PASS yarn workspace dashmate test:unit: 359 passing. lint: 0 errors. --- .../configs/getConfigFileMigrationsFactory.js | 34 +++++++++++---- .../migrateConfigFileFactory.spec.js | 42 +++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/dashmate/configs/getConfigFileMigrationsFactory.js b/packages/dashmate/configs/getConfigFileMigrationsFactory.js index e6deeb3197b..7424c517c98 100644 --- a/packages/dashmate/configs/getConfigFileMigrationsFactory.js +++ b/packages/dashmate/configs/getConfigFileMigrationsFactory.js @@ -75,6 +75,27 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) } } + /** + * Drop the Tenderdash Commit timeout overrides Tenderdash itself removed. + * + * The config schema stopped defining them, and it accepts no property it + * does not define, so a config that still carries them cannot be loaded at + * all. + * + * Called from more than one migration on purpose. A config records the + * version of the build that wrote it, and a development build records its + * own prerelease version - which semver orders above a key named after an + * earlier patch release. A config stamped that way skips such a key + * entirely, so a deletion the schema depends on has to be repeated at a key + * above every stamp still in the field. Repeating it costs nothing: removing + * a key that is not there does nothing. + * + * @param {Object} options - one config's options + */ + function dropRemovedTenderdashCommitOverride(options) { + delete options.platform?.drive?.tenderdash?.consensus?.unsafeOverride?.commit; + } + function getDefaultConfigByNetwork(network) { if (network === NETWORK_MAINNET) { return defaultConfigs.get('mainnet'); @@ -1715,11 +1736,12 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) return configFile; }, '4.2.0': (configFile) => { - // The ACME directory certificates are requested from became - // configurable. Existing configs have no value for it, and the schema - // requires one, so fill in the directory they were already using. Object.entries(configFile.configs) .forEach(([, options]) => { + // Also done by the 4.1.1 migration, which a config written by a + // development build of this release is stamped above and skips. + dropRemovedTenderdashCommitOverride(options); + const providerConfigs = options.platform?.gateway?.ssl?.providerConfigs; if (providerConfigs?.letsencrypt @@ -1769,11 +1791,7 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) rsDapiDocker.image = base.get('platform.dapi.rsDapi.docker.image'); } - // The Commit timeout and BypassCommitTimeout overrides no longer - // exist in Tenderdash, which now only warns when they are set. - // Drop them: the config schema accepts no properties it does not - // define, so a config that kept them would fail validation. - delete options.platform?.drive?.tenderdash?.consensus?.unsafeOverride?.commit; + dropRemovedTenderdashCommitOverride(options); }); return configFile; diff --git a/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js index 13bc9a061f1..2fd90a11eb5 100644 --- a/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js +++ b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import { STOCK_PRERELEASE_IDS } from '../../../../src/config/stockImages.js'; +import Config from '../../../../src/config/Config.js'; import HomeDir from '../../../../src/config/HomeDir.js'; import { PACKAGE_ROOT_DIR } from '../../../../src/constants.js'; import createDIContainer from '../../../../src/createDIContainer.js'; @@ -266,6 +267,47 @@ describe('migrateConfigFileFactory', () => { } }); + it('should load a config a development build stamped with its own prerelease version', async () => { + // A development build records its own package version in the config, so + // every node running one is stamped at a prerelease of the next release. + // Semver orders such a stamp above a key named after an earlier patch + // release, so a migration keyed there is skipped for exactly these configs. + // + // The migration that drops the Tenderdash Commit timeout overrides is keyed + // that way. The schema stopped defining them and accepts no property it does + // not define, so a config that keeps them cannot be loaded at all - which is + // every node running a development build, the population these changes are + // validated on. + const FROM_VERSION = '4.2.0-dev.1'; + const { version } = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT_DIR, 'package.json'), 'utf8')); + + const configFileData = createConfigFile().toObject(); + configFileData.configFormatVersion = FROM_VERSION; + for (const options of Object.values(configFileData.configs)) { + // The shape the base config carried while these overrides still existed. + options.platform.drive.tenderdash.consensus.unsafeOverride.commit = { + timeout: null, + bypass: null, + }; + } + + const migrated = migrateConfigFile(configFileData, FROM_VERSION, version); + + for (const [name, options] of Object.entries(migrated.configs)) { + let loadError = null; + try { + // Loading is what fails in production, so that is what is asserted + // rather than the absence of the key on its own. + new Config(name, options); + } catch (e) { + loadError = e; + } + + expect(loadError, `migrated ${name} config does not load: ${loadError?.message}`) + .to.equal(null); + } + }); + it('should keep an operator image that predates the 4.0.0 re-pin', async () => { // Every config older than 4.0.0 crosses the unconditional re-pin in that // migration, so it is the first place operator intent can be respected. It From 7d83d1dd20be1425bab0c05c7ed9099d25280433 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 19:49:56 +0700 Subject: [PATCH 34/63] fix(dashmate): stop prescribing a fix that cannot work for a hijacked port When the certificate served on the DAPI port does not name this node's address, the connection did not reach this node's gateway - another dashmate config, a reverse proxy, or a second node sharing the address answered instead. The code says so in its own comment, and then told the operator to regenerate the certificate and restart Platform. Neither helps. A new certificate installs on a gateway nobody is reaching, the port stays taken, and the operator has taken an outage to end up exactly where they started. The first thing they need is to find out what is answering. So the remedy now leads with that, naming the port and the three things that usually hold it, and offers reissuing only on the condition that this node's gateway turns out to be what answered - which is the one case where a wrong address is the whole problem. The restart is dropped rather than moved. `dashmate ssl obtain` installs the pair and signals the gateway, and the signal reaches Envoy's hot-restarter, which re-execs Envoy against the same configuration without touching the container - measured against a live gateway rather than inferred. That reasoning now sits with the restart hint itself, which stays because one remaining branch earns it: the incomplete-chain case asks the operator to edit the bundle by hand, and nothing signals the gateway after that. Tests: 2 new, red before this commit - the remedy carried a restart and offered reissuing unconditionally. Co-Authored-By: Claude Opus 5 --- .../analyseGatewayCertificateFactory.js | 22 ++++++++++++--- .../analyseGatewayCertificateFactory.spec.js | 27 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 016b6e9ed7c..a284b527091 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -11,6 +11,12 @@ import renderConfigFlag from '../../util/renderConfigFlag.js'; * The node is named because a report is read against one config among several, and a command * pasted without one acts on whichever happens to be the default. * + * Only for a remedy that changes the files by hand. Anything routed through + * `dashmate ssl obtain` needs no restart: that command installs the pair and + * signals the gateway, and the signal reaches Envoy's hot-restarter, which + * re-execs Envoy against the same configuration without touching the + * container. Measured against a live gateway, not inferred. + * * @param {string} cfg * @return {string} */ @@ -107,12 +113,20 @@ Obtain a new certificate - it signals the gateway itself, so no restart is neede // 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) { + // No restart here, and no unconditional reissue. If something else is + // answering on that port, a new certificate installs on a gateway nobody + // is reaching and the port stays taken - the operator would take an + // outage and still have the problem. Reissuing is the remedy only once + // this node's gateway is known to be what answered. 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 ${cfg} --force} -${restartHint(cfg)}`, + chalk`Something other than this node's gateway may be answering on that port, or the +certificate is issued for the wrong address. Find what is listening on ${served.port} +first - another dashmate config, a reverse proxy, or a second node sharing the +address. + +If this node's gateway is the one answering and the address is simply wrong: +{bold.cyanBright dashmate ssl obtain ${cfg} --force}`, 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 0e3d17b80ff..40aa063eb06 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -329,6 +329,33 @@ describe('analyseGatewayCertificateFactory', () => { expect(problem.getSolution()).to.not.match(/dashmate restart/); }); + // This branch means the connection did not reach this node's gateway at all - + // another config, a proxy, or a second node is answering on that port. So + // reissuing the certificate and restarting Platform fixes nothing: the port + // is still taken, and the operator has bought an outage for it. + describe('when the connection did not reach this node', () => { + const hijacked = () => served({ + identityVerified: false, + identityError: 'Host: 198.51.100.7. is not in the cert\'s altnames', + }); + + it('should send the operator to find what is answering, not to restart', () => { + const [problem] = analyse(hijacked()); + + expect(problem.getSolution()).to.not.match(/dashmate restart/); + expect(problem.getSolution()).to.contain('what is listening on 443'); + }); + + // Reissuing is only the remedy once the gateway is known to be the thing + // answering, so it is offered on that condition rather than as the step. + it('should offer reissuing only once the gateway is known to be answering', () => { + const [problem] = analyse(hijacked()); + + expect(problem.getSolution()).to.contain('If this node\'s gateway is the one answering'); + expect(problem.getSolution()).to.contain('dashmate ssl obtain --config base --force'); + }); + }); + // A later expiry says nothing about whether the disk pair can be served. The // sample carries only a fingerprint and a date; key pairing, address and // self-signature come from the installed verdict, which is collected in the From 2b33981c156908424c3c04ce2d927e23b5ec4b4f Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 20:28:46 +0700 Subject: [PATCH 35/63] fix(dashmate): require the key's certificate to be the bundle's first block Envoy reads the chain file in order and serves the first block as the leaf. A bundle written root-first is therefore broken at the gateway no matter how well its contents pair up - but the checker searched every block for the one matching private.key, found it, and reported that the checks had passed on a bundle the gateway cannot serve. Identifying the leaf by SPKI match is kept exactly as it is. That rule exists so an ordinary chain carrying its own self-signed root is not misjudged as a self-signed certificate, and it decides WHICH block is tested. Position is a separate requirement: the block the key matches must also be block one. Both now hold, and the reason names the position it was actually found at. Unparseable blocks are no longer skipped either. The gateway loads this same file, so a block Envoy will choke on is a problem with the bundle even when a usable leaf sits beside it; the read now fails and says which block and why. The two parametrised tests asserting root-first bundles are acceptable were wrong. They are inverted rather than deleted, so the ordering requirement is pinned where the opposite claim used to sit. Tests: 3 new, red before this commit; 2 inverted. Co-Authored-By: Claude Opus 5 --- .../src/ssl/checkGatewayCertificateFactory.js | 12 ++- .../dashmate/src/ssl/selectLeafCertificate.js | 62 ++++++++------ .../checkGatewayCertificateFactory.spec.js | 82 ++++++++++++++----- 3 files changed, 110 insertions(+), 46 deletions(-) diff --git a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js index 27bc6a153f7..de1033a12eb 100644 --- a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js +++ b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js @@ -20,6 +20,7 @@ export const CERTIFICATE_STATUS = { export const CERTIFICATE_REASONS = { BUNDLE_MISSING: 'BUNDLE_MISSING', BUNDLE_UNREADABLE: 'BUNDLE_UNREADABLE', + BUNDLE_ORDER: 'BUNDLE_ORDER', KEY_MISSING: 'KEY_MISSING', KEY_UNUSABLE: 'KEY_UNUSABLE', KEY_MISMATCH: 'KEY_MISMATCH', @@ -212,7 +213,16 @@ export default function checkGatewayCertificateFactory(homeDir) { if (error === LEAF_SELECTION_ERRORS.BUNDLE_UNREADABLE) { reasons.push({ code: CERTIFICATE_REASONS.BUNDLE_UNREADABLE, - message: `dashmate could not read any certificate from ${bundleFilePath}`, + message: `dashmate could not read ${bundleFilePath}: ${detail}`, + }); + + return verdict(); + } + + if (error === LEAF_SELECTION_ERRORS.BUNDLE_ORDER) { + reasons.push({ + code: CERTIFICATE_REASONS.BUNDLE_ORDER, + message: `The certificates in ${bundleFilePath} are in the wrong order: ${detail}`, }); return verdict(); diff --git a/packages/dashmate/src/ssl/selectLeafCertificate.js b/packages/dashmate/src/ssl/selectLeafCertificate.js index ba200772ad2..59c5efa0235 100644 --- a/packages/dashmate/src/ssl/selectLeafCertificate.js +++ b/packages/dashmate/src/ssl/selectLeafCertificate.js @@ -4,6 +4,7 @@ export const LEAF_SELECTION_ERRORS = { KEY_UNUSABLE: 'KEY_UNUSABLE', BUNDLE_UNREADABLE: 'BUNDLE_UNREADABLE', KEY_MISMATCH: 'KEY_MISMATCH', + BUNDLE_ORDER: 'BUNDLE_ORDER', }; const PEM_CERTIFICATE = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; @@ -29,15 +30,18 @@ function exportSubjectPublicKeyInfo(publicKey) { /** * Find the certificate in a bundle that belongs to a private key. * - * The leaf is the block whose public key material is the key's own. That single - * rule does three jobs: it finds the leaf whichever way round the bundle is - * written, it is itself the pairing check, and comparing key material rather - * than verifying an RSA signature works for every key type an authority might - * issue. + * The leaf is the block whose public key material is the key's own. Comparing + * key material is both how the leaf is recognised and the pairing check itself, + * and unlike verifying an RSA signature it works for every key type an + * authority might issue. Self-sign testing every block to find the leaf would + * instead reject any chain carrying its own root, which an ordinary publicly + * trusted bundle does. * - * Selecting by position gets one bundle order wrong, and self-sign testing - * every block to find the leaf rejects any chain that carries its own root - - * which an ordinary publicly trusted bundle does. + * Identifying it that way does not make its position free. Envoy reads the + * chain file in order and serves the first block as the leaf, so a bundle + * written the other way round is broken at the gateway however well its + * contents pair up. The key's certificate is therefore required to be the first + * block as well as to exist. * * @param {string} bundlePem * @param {string} privateKeyPem @@ -64,34 +68,46 @@ export default function selectLeafCertificate(bundlePem, privateKeyPem) { return { error: LEAF_SELECTION_ERRORS.KEY_UNUSABLE, detail: 'its key material could not be read' }; } - const certificates = (bundlePem.match(PEM_CERTIFICATE) ?? []) - .map((block) => { - try { - return new crypto.X509Certificate(block); - } catch { - // A block that will not parse is skipped rather than failing the whole - // bundle, which may hold comments or a stray key. - return null; - } - }) - .filter(Boolean); + const blocks = bundlePem.match(PEM_CERTIFICATE) ?? []; - if (certificates.length === 0) { + if (blocks.length === 0) { return { error: LEAF_SELECTION_ERRORS.BUNDLE_UNREADABLE, detail: 'it holds no certificate' }; } - const leaf = certificates.find((certificate) => { + const certificates = []; + for (let position = 0; position < blocks.length; position += 1) { + try { + certificates.push(new crypto.X509Certificate(blocks[position])); + } catch (e) { + // Not skipped. The gateway loads this same file, so a block it will choke + // on is a problem with the bundle even when a usable leaf sits beside it. + return { + error: LEAF_SELECTION_ERRORS.BUNDLE_UNREADABLE, + detail: `certificate ${position + 1} of ${blocks.length} could not be parsed: ${e.message}`, + }; + } + } + + const position = certificates.findIndex((certificate) => { const spki = exportSubjectPublicKeyInfo(certificate.publicKey); return spki !== null && spki.equals(subjectPublicKeyInfo); }); - if (!leaf) { + if (position === -1) { return { error: LEAF_SELECTION_ERRORS.KEY_MISMATCH, detail: 'no certificate in the bundle belongs to the private key', }; } - return { leaf }; + if (position !== 0) { + return { + error: LEAF_SELECTION_ERRORS.BUNDLE_ORDER, + detail: `the certificate belonging to the private key is block ${position + 1}` + + ` of ${certificates.length}, and the gateway serves the first block as the leaf`, + }; + } + + return { leaf: certificates[0] }; } diff --git a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js index 5a9749c485c..1c9dcb753b2 100644 --- a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js @@ -61,35 +61,73 @@ describe('checkGatewayCertificateFactory', () => { const codes = (list) => list.map(({ code }) => code); describe('leaf identification', () => { - // A bundle can be written either way round, and an operator supplying their - // own routinely writes the root first. Selecting the leaf by position gets - // one of the two orders wrong every time. - ['leaf-first', 'root-first'].forEach((order) => { - it(`should identify the leaf by its key in a ${order} bundle`, () => { - const { leaf, intermediate, root } = issueChain({ ip: EXTERNAL_IP }); - const blocks = [leaf.pem, intermediate.pem, root.pem]; + // The leaf is identified by matching key material rather than by position, + // because that is the check that says which block belongs to private.key + // and it works for every key type an authority might issue. + it('should identify the leaf by its key material', () => { + const { leaf, intermediate, root } = issueChain({ ip: EXTERNAL_IP }); - install((order === 'leaf-first' ? blocks : [...blocks].reverse()).join(''), leaf.keyPem); + install(leaf.pem + intermediate.pem + root.pem, leaf.keyPem); - const verdict = checkGatewayCertificate(config); + const verdict = checkGatewayCertificate(config); - expect(verdict.status).to.equal(CERTIFICATE_STATUS.CHECKS_PASSED); - expect(verdict.installed.fingerprint256).to.be.a('string'); - }); + expect(verdict.status).to.equal(CERTIFICATE_STATUS.CHECKS_PASSED); + expect(verdict.installed.fingerprint256).to.be.a('string'); + }); - // An ordinary public chain contains a self-signed root. Testing every - // block for self-signature rejects every valid paid chain outright. - it(`should accept a chain containing a self-signed root, ${order}`, () => { - const { leaf, intermediate, root } = issueChain({ ip: EXTERNAL_IP }); - const blocks = [leaf.pem, intermediate.pem, root.pem]; + // An ordinary public chain contains a self-signed root. Only the block that + // matches the key is self-sign tested, so carrying a root is not mistaken + // for the certificate itself being self-signed. + it('should accept a chain containing a self-signed root', () => { + const { leaf, intermediate, root } = issueChain({ ip: EXTERNAL_IP }); - install((order === 'leaf-first' ? blocks : [...blocks].reverse()).join(''), leaf.keyPem); + install(leaf.pem + intermediate.pem + root.pem, leaf.keyPem); - const verdict = checkGatewayCertificate(config); + const verdict = checkGatewayCertificate(config); - expect(codes(verdict.reasons)).to.deep.equal([]); - expect(verdict.status).to.equal(CERTIFICATE_STATUS.CHECKS_PASSED); - }); + expect(codes(verdict.reasons)).to.deep.equal([]); + expect(verdict.status).to.equal(CERTIFICATE_STATUS.CHECKS_PASSED); + }); + + // Envoy reads the chain file in order and serves the first block as the + // leaf. A bundle written the other way round is therefore broken at the + // gateway however well its contents pair up, so finding the key's + // certificate further down is a finding rather than a pass. + it('should block on a bundle whose leaf is not the first block', () => { + const { leaf, intermediate, root } = issueChain({ ip: EXTERNAL_IP }); + + install(root.pem + intermediate.pem + leaf.pem, leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.include(CERTIFICATE_REASONS.BUNDLE_ORDER); + }); + + it('should name the position the key-matching certificate was found at', () => { + const { leaf, intermediate, root } = issueChain({ ip: EXTERNAL_IP }); + + install(root.pem + intermediate.pem + leaf.pem, leaf.keyPem); + + const [reason] = checkGatewayCertificate(config).reasons + .filter(({ code }) => code === CERTIFICATE_REASONS.BUNDLE_ORDER); + + expect(reason.message).to.contain('3'); + }); + + // A block Envoy will choke on is not something to pass over quietly. The + // bundle is what the gateway loads, so an unparseable block in it is a + // problem with the bundle whether or not a usable leaf sits beside it. + it('should block on a bundle holding a certificate block that will not parse', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + const corrupt = '-----BEGIN CERTIFICATE-----\nbm90IGEgY2VydGlmaWNhdGU=\n-----END CERTIFICATE-----\n'; + + install(leaf.pem + corrupt + intermediate.pem, leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.include(CERTIFICATE_REASONS.BUNDLE_UNREADABLE); }); // An operator's own self-signed certificate is usually marked as a CA. From 3aceeebcc07858ed757f48daad82d626874eaabf Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 20:29:29 +0700 Subject: [PATCH 36/63] fix(dashmate): match the node address against the SAN only The identity check fell back to the common name when the leaf carried no IP subject alternative name. Nothing that connects to this node does that: Node's own tls.checkServerIdentity does not consult the common name for an IP identifier, and neither do browsers. So a certificate carrying the address only in its subject was reported as having passed the checks while every real client rejected it - the exact class of false pass the status name was changed to stop making. The common name is no longer consulted, and the message now distinguishes a certificate issued for some other address from one carrying no address at all. No settled decision is being reversed here; the fallback was never specified. The test asserting the fallback was itself wrong. It is inverted rather than deleted, so the behaviour stays pinned in the direction it should have been. Tests: 1 new, 1 inverted, both red before this commit. Co-Authored-By: Claude Opus 5 --- .../src/ssl/checkGatewayCertificateFactory.js | 34 +++++++------------ .../checkGatewayCertificateFactory.spec.js | 19 +++++++++-- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js index de1033a12eb..6d23d75d32a 100644 --- a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js +++ b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js @@ -43,19 +43,6 @@ const DAY_MS = 24 * 60 * 60 * 1000; */ const EXPIRING_SOON_DAYS = 1; -/** - * @param {string} distinguishedName - as rendered by X509Certificate - * @return {string|undefined} - */ -function commonNameOf(distinguishedName) { - const line = (distinguishedName ?? '') - .split('\n') - .map((entry) => entry.trim()) - .find((entry) => entry.startsWith('CN=')); - - return line?.slice('CN='.length); -} - /** * Which provider the issuer of an installed leaf points at. * @@ -295,17 +282,20 @@ export default function checkGatewayCertificateFactory(homeDir) { if (!externalIp) { skipped.push('IDENTITY'); } else { - // Dashmate identifies a node by its address, and lego passes --disable-cn - // for an IP certificate, so the address is normally only in the SAN. The - // common name is the fallback for a certificate issued without one. - const namesExternalIp = installed.ipAddresses.length > 0 - ? installed.ipAddresses.includes(externalIp) - : commonNameOf(leaf.subject) === externalIp; - - if (!namesExternalIp) { + // Only the subject alternative name counts. Node's own + // tls.checkServerIdentity does not consult the common name for an IP + // identifier and neither do browsers, so a certificate carrying the + // address only in its subject is one every client rejects - accepting it + // would pass a node that nothing can connect to. + if (!installed.ipAddresses.includes(externalIp)) { + const detail = installed.ipAddresses.length > 0 + ? `it names ${installed.ipAddresses.join(', ')} instead` + : 'it carries no IP address at all'; + reasons.push({ code: CERTIFICATE_REASONS.IP_MISMATCH, - message: `The installed certificate does not name this node's address ${externalIp}`, + message: "The installed certificate does not carry this node's address" + + ` ${externalIp} in its subject alternative name - ${detail}`, }); } } diff --git a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js index 1c9dcb753b2..1d2aa991139 100644 --- a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js @@ -303,13 +303,28 @@ describe('checkGatewayCertificateFactory', () => { expect(codes(verdict.reasons)).to.not.include(CERTIFICATE_REASONS.IP_MISMATCH); }); - it('should fall back to the common name when the leaf carries no IP SAN', () => { + // Node's own tls.checkServerIdentity does not consult the common name for + // an IP identifier, and neither do browsers. A certificate that carries the + // address only in its subject is rejected by every client that matters, so + // passing it here would report no problem on a node nothing can connect to. + it('should block on a leaf that carries the address only in its common name', () => { const certificate = issueCertificate({ subject: { commonName: EXTERNAL_IP } }); install(certificate.pem, certificate.keyPem); expect(codes(checkGatewayCertificate(config).reasons)) - .to.not.include(CERTIFICATE_REASONS.IP_MISMATCH); + .to.include(CERTIFICATE_REASONS.IP_MISMATCH); + }); + + it('should say the address is missing from the SAN rather than wrong', () => { + const certificate = issueCertificate({ subject: { commonName: EXTERNAL_IP } }); + + install(certificate.pem, certificate.keyPem); + + const [reason] = checkGatewayCertificate(config).reasons + .filter(({ code }) => code === CERTIFICATE_REASONS.IP_MISMATCH); + + expect(reason.message).to.contain('subject alternative name'); }); }); From 7e01230c0540bf3c6ec1708152fe7f1862c3b9c3 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 20:30:12 +0700 Subject: [PATCH 37/63] fix(dashmate): reject a certificate whose validity has not started A certificate with a notBefore in the future is unservable in exactly the way an expired one is: clients reject it on the same field. The checks looked only at the far end of the window, so such a certificate was reported as having passed. This is a plain validity condition, not the clock-skew inference that was removed earlier. That inference was wrong because validFrom in the future cannot distinguish a slow local clock from anything else, and nothing here tries to: no conclusion is drawn about which side the clock is on, and the message says only that clients reject the certificate as it stands. The test fixture gains a startsInDays option so a not-yet-valid certificate can be issued; the default validity window is unchanged. Tests: 2 new, red before this commit. Co-Authored-By: Claude Opus 5 --- .../src/ssl/checkGatewayCertificateFactory.js | 13 +++++++++ .../dashmate/src/test/certificateFixtures.js | 8 +++--- .../checkGatewayCertificateFactory.spec.js | 27 +++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js index 6d23d75d32a..29a19c9b22f 100644 --- a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js +++ b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js @@ -19,6 +19,7 @@ export const CERTIFICATE_STATUS = { export const CERTIFICATE_REASONS = { BUNDLE_MISSING: 'BUNDLE_MISSING', + NOT_YET_VALID: 'NOT_YET_VALID', BUNDLE_UNREADABLE: 'BUNDLE_UNREADABLE', BUNDLE_ORDER: 'BUNDLE_ORDER', KEY_MISSING: 'KEY_MISSING', @@ -265,6 +266,18 @@ export default function checkGatewayCertificateFactory(homeDir) { }); } + // Unservable in exactly the way an expired certificate is - clients reject + // it on the same field. Nothing is concluded about the clock here: a fast + // local clock and a genuinely future start date look identical from disk, + // so this says only that the certificate cannot be served as it stands. + if (installed.validFrom.getTime() > Date.now()) { + reasons.push({ + code: CERTIFICATE_REASONS.NOT_YET_VALID, + message: 'The installed certificate is not valid until' + + ` ${installed.validFrom.toISOString().slice(0, 10)}, so clients reject it`, + }); + } + if (expiresInDays <= 0) { reasons.push({ code: CERTIFICATE_REASONS.EXPIRED, diff --git a/packages/dashmate/src/test/certificateFixtures.js b/packages/dashmate/src/test/certificateFixtures.js index 9afc678eda5..58d92ac3d9d 100644 --- a/packages/dashmate/src/test/certificateFixtures.js +++ b/packages/dashmate/src/test/certificateFixtures.js @@ -48,6 +48,7 @@ function toPem(der) { * @param {Object} [options.issuer] - the issuing authority, self-signed when absent * @param {string} [options.ip] - placed in the subject alternative name * @param {number} [options.days] - days from now it expires, negative for expired + * @param {number} [options.startsInDays] - days from now it becomes valid * @param {boolean} [options.ca] * @param {Object} [options.keys] - reuse an existing node-forge key pair * @return {{pem: string, keyPem: string, keys: Object, certificate: Object, @@ -58,6 +59,7 @@ export function issueCertificate({ issuer, ip, days = 30, + startsInDays, ca = false, keys = forge.pki.rsa.generateKeyPair(2048), } = {}) { @@ -69,9 +71,9 @@ export function issueCertificate({ // Anchored to the expiry so an already-expired certificate still starts // before it ends. certificate.validity.notAfter = new Date(Date.now() + days * DAY_MS); - certificate.validity.notBefore = new Date( - certificate.validity.notAfter.getTime() - 90 * DAY_MS, - ); + certificate.validity.notBefore = startsInDays === undefined + ? new Date(certificate.validity.notAfter.getTime() - 90 * DAY_MS) + : new Date(Date.now() + startsInDays * DAY_MS); certificate.setSubject(toAttributes(subject)); certificate.setIssuer(toAttributes(issuer ? issuer.subject : subject)); diff --git a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js index 1d2aa991139..c5d34534b2a 100644 --- a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js @@ -328,6 +328,33 @@ describe('checkGatewayCertificateFactory', () => { }); }); + // A certificate whose validity has not started yet is unservable in exactly + // the way an expired one is - clients reject it on the same field. This is a + // plain validity condition and infers nothing about the clock: a fast local + // clock and a genuinely future notBefore are indistinguishable from here, so + // no conclusion is drawn about which one it is. + describe('validity start', () => { + it('should block on a certificate that is not valid yet', () => { + const certificate = issueCertificate({ ip: EXTERNAL_IP, startsInDays: 5 }); + + install(certificate.pem, certificate.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.include(CERTIFICATE_REASONS.NOT_YET_VALID); + }); + + it('should not report a certificate already in its validity window', () => { + const certificate = issueCertificate({ ip: EXTERNAL_IP, startsInDays: -1 }); + + install(certificate.pem, certificate.keyPem); + + expect(codes(checkGatewayCertificate(config).reasons)) + .to.not.include(CERTIFICATE_REASONS.NOT_YET_VALID); + }); + }); + describe('provider agreement', () => { /** * @param {Object} pair From 5d5b444ced3ea53bdd0c18a05f701a600eebeac5 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 20:34:16 +0700 Subject: [PATCH 38/63] fix(dashmate): stop telling operators to restart after obtaining a certificate `dashmate ssl obtain` installs the pair and signals the gateway, and the signal reaches Envoy's hot-restarter, which re-execs Envoy against the same configuration without touching the container. That was measured on a live gateway. So every remedy that appended a restart to an obtain was selling the operator an outage in exchange for nothing. Two surfaces still did it: the update guidance, on every provider when the node is running, and doctor's expired-served prescription. Both are swept. The guidance now says plainly that a running node needs nothing further; starting a node that is already stopped is a different act and stays. Restart advice survives in the one place that earns it - the incomplete chain prescription, where the operator edits the bundle by hand and nothing signals the gateway afterwards. The two renewed-but-never-delivered prescriptions keep theirs too: there the usable certificate is already on disk and a restart is the entire remedy, with no obtain involved. This defect has been reported five times, each time in a surface the last fix did not reach. So the invariant is now pinned categorically rather than per site: every rendered remedy from both surfaces is driven for real, and any that prescribes an obtain alongside a restart fails the build. The guard counts the remedies it actually inspected, so it cannot pass by matching nothing. The test asserting the guidance offers a restart to a running node was itself the defect, and is inverted rather than deleted. Tests: 2 new categorical guards, red before this commit - they located both remaining surfaces on their first run; 1 inverted. Co-Authored-By: Claude Opus 5 --- .../analyseGatewayCertificateFactory.js | 4 +- .../src/ssl/renderCertificateGuidance.js | 18 ++- .../test/unit/renderedCommands.spec.js | 110 ++++++++++++++++++ .../ssl/renderCertificateGuidance.spec.js | 8 +- 4 files changed, 134 insertions(+), 6 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index a284b527091..52b398d196c 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -184,8 +184,8 @@ signals the gateway: + 'Clients cannot connect to this node', chalk`Renewal has not succeeded. Check the renewal logs: {bold.cyanBright dashmate logs ${cfg} dashmate_helper} -Then obtain a new certificate: {bold.cyanBright dashmate ssl obtain ${cfg}} -${restartHint(cfg)}`, +Then obtain a new certificate, which installs it and signals the gateway: +{bold.cyanBright dashmate ssl obtain ${cfg}}`, SEVERITY.HIGH, )); } else if (onDiskDiffers) { diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index 377d0eef9ed..5cf56002ad4 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -150,6 +150,12 @@ function renderFix(cfg, isNodeRunning, isAlreadyLetsEncrypt) { // authority that issues IP-address certificates over ACME - so the heading // that offers a switch would contradict the diagnosis printed above it. The // commands are the same either way. + // + // No restart follows the obtain. That command installs the pair and signals + // the gateway, and the signal reaches Envoy's hot-restarter, which re-execs + // Envoy against the same configuration without touching the container, so a + // restart would cost an outage and change nothing. Starting a node that is + // already stopped is a different thing and stays. const heading = isAlreadyLetsEncrypt ? ` THE FIX - obtain a new certificate from Let's Encrypt.` : ` THE FIX - switch to Let's Encrypt, which issues IP-address certificates free.`; @@ -165,8 +171,16 @@ function renderFix(cfg, isNodeRunning, isAlreadyLetsEncrypt) { Then: dashmate ssl obtain ${cfg} --provider letsencrypt - dashmate ${isNodeRunning ? 'restart' : 'start'} ${cfg} -`; + +${isNodeRunning + ? ` That installs the certificate and signals the gateway, so a running node + needs nothing further - no restart. +` + : ` That installs the certificate and signals the gateway. This node is + stopped, so bring it back up: + + dashmate start ${cfg} +`}`; } /** diff --git a/packages/dashmate/test/unit/renderedCommands.spec.js b/packages/dashmate/test/unit/renderedCommands.spec.js index 51b94d0330d..75b4c4fb6aa 100644 --- a/packages/dashmate/test/unit/renderedCommands.spec.js +++ b/packages/dashmate/test/unit/renderedCommands.spec.js @@ -272,6 +272,116 @@ describe('every command dashmate tells an operator to run', () => { }); }); + // `dashmate ssl obtain` installs the pair and signals the gateway, and the + // signal reaches Envoy's hot-restarter, which re-execs Envoy against the same + // configuration without touching the container. Telling an operator to + // restart after obtaining therefore buys them an outage and changes nothing. + // + // This has been reported five separate times, each in a surface the previous + // fix did not cover, so the invariant is asserted over every rendered remedy + // at once rather than at the places it happened to appear. + describe('remedies routed through ssl obtain', () => { + /** + * @param {string} label + * @param {string} text + * @return {boolean} whether this text prescribed an obtain + */ + function expectNoRestartAlongsideObtain(label, text) { + const commands = commandsIn(text); + const obtains = commands.filter((command) => /dashmate\s+ssl\s+obtain/.test(command)); + const restarts = commands.filter((command) => /dashmate\s+restart/.test(command)); + + if (obtains.length === 0) { + return false; + } + + expect(restarts, `${label} prescribes an obtain and then ${restarts.join(', ')}`) + .to.be.empty(); + + return true; + } + + it('never tells the operator to restart afterwards in the update guidance', () => { + let covered = 0; + + ['zerossl', 'letsencrypt', 'file', 'self-signed'].forEach((provider) => { + config.set('platform.gateway.ssl.provider', provider); + + [true, false].forEach((isNodeRunning) => { + const text = renderCertificateGuidance({ + config, verdict: verdict(), isNodeRunning, pull: null, + }); + + if (expectNoRestartAlongsideObtain(`guidance ${provider}/${isNodeRunning}`, text)) { + covered += 1; + } + }); + }); + + expect(covered, 'no guidance variant prescribed an obtain').to.equal(8); + }); + + it('never tells the operator to restart afterwards in a doctor prescription', () => { + /** + * @param {Object} servedCertificate + * @return {Object[]} + */ + const problemsFor = (servedCertificate) => { + const samples = new Samples(); + samples.setDashmateConfig(config); + samples.setServiceInfo('gateway', 'installedCertificate', { + status: 'INVALID', + reasons: [{ code: 'EXPIRED', message: 'expired' }], + warnings: [], + }); + samples.setServiceInfo('gateway', 'servedCertificate', servedCertificate); + + return analyseGatewayCertificateFactory()(samples); + }; + + const expired = new Date(Date.now() - 864e5).toUTCString(); + const base = { + state: 'served', + port: 443, + chainVerified: true, + identityVerified: true, + matchesOnDisk: true, + }; + + // Every branch that can prescribe an obtain: the address it served does + // not belong to this node, its certificate has run out, and its + // certificate differs from a disk copy not known to be usable. + const cases = [ + { ...base, identityVerified: false, identityError: 'not in the cert altnames' }, + { ...base, certificate: { fingerprint256: 'AA:BB', validTo: expired } }, + { + ...base, + certificate: { fingerprint256: 'AA:BB', validTo: expired }, + matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD' }, + }, + { + ...base, + certificate: { fingerprint256: 'AA:BB', validTo: new Date(Date.now() + 864e5).toUTCString() }, + matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD' }, + }, + ]; + + let covered = 0; + + cases.forEach((servedCertificate, index) => { + problemsFor(servedCertificate).forEach((problem, position) => { + if (expectNoRestartAlongsideObtain(`doctor ${index}/${position}`, problem.getSolution())) { + covered += 1; + } + }); + }); + + expect(covered, 'no doctor prescription offered an obtain').to.be.greaterThan(2); + }); + }); + // The backstop. Rendering can only check surfaces a test knows about, and // this class of defect has recurred by arriving in a place nobody thought to // check. Anything under src/ that lays out a command has to name the node, diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index c51bff87452..11d3af8f29d 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -258,11 +258,15 @@ describe('renderCertificateGuidance', () => { expect(output).to.contain('dashmate start --config base'); }); - it('should offer restart instead when the node is running', () => { + // Obtaining installs the pair and signals the gateway, so a node that is + // already up needs nothing further. Telling the operator to restart it would + // cost them an outage for no change. + it('should ask for nothing further when the node is running', () => { const output = render({ isNodeRunning: true }); expect(output).to.not.contain('Your node is currently stopped'); - expect(output).to.contain('dashmate restart --config base'); + expect(output).to.not.contain('dashmate restart --config base'); + expect(output).to.contain('needs nothing further - no restart'); }); it('should say when images failed to pull', () => { From f4cb3dcf7cfd847eb7c66a6f623677d5a93eb7e7 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 20:35:10 +0700 Subject: [PATCH 39/63] style(dashmate): flatten the identity branch and unshadow its detail The SAN-only identity check landed as a lone `if` inside an `else` with a local named `detail`, which shadows the leaf-selection detail in the same scope. Both are lint errors; neither changes behaviour. Co-Authored-By: Claude Opus 5 --- .../src/ssl/checkGatewayCertificateFactory.js | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js index 29a19c9b22f..17a5b1dc33b 100644 --- a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js +++ b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js @@ -294,23 +294,21 @@ export default function checkGatewayCertificateFactory(homeDir) { if (!externalIp) { skipped.push('IDENTITY'); - } else { + } else if (!installed.ipAddresses.includes(externalIp)) { // Only the subject alternative name counts. Node's own // tls.checkServerIdentity does not consult the common name for an IP // identifier and neither do browsers, so a certificate carrying the // address only in its subject is one every client rejects - accepting it // would pass a node that nothing can connect to. - if (!installed.ipAddresses.includes(externalIp)) { - const detail = installed.ipAddresses.length > 0 - ? `it names ${installed.ipAddresses.join(', ')} instead` - : 'it carries no IP address at all'; - - reasons.push({ - code: CERTIFICATE_REASONS.IP_MISMATCH, - message: "The installed certificate does not carry this node's address" - + ` ${externalIp} in its subject alternative name - ${detail}`, - }); - } + const named = installed.ipAddresses.length > 0 + ? `it names ${installed.ipAddresses.join(', ')} instead` + : 'it carries no IP address at all'; + + reasons.push({ + code: CERTIFICATE_REASONS.IP_MISMATCH, + message: "The installed certificate does not carry this node's address" + + ` ${externalIp} in its subject alternative name - ${named}`, + }); } const legoDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'lego'); From 8d29701c4dd363d9a5fd7c20495799db9661fb75 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 20:38:53 +0700 Subject: [PATCH 40/63] fix(dashmate): stop claiming a failed attempt changed nothing The guidance made two unconditional promises: that nothing broke just now, and that a stopped node will start regardless. Both are false once an obtain has been attempted and failed. That path is not hypothetical - the task explicitly handles a run that threw, and re-reads the disk afterwards precisely because a failure between writing the certificate and writing the key replaces a working pair with a mismatched one. Telling an operator nothing changed at the moment their gateway lost its TLS pair is the same family of defect as reporting a certificate valid when the checks never looked at the wire: an assurance the code cannot support. The guidance now takes whether an attempt ran and failed, and where it did, says so - the files may not be what they were, and the status shown was read back from disk after the attempt. The start line still points the operator at their stopped node, but asks them to check it came up rather than promising it will. With no attempt made, both original sentences are unchanged. The command supplies the fact from the error the task already records. That wiring has its own test, verified to fail when the argument is hardcoded back to false - the renderer's own tests cannot catch a flag that never arrives. Tests: 4 new, red before this commit. Co-Authored-By: Claude Opus 5 --- packages/dashmate/src/commands/update.js | 9 +++++-- .../src/ssl/renderCertificateGuidance.js | 22 ++++++++++++++--- .../test/unit/commands/update.spec.js | 22 +++++++++++++++++ .../ssl/renderCertificateGuidance.spec.js | 24 +++++++++++++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/dashmate/src/commands/update.js b/packages/dashmate/src/commands/update.js index bd80b20d267..4995c92a6da 100644 --- a/packages/dashmate/src/commands/update.js +++ b/packages/dashmate/src/commands/update.js @@ -100,9 +100,10 @@ export default class UpdateCommand extends ConfigBaseCommand { /** * @param {Object} verdict + * @param {boolean} [obtainAttemptFailed] * @return {Promise} */ - const reportUnresolved = async (verdict) => { + const reportUnresolved = async (verdict, obtainAttemptFailed = false) => { let isNodeRunning = false; try { isNodeRunning = await dockerCompose.isServiceRunning(config, 'gateway'); @@ -116,6 +117,7 @@ export default class UpdateCommand extends ConfigBaseCommand { verdict, isNodeRunning, pull: this.pullResult ?? null, + obtainAttemptFailed, })); }; @@ -295,7 +297,10 @@ export default class UpdateCommand extends ConfigBaseCommand { // Printed before either failure is raised, so an operator whose node has // both problems still gets the remediation for the one they can act on. if (unresolved) { - await reportUnresolved(unresolved.getVerdict()); + await reportUnresolved( + unresolved.getVerdict(), + Boolean(context.certificateObtainError), + ); } // A pull that fetched nothing is what this command exists to do, so it diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index 5cf56002ad4..23dbd7c4b0e 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -198,6 +198,7 @@ ${isNodeRunning * @param {Config} options.config * @param {Object} options.verdict * @param {boolean} options.isNodeRunning + * @param {boolean} [options.obtainAttemptFailed] - an obtain was run and threw * @param {{ok: boolean, failed: number, total: number}|null} options.pull * @return {string} */ @@ -206,6 +207,7 @@ export default function renderCertificateGuidance({ verdict, isNodeRunning, pull, + obtainAttemptFailed = false, }) { const cfg = renderConfigFlag(config.getName()); const provider = config.get('platform.gateway.ssl.provider'); @@ -227,8 +229,14 @@ export default function renderCertificateGuidance({ not validate the certificate against public trust stores either; \`dashmate doctor ${cfg}\` does the first of those. - Nothing broke just now. This is the first release of dashmate that checks - the certificate, so this is the first time you are being told. + ${obtainAttemptFailed + ? `An attempt to obtain a certificate ran just now and did not complete. + It can have failed at any point, including after writing one half of the + pair, so the files on disk may not be what they were before this run. The + status above was read back from disk after the attempt, so it describes + what is there now.` + : `Nothing broke just now. This is the first release of dashmate that checks + the certificate, so this is the first time you are being told.`} `, ]; @@ -237,7 +245,15 @@ export default function renderCertificateGuidance({ // complaint, assumes it changed nothing and walks away has left a stopped // masternode behind. if (!isNodeRunning) { - blocks.push(` Your node is currently stopped. Run \`dashmate start ${cfg}\` to bring + // The reassurance holds for a certificate that merely failed the checks: + // nothing about them gates startup. It does not hold once an obtain has + // run and failed, because what is on disk may have changed underneath the + // gateway, and promising a clean start there is a claim this cannot make. + blocks.push(obtainAttemptFailed + ? ` Your node is currently stopped. Bring it back up with \`dashmate start ${cfg}\`, + then check it came up: the attempt above may have changed what is installed. +` + : ` Your node is currently stopped. Run \`dashmate start ${cfg}\` to bring it back up - the certificate problem does not prevent it from starting. `); } diff --git a/packages/dashmate/test/unit/commands/update.spec.js b/packages/dashmate/test/unit/commands/update.spec.js index a45153e1aeb..3658ab5c6d4 100644 --- a/packages/dashmate/test/unit/commands/update.spec.js +++ b/packages/dashmate/test/unit/commands/update.spec.js @@ -177,6 +177,28 @@ describe('Update command', () => { expect(stderr).to.contain('did not pass'); }); + // The renderer can only tell the truth about a failed attempt if the + // command actually tells it one happened, so the wiring is pinned here + // rather than left to the renderer's own tests. + it('should tell the guidance an obtain was attempted and failed', async () => { + const verdict = invalidVerdict(); + + // An unresolved certificate always exits non-zero, so the rejection is + // the command working; the guidance it printed first is what is checked. + await expect(runUpdate({ + checkGatewayCertificate: () => verdict, + gatewayCertificateTask: () => async (ctx) => { + ctx.certificate = verdict; + ctx.certificateObtainError = new Error('lego exited 1'); + + throw new CertificateUnresolvedError(verdict); + }, + })).to.be.rejected(); + + expect(stderr).to.contain('did not complete'); + expect(stderr).to.not.contain('Nothing broke just now'); + }); + // Individual images failing is not a rejection: updateNode resolves those // as error rows, and that has always exited 0. it('should not fail the command when individual pulls fail', async function it() { diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index 11d3af8f29d..59ffc7ae908 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -269,6 +269,30 @@ describe('renderCertificateGuidance', () => { expect(output).to.contain('needs nothing further - no restart'); }); + // An obtain that failed can have failed anywhere, including between writing + // the certificate and writing the key - which replaces a working pair with a + // mismatched one. Two of the claims here are unconditional and the code + // cannot support either of them once that has happened. + describe('after a remediation attempt that failed', () => { + it('should not claim nothing changed', () => { + const output = render({ obtainAttemptFailed: true }); + + expect(output).to.not.contain('Nothing broke just now'); + expect(output).to.contain('did not complete'); + }); + + it('should not promise the node will start', () => { + const output = render({ obtainAttemptFailed: true, isNodeRunning: false }); + + expect(output).to.not.contain('does not prevent it from starting'); + }); + + it('should still say nothing changed when no attempt was made', () => { + expect(render()).to.contain('Nothing broke just now'); + expect(render({ isNodeRunning: false })).to.contain('does not prevent it from starting'); + }); + }); + it('should say when images failed to pull', () => { expect(render({ pull: { ok: true, failed: 2, total: 7 } })) .to.contain('2 of 7 failed'); From 5140e70ca77f234d9f784824d2070bc7b723b21a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 20:39:52 +0700 Subject: [PATCH 41/63] style(dashmate): name the migration specs' local version binding in camelCase FROM_VERSION is a block-local binding inside individual specs, not a module constant, so the screaming-snake form does not match the convention the rest of the package follows. Rename only; 15 occurrences, no behaviour change. Co-Authored-By: Claude Opus 5 --- .../migrateConfigFileFactory.spec.js | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js index 2fd90a11eb5..dd54dff961f 100644 --- a/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js +++ b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js @@ -156,7 +156,7 @@ describe('migrateConfigFileFactory', () => { // This is the last time a stock tag is recognised by shape, so both halves // need pinning - a widened pattern would overwrite operator images, and a // narrowed one would strand operators on a tag that no longer moves. - const FROM_VERSION = '4.0.0'; + const fromVersion = '4.0.0'; // Spelled out rather than only imported, so adding an identifier to the // shared list without deciding it belongs here fails instead of silently @@ -172,13 +172,13 @@ describe('migrateConfigFileFactory', () => { const migrateImages = (driveImage, rsDapiImage) => { const configFileData = createConfigFile().toObject(); - configFileData.configFormatVersion = FROM_VERSION; + configFileData.configFormatVersion = fromVersion; for (const options of Object.values(configFileData.configs)) { options.platform.drive.abci.docker.image = driveImage; options.platform.dapi.rsDapi.docker.image = rsDapiImage; } - const migrated = migrateConfigFile(configFileData, FROM_VERSION, version); + const migrated = migrateConfigFile(configFileData, fromVersion, version); return migrated.configs[firstConfigName].platform; }; @@ -234,14 +234,14 @@ describe('migrateConfigFileFactory', () => { // pin and the ACME directory default. A node upgrading from 4.1.0 crosses both, // and the runner orders them by version rather than by their position in the // table, where the Tenderdash pin sits last. - const FROM_VERSION = '4.1.0'; + const fromVersion = '4.1.0'; const { version } = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT_DIR, 'package.json'), 'utf8')); const baseConfig = container.resolve('defaultConfigs').get('base'); const expectedTenderdashImage = baseConfig.get('platform.drive.tenderdash.docker.image'); const configFileData = createConfigFile().toObject(); - configFileData.configFormatVersion = FROM_VERSION; + configFileData.configFormatVersion = fromVersion; for (const options of Object.values(configFileData.configs)) { // The shapes a config stamped 4.1.0 carries, before either migration ran. options.platform.drive.tenderdash.docker.image = 'dashpay/tenderdash:1.6.0'; @@ -249,7 +249,7 @@ describe('migrateConfigFileFactory', () => { delete options.platform.gateway.ssl.providerConfigs.letsencrypt.acmeDirectoryUrl; } - const migrated = migrateConfigFile(configFileData, FROM_VERSION, version); + const migrated = migrateConfigFile(configFileData, fromVersion, version); for (const [name, options] of Object.entries(migrated.configs)) { expect(options.platform.drive.tenderdash.docker.image).to.equal( @@ -278,11 +278,11 @@ describe('migrateConfigFileFactory', () => { // not define, so a config that keeps them cannot be loaded at all - which is // every node running a development build, the population these changes are // validated on. - const FROM_VERSION = '4.2.0-dev.1'; + const fromVersion = '4.2.0-dev.1'; const { version } = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT_DIR, 'package.json'), 'utf8')); const configFileData = createConfigFile().toObject(); - configFileData.configFormatVersion = FROM_VERSION; + configFileData.configFormatVersion = fromVersion; for (const options of Object.values(configFileData.configs)) { // The shape the base config carried while these overrides still existed. options.platform.drive.tenderdash.consensus.unsafeOverride.commit = { @@ -291,7 +291,7 @@ describe('migrateConfigFileFactory', () => { }; } - const migrated = migrateConfigFile(configFileData, FROM_VERSION, version); + const migrated = migrateConfigFile(configFileData, fromVersion, version); for (const [name, options] of Object.entries(migrated.configs)) { let loadError = null; @@ -313,20 +313,20 @@ describe('migrateConfigFileFactory', () => { // migration, so it is the first place operator intent can be respected. It // used to overwrite unconditionally, which destroyed a custom image before // any later migration could tell it apart from a stale default. - const FROM_VERSION = '3.1.0'; + const fromVersion = '3.1.0'; const customDriveImage = 'registry.example.com/security-patched-drive:stable'; const customRsDapiImage = 'registry.example.com/security-patched-rs-dapi:stable'; const { version } = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT_DIR, 'package.json'), 'utf8')); const configFileData = createConfigFile().toObject(); - configFileData.configFormatVersion = FROM_VERSION; + configFileData.configFormatVersion = fromVersion; for (const options of Object.values(configFileData.configs)) { options.platform.drive.abci.docker.image = customDriveImage; options.platform.dapi.rsDapi.docker.image = customRsDapiImage; } - const migrated = migrateConfigFile(configFileData, FROM_VERSION, version); + const migrated = migrateConfigFile(configFileData, fromVersion, version); for (const [name, options] of Object.entries(migrated.configs)) { expect(options.platform.drive.abci.docker.image).to.equal( @@ -344,7 +344,7 @@ describe('migrateConfigFileFactory', () => { // A config from before 4.0.0 carries a tag of its own era. Those still have // to be recognised as published defaults, or an operator who never chose an // image is stranded on a tag nothing moves any more. - const FROM_VERSION = '3.1.0'; + const fromVersion = '3.1.0'; const { version } = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT_DIR, 'package.json'), 'utf8')); @@ -352,13 +352,13 @@ describe('migrateConfigFileFactory', () => { // major alone in v1.0.2, and the 0.x line used major.minor throughout. for (const tag of ['3', '2', '1-dev', '1.0', '1.0-rc', '0.25', '0.24']) { const configFileData = createConfigFile().toObject(); - configFileData.configFormatVersion = FROM_VERSION; + configFileData.configFormatVersion = fromVersion; for (const options of Object.values(configFileData.configs)) { options.platform.drive.abci.docker.image = `dashpay/drive:${tag}`; options.platform.dapi.rsDapi.docker.image = `dashpay/rs-dapi:${tag}`; } - const migrated = migrateConfigFile(configFileData, FROM_VERSION, version); + const migrated = migrateConfigFile(configFileData, fromVersion, version); for (const [name, options] of Object.entries(migrated.configs)) { expect(options.platform.drive.abci.docker.image).to.equal( From adf6fb0bc97b6a788e63acd06741d4d2ce4a543c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 20:47:29 +0700 Subject: [PATCH 42/63] fix(dashmate): offer the ZeroSSL switch when the certificate is nearly out The ZeroSSL branch was gated on a completely clean verdict, and the generic warning return sat in front of it. Any warning at all puts the verdict at WARN, so a ZeroSSL node reaching that state got the bare warning text and was never offered the switch. The certificate expiring inside a day is one of those warnings. So the offer was withheld at precisely the point it was worth making, and the prompt's own default - Yes when little time is left - could never be reached. An unattended run lost the explanation of the free-tier limit and the migration command with it, which is the one thing that population has no other way to learn. The branch now covers every non-blocking verdict rather than a spotless one, and the generic return it used to hide behind is gone: each path that returns without switching collects the verdict's own warnings itself, so nothing that made the node WARN is lost. A successful switch still stays silent about the old certificate's expiry rather than contradicting its own success message. Below a day the remaining-days figure floored to zero, which had been unreachable and now is not. It reads as "in less than a day". Tests: 4 new, 2 red before this commit. One existing assertion updated: a ZeroSSL node now also carries the free-tier warning, which is the point of the fix, so it asserts neither verdict warning is dropped rather than pinning the exact list. Co-Authored-By: Claude Opus 5 --- .../update/gatewayCertificateTaskFactory.js | 25 ++++--- .../gatewayCertificateTaskFactory.spec.js | 69 ++++++++++++++++++- 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js index bf43c66bebe..d198c1ca670 100644 --- a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js @@ -258,15 +258,25 @@ export default function gatewayCertificateTaskFactory( return; } - if (verdict.status === CERTIFICATE_STATUS.CHECKS_PASSED) { + // Anything short of blocking, not only a spotless verdict. A ZeroSSL node + // reaches WARN through any warning at all, including the certificate + // running out inside a day - and that is when the switch below matters + // most, so gating it on a clean verdict withheld the offer at exactly the + // moment it was worth making. + if (verdict.status !== CERTIFICATE_STATUS.INVALID) { // Someone who bought a certificate is never nagged. ZeroSSL is the one // exception, because a free account stops being able to renew and the // operator has no way to find that out until it has happened. if (verdict.provider !== SSL_PROVIDERS.ZEROSSL) { + collectWarnings(ctx, verdict); + return; } const daysLeft = Math.floor(verdict.expiresInDays ?? 0); + // Below a day this floors to zero, and "expires in 0 days" reads as a + // rendering fault rather than as the most urgent thing on the page. + const remaining = daysLeft < 1 ? 'in less than a day' : `in ${daysLeft} days`; // Said on every run, to a human and to a script alike: a free ZeroSSL // account allows three certificates in total, and nothing tells an @@ -274,7 +284,7 @@ export default function gatewayCertificateTaskFactory( const warn = () => { ctx.certificateWarnings = [ ...(ctx.certificateWarnings ?? []), - `This node's ZeroSSL certificate expires in ${daysLeft} days. A free ZeroSSL` + `This node's ZeroSSL certificate expires ${remaining}. A free ZeroSSL` + " account allows three certificates in total, so dashmate's renewals stop" + ` working after about 270 days. Switch to Let's Encrypt with:` + `\n dashmate ssl obtain ${cfg} --provider letsencrypt`, @@ -283,6 +293,7 @@ export default function gatewayCertificateTaskFactory( if (!interactive) { warn(); + collectWarnings(ctx, verdict); return; } @@ -300,6 +311,7 @@ export default function gatewayCertificateTaskFactory( if (!accepted) { warn(); + collectWarnings(ctx, verdict); return; } @@ -341,15 +353,6 @@ export default function gatewayCertificateTaskFactory( throw new CertificateUnresolvedError(after); } - if (verdict.status === CERTIFICATE_STATUS.WARN) { - ctx.certificateWarnings = [ - ...(ctx.certificateWarnings ?? []), - ...verdict.warnings.map(({ message }) => message), - ]; - - return; - } - // INVALID from here on. Nothing is acted on without an operator: a // configuration change nobody asked for, made unattended on infrastructure // they own, is not dashmate's to make - and it would replace a diagnosis diff --git a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js index d9ed31a4e01..7a99175f0c1 100644 --- a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js @@ -113,6 +113,68 @@ describe('gatewayCertificateTaskFactory', () => { afterEach(() => homeDir.remove()); + // A ZeroSSL node reaches WARN through any warning at all - the certificate + // running out inside a day, an unmanaged pair, a provider that disagrees with + // the configuration. The switch offer is the whole point of noticing ZeroSSL, + // and it is needed most in exactly those states, so it must not be reachable + // only from a completely clean verdict. + describe('a ZeroSSL node that also has a warning', () => { + const expiringSoon = () => verdict({ + status: CERTIFICATE_STATUS.WARN, + warnings: [{ code: CERTIFICATE_REASONS.EXPIRING_SOON, message: 'expires tomorrow' }], + expiresInDays: 0.5, + }); + + it('should still offer the switch when expiry is hours away', async function it() { + const { errors } = await run.call(this, { + checkGatewayCertificate: () => expiringSoon(), + }); + + expect(errors).to.be.empty(); + expect(enquirer.prompt).to.have.been.called(); + expect(enquirer.options[0].message).to.contain("Switch to Let's Encrypt"); + }); + + it('should still explain the ZeroSSL wall to a machine', async function it() { + const { context, errors } = await run.call(this, { + checkGatewayCertificate: () => expiringSoon(), + interactive: false, + }); + + expect(errors).to.be.empty(); + expect(context.certificateWarnings.join('\n')).to.contain('three certificates in total'); + }); + + // The reason it reached WARN is a fact about this node in its own right and + // has to survive: it is what tells the operator the certificate is nearly + // out, which is why the switch is urgent at all. + it('should keep the warning that made it WARN', async function it() { + const { context } = await run.call(this, { + checkGatewayCertificate: () => expiringSoon(), + interactive: false, + }); + + expect(context.certificateWarnings.join('\n')).to.contain('expires tomorrow'); + }); + + it('should still report a non-ZeroSSL warning on its own', async function it() { + config.set('platform.gateway.ssl.provider', 'letsencrypt'); + + const { context, errors } = await run.call(this, { + checkGatewayCertificate: () => verdict({ + status: CERTIFICATE_STATUS.WARN, + provider: 'letsencrypt', + warnings: [{ code: CERTIFICATE_REASONS.EXPIRING_SOON, message: 'expires tomorrow' }], + }), + interactive: false, + }); + + expect(errors).to.be.empty(); + expect(context.certificateWarnings).to.deep.equal(['expires tomorrow']); + expect(enquirer.prompt).to.not.have.been.called(); + }); + }); + describe('nothing blocks on a certificate that passed', () => { it('should say nothing at all for a provider that is working', async function it() { config.set('platform.gateway.ssl.provider', 'letsencrypt'); @@ -650,7 +712,12 @@ describe('gatewayCertificateTaskFactory', () => { }); expect(errors).to.be.empty(); - expect(context.certificateWarnings).to.deep.equal(['issuer disagrees', 'expires tomorrow']); + + // This node is on ZeroSSL, so the structural warning about the free-tier + // limit rides along in front. What matters here is that neither verdict + // warning is dropped on the way through. + expect(context.certificateWarnings.slice(-2)) + .to.deep.equal(['issuer disagrees', 'expires tomorrow']); }); }); From e9f517ccbaa82e2767f686578f2eebe8f9be2768 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 21:12:09 +0700 Subject: [PATCH 43/63] fix(dashmate): reject a bundle whose certificate blocks are truncated Matching a BEGIN delimiter to its END cannot see a BEGIN that never closes - the text simply does not match, so a bundle truncated mid-block read as one certificate shorter than it is rather than as damaged. The checks passed and the gateway then refused to load the file. The number of openings must now equal the number of complete blocks. Verified against the pinned gateway image rather than by argument, running envoy --mode validate over a config that loads each bundle: leaf + intermediate exit 0, config OK leaf + half an intermediate exit 1, Failed to load certificate chain half a leaf + leaf + intermediate exit 1, Failed to load certificate chain leaf + intermediate + a stray END exit 0, config OK That last case is why only openings are counted. A stray END delimiter has no partner either, but the gateway loads such a bundle without complaint, so treating unpaired delimiters symmetrically would have refused an update on a node that serves TLS perfectly well - the same defect as passing a broken bundle, pointed the other way. The rule now agrees with the gateway on all four shapes. Tests: 3 new, 2 red before this commit. The third pins the stray-END bundle as acceptable, so a future tightening cannot quietly overshoot. Co-Authored-By: Claude Opus 5 --- .../dashmate/src/ssl/selectLeafCertificate.js | 21 ++++++++++ .../checkGatewayCertificateFactory.spec.js | 40 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/packages/dashmate/src/ssl/selectLeafCertificate.js b/packages/dashmate/src/ssl/selectLeafCertificate.js index 59c5efa0235..3860cd9d3a5 100644 --- a/packages/dashmate/src/ssl/selectLeafCertificate.js +++ b/packages/dashmate/src/ssl/selectLeafCertificate.js @@ -9,6 +9,18 @@ export const LEAF_SELECTION_ERRORS = { const PEM_CERTIFICATE = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; +/** + * Counted separately from the block match above, which pairs a BEGIN with an + * END and so cannot see a BEGIN that never closes - a bundle truncated + * mid-block reads as one certificate shorter rather than as damaged, and the + * gateway refuses to load it. + * + * Only openings are counted. A stray END with no BEGIN is loaded by the + * gateway without complaint, so treating unpaired delimiters symmetrically + * would reject a bundle that works. + */ +const BEGIN_CERTIFICATE = /-----BEGIN CERTIFICATE-----/g; + /** * A key protected by a passphrase is detected from the PEM rather than by * asking OpenSSL, which can go looking for a terminal to ask on. @@ -69,6 +81,15 @@ export default function selectLeafCertificate(bundlePem, privateKeyPem) { } const blocks = bundlePem.match(PEM_CERTIFICATE) ?? []; + const begins = (bundlePem.match(BEGIN_CERTIFICATE) ?? []).length; + + if (begins !== blocks.length) { + return { + error: LEAF_SELECTION_ERRORS.BUNDLE_UNREADABLE, + detail: `it opens ${begins} certificate(s) but only ${blocks.length} are complete,` + + ' so at least one block is truncated', + }; + } if (blocks.length === 0) { return { error: LEAF_SELECTION_ERRORS.BUNDLE_UNREADABLE, detail: 'it holds no certificate' }; diff --git a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js index c5d34534b2a..160e9d523e1 100644 --- a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js @@ -115,6 +115,46 @@ describe('checkGatewayCertificateFactory', () => { expect(reason.message).to.contain('3'); }); + // A block whose END delimiter is missing is invisible to a match that pairs + // BEGIN with END: the text is simply not matched, so a truncated bundle + // looked identical to a well-formed one. Envoy refuses to load it, so + // passing it here blesses a bundle the gateway cannot serve. + it('should block on a bundle whose last certificate is unterminated', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + const truncated = intermediate.pem.slice(0, intermediate.pem.length / 2); + + install(`${leaf.pem}${truncated}`, leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.include(CERTIFICATE_REASONS.BUNDLE_UNREADABLE); + }); + + it('should block on a bundle whose first certificate is unterminated', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + const truncated = leaf.pem.slice(0, Math.floor(leaf.pem.length / 2)); + + install(`${truncated}${leaf.pem}${intermediate.pem}`, leaf.keyPem); + + expect(codes(checkGatewayCertificate(config).reasons)) + .to.include(CERTIFICATE_REASONS.BUNDLE_UNREADABLE); + }); + + // The gateway loads a bundle carrying a stray END without complaint, so + // rejecting it would refuse a node that works. Only an opening that never + // closes means a block is actually missing. + it('should accept a bundle carrying an END delimiter with no BEGIN', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + + install(`${leaf.pem}${intermediate.pem}-----END CERTIFICATE-----\n`, leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(codes(verdict.reasons)).to.deep.equal([]); + expect(verdict.status).to.equal(CERTIFICATE_STATUS.CHECKS_PASSED); + }); + // A block Envoy will choke on is not something to pass over quietly. The // bundle is what the gateway loads, so an unparseable block in it is a // problem with the bundle whether or not a usable leaf sits beside it. From f85256c25aa5e4ca6cc0be4eccacce92db67bd8a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 21:21:15 +0700 Subject: [PATCH 44/63] fix(dashmate): stop the switch offer asserting things the verdict does not say Widening this branch past a spotless verdict brought states its wording was never written for. The branch is chosen on the configured provider, which is not evidence of who issued the leaf on disk - under a provider mismatch it can be a Let's Encrypt certificate or a self-signed one. Calling it "this node's ZeroSSL certificate" tells the operator something about their installation that dashmate has just finished disagreeing with. It now says the node is configured to use ZeroSSL and reports the expiry of whatever is installed, which is all that was ever measured. The offer also claimed the certificate had passed its checks whenever it was not blocking, so a warned node was told it passed. What declining leaves behind now follows the verdict: passed stays passed, a warned certificate is described as not blocking with its warnings still standing, and a failing one keeps the text it already had. Tests: 8 new covering PROVIDER_MISMATCH, SSL_UNMANAGED and SELF_SIGNED through the real task - 4 red before this commit. The clean verdict keeps its own assertion so the accurate claim is not lost while removing the wrong one. Co-Authored-By: Claude Opus 5 --- .../update/gatewayCertificateTaskFactory.js | 30 ++++++---- .../gatewayCertificateTaskFactory.spec.js | 55 +++++++++++++++++++ 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js index d198c1ca670..a88656e83d0 100644 --- a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js @@ -23,11 +23,24 @@ const ZEROSSL_URGENT_DAYS = 14; * @param {Config} config * @param {string} externalIp * @param {Object} [options] - * @param {boolean} [options.certificatePassedChecks] - whether the certificate - * this node is running on right now cleared the checks + * @param {string} [options.status] - the verdict for the certificate this node + * is running on right now, which decides what declining actually leaves behind * @return {string} */ -function renderSwitchOffer(config, externalIp, { certificatePassedChecks = false } = {}) { +const DECLINING = { + [CERTIFICATE_STATUS.CHECKS_PASSED]: + ' The certificate this node is running on now passed its checks and stays\n' + + ' in place, so nothing changes if you decline.\n', + [CERTIFICATE_STATUS.WARN]: + ' The certificate this node is running on now is not blocking anything and\n' + + ' stays in place, so declining changes nothing - including the warnings\n' + + ' above, which remain.\n', + [CERTIFICATE_STATUS.INVALID]: + ' Declining leaves the installed certificate exactly as it is: unchanged,\n' + + ' and still failing the checks above.\n', +}; + +function renderSwitchOffer(config, externalIp, { status = CERTIFICATE_STATUS.INVALID } = {}) { return ` Switching this node to Let's Encrypt will: - obtain a new certificate now, free, for ${externalIp} - change platform.gateway.ssl.provider from ${config.get('platform.gateway.ssl.provider')} to letsencrypt @@ -45,11 +58,7 @@ function renderSwitchOffer(config, externalIp, { certificatePassedChecks = false Your image pull is running now and will finish either way, so answering No does not hold this node back from protocol upgrades or security patches. -${certificatePassedChecks - ? ' The certificate this node is running on now passed its checks and stays\n' - + ' in place, so nothing changes if you decline.\n' - : ' Declining leaves the installed certificate exactly as it is: unchanged,\n' - + ' and still failing the checks above.\n'}`; +${DECLINING[status] ?? DECLINING[CERTIFICATE_STATUS.INVALID]}`; } /** @@ -284,7 +293,8 @@ export default function gatewayCertificateTaskFactory( const warn = () => { ctx.certificateWarnings = [ ...(ctx.certificateWarnings ?? []), - `This node's ZeroSSL certificate expires ${remaining}. A free ZeroSSL` + `This node is configured to use ZeroSSL, and the certificate it has` + + ` installed expires ${remaining}. A free ZeroSSL` + " account allows three certificates in total, so dashmate's renewals stop" + ` working after about 270 days. Switch to Let's Encrypt with:` + `\n dashmate ssl obtain ${cfg} --provider letsencrypt`, @@ -301,7 +311,7 @@ export default function gatewayCertificateTaskFactory( const accepted = await promptOrThrow(task, { type: 'toggle', header: renderSwitchOffer(config, config.get('externalIp'), { - certificatePassedChecks: true, + status: verdict.status, }), message: "Switch to Let's Encrypt and obtain a certificate now?", enabled: 'Yes', diff --git a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js index 7a99175f0c1..10d74e2420f 100644 --- a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js @@ -175,6 +175,61 @@ describe('gatewayCertificateTaskFactory', () => { }); }); + // The branch is entered on the configured provider, which is not evidence of + // who issued the leaf that is actually installed. Under a provider mismatch + // the certificate on disk can come from anywhere, so nothing here may assert + // what it is, and nothing may claim it passed checks it only warned on. + describe('a ZeroSSL node whose installed leaf is not ZeroSSL', () => { + const warned = (code) => verdict({ + status: CERTIFICATE_STATUS.WARN, + warnings: [{ code, message: `warning: ${code}` }], + }); + + [ + CERTIFICATE_REASONS.PROVIDER_MISMATCH, + CERTIFICATE_REASONS.SSL_UNMANAGED, + CERTIFICATE_REASONS.SELF_SIGNED, + ].forEach((code) => { + it(`should not call the installed leaf a ZeroSSL certificate on ${code}`, async function it() { + const { context, errors } = await run.call(this, { + checkGatewayCertificate: () => warned(code), + interactive: false, + }); + + expect(errors).to.be.empty(); + + const warnings = context.certificateWarnings.join('\n'); + + expect(warnings).to.contain('three certificates in total'); + expect(warnings).to.not.contain("This node's ZeroSSL certificate"); + expect(warnings).to.contain(`warning: ${code}`); + }); + + it(`should still offer the switch on ${code}`, async function it() { + const { errors } = await run.call(this, { + checkGatewayCertificate: () => warned(code), + }); + + expect(errors).to.be.empty(); + expect(enquirer.prompt).to.have.been.called(); + }); + }); + + it('should not tell a warned node its certificate passed its checks', async function it() { + await run.call(this, { + checkGatewayCertificate: () => warned(CERTIFICATE_REASONS.PROVIDER_MISMATCH), + }); + + expect(enquirer.options[0].header).to.not.contain('passed its checks'); + }); + + it('should still say the certificate passed when the verdict is clean', async function it() { + await run.call(this, { checkGatewayCertificate: () => verdict() }); + + expect(enquirer.options[0].header).to.contain('passed its checks'); + }); + }); + describe('nothing blocks on a certificate that passed', () => { it('should say nothing at all for a provider that is working', async function it() { config.set('platform.gateway.ssl.provider', 'letsencrypt'); From 2a9df46478eb2c4043791d6746373033e1e90908 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 21:22:55 +0700 Subject: [PATCH 45/63] docs(dashmate): drop dev-time narration from the certificate test comments A comment counting how many times a defect has been reported, or referring to "the previous fix", tells a future reader nothing they can use and stops being true the moment the history moves on. The invariant above it already says what the guard protects and why it is asserted across every surface at once. Comment text only; no test or assertion changed. Co-Authored-By: Claude Opus 5 --- .../listr/tasks/update/gatewayCertificateTaskFactory.spec.js | 2 +- packages/dashmate/test/unit/renderedCommands.spec.js | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js index 10d74e2420f..30518f6d206 100644 --- a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js @@ -770,7 +770,7 @@ describe('gatewayCertificateTaskFactory', () => { // This node is on ZeroSSL, so the structural warning about the free-tier // limit rides along in front. What matters here is that neither verdict - // warning is dropped on the way through. + // warning is dropped on the way through, whatever precedes them. expect(context.certificateWarnings.slice(-2)) .to.deep.equal(['issuer disagrees', 'expires tomorrow']); }); diff --git a/packages/dashmate/test/unit/renderedCommands.spec.js b/packages/dashmate/test/unit/renderedCommands.spec.js index 75b4c4fb6aa..b29f52b24b9 100644 --- a/packages/dashmate/test/unit/renderedCommands.spec.js +++ b/packages/dashmate/test/unit/renderedCommands.spec.js @@ -277,9 +277,8 @@ describe('every command dashmate tells an operator to run', () => { // configuration without touching the container. Telling an operator to // restart after obtaining therefore buys them an outage and changes nothing. // - // This has been reported five separate times, each in a surface the previous - // fix did not cover, so the invariant is asserted over every rendered remedy - // at once rather than at the places it happened to appear. + // Asserted over every rendered remedy at once rather than per site, because a + // surface nobody thought to check is exactly where this reappears. describe('remedies routed through ssl obtain', () => { /** * @param {string} label From 7e80c1f83e2ae5de1632b5e52f2b6a4bfe52f552 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 21:46:53 +0700 Subject: [PATCH 46/63] fix(dashmate): treat a PEM delimiter as a line, not as a substring Matching the delimiters anywhere in the text let them be read out of the middle of a mangled line. A bundle whose opening marker carries a stray prefix, an indent, or a sixth hyphen counted as a well-formed certificate and the checks passed, while the gateway refused the same bytes outright. Complete blocks are now anchored to whole lines. The opening counter stays unanchored on purpose: anything that looks like an opening still counts as one, so a marker the block match rightly refused registers anyway and the totals disagree. Stray END markers are still ignored. The anchors allow a trailing carriage return. Without that, a bundle written with Windows line endings would have been called damaged, which would have been a fresh false verdict rather than a fix. Checked against the pinned gateway image with envoy --mode validate, and the selector run over the same bytes: leaf + intermediate envoy OK accepted Windows line endings envoy OK accepted trailing text, no delimiters envoy OK accepted prefix before the opening marker KEY_VALUES_... rejected indented opening marker KEY_VALUES_... rejected opening marker with a sixth hyphen KEY_VALUES_... rejected Correcting the record on the commit before this one: it claimed two of its three tests were red beforehand. Replaying those inputs against adf6fb0bc9 shows only the unterminated-last-block case was. The unterminated-first case already failed closed through the unparseable-block check, and the stray-END case is a control that was green on both sides. The fix itself stands; the evidence for it was overstated. Tests: 4 new, 3 red before this commit. The fourth pins the CRLF bundle as acceptable so the line anchors cannot quietly overshoot. Co-Authored-By: Claude Opus 5 --- .../dashmate/src/ssl/selectLeafCertificate.js | 27 ++++++++------ .../checkGatewayCertificateFactory.spec.js | 35 +++++++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/packages/dashmate/src/ssl/selectLeafCertificate.js b/packages/dashmate/src/ssl/selectLeafCertificate.js index 3860cd9d3a5..19cc99c022b 100644 --- a/packages/dashmate/src/ssl/selectLeafCertificate.js +++ b/packages/dashmate/src/ssl/selectLeafCertificate.js @@ -7,17 +7,24 @@ export const LEAF_SELECTION_ERRORS = { BUNDLE_ORDER: 'BUNDLE_ORDER', }; -const PEM_CERTIFICATE = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; +/** + * Both delimiters must be a line of their own. Matched as substrings they can + * be read out of the middle of a mangled line - a stray prefix, indentation, an + * extra hyphen - and the gateway refuses such a file. The optional carriage + * return keeps a bundle written with Windows line endings valid, which the + * gateway loads without complaint. + */ +const PEM_CERTIFICATE = /^-----BEGIN CERTIFICATE-----\r?$[\s\S]*?^-----END CERTIFICATE-----\r?$/gm; /** - * Counted separately from the block match above, which pairs a BEGIN with an - * END and so cannot see a BEGIN that never closes - a bundle truncated - * mid-block reads as one certificate shorter rather than as damaged, and the - * gateway refuses to load it. + * Deliberately unanchored, unlike the block match above. Anything that looks + * like an opening counts as one, so a marker the block match rightly refused - + * because its line carries something else, or because it never closes - still + * registers, and the totals disagree. * - * Only openings are counted. A stray END with no BEGIN is loaded by the - * gateway without complaint, so treating unpaired delimiters symmetrically - * would reject a bundle that works. + * Only openings are counted. A stray END with no BEGIN is loaded by the gateway + * without complaint, so treating unpaired delimiters symmetrically would reject + * a bundle that works. */ const BEGIN_CERTIFICATE = /-----BEGIN CERTIFICATE-----/g; @@ -86,8 +93,8 @@ export default function selectLeafCertificate(bundlePem, privateKeyPem) { if (begins !== blocks.length) { return { error: LEAF_SELECTION_ERRORS.BUNDLE_UNREADABLE, - detail: `it opens ${begins} certificate(s) but only ${blocks.length} are complete,` - + ' so at least one block is truncated', + detail: `it opens ${begins} certificate(s) but only ${blocks.length} are well formed,` + + ' so at least one block is truncated or its delimiters are damaged', }; } diff --git a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js index 160e9d523e1..d935670931b 100644 --- a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js @@ -131,6 +131,41 @@ describe('checkGatewayCertificateFactory', () => { expect(codes(verdict.reasons)).to.include(CERTIFICATE_REASONS.BUNDLE_UNREADABLE); }); + // The delimiters are a line, not a substring. A line carrying anything else + // besides - a stray prefix, indentation, an extra hyphen - is not a + // delimiter, and the gateway refuses the file, so the checks must not read + // one out of the middle of it. + [ + ['a prefix before the marker', (chain) => `garbage${chain}`], + ['an indented marker', (chain) => chain.replace('-----BEGIN CERTIFICATE-----', ' -----BEGIN CERTIFICATE-----')], + ['a marker with an extra hyphen', (chain) => chain.replace('-----BEGIN CERTIFICATE-----', '------BEGIN CERTIFICATE-----')], + ].forEach(([label, damage]) => { + it(`should block on a bundle with ${label}`, () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + + install(damage(leaf.pem + intermediate.pem), leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.include(CERTIFICATE_REASONS.BUNDLE_UNREADABLE); + }); + }); + + // The gateway loads a bundle with Windows line endings, so requiring the + // delimiter to be its own line must not turn the trailing carriage return + // into damage. + it('should accept a bundle written with CRLF line endings', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + + install((leaf.pem + intermediate.pem).replace(/\n/g, '\r\n'), leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(codes(verdict.reasons)).to.deep.equal([]); + expect(verdict.status).to.equal(CERTIFICATE_STATUS.CHECKS_PASSED); + }); + it('should block on a bundle whose first certificate is unterminated', () => { const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); const truncated = leaf.pem.slice(0, Math.floor(leaf.pem.length / 2)); From e3a270b9186b9925e0e54aaedb0a68f1f03c55a8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 21:50:39 +0700 Subject: [PATCH 47/63] fix(dashmate): say only what the verdict established in the switch offer The copy introduced with the warned states made three claims the checks do not support. It called the pair on disk the certificate the node is running on. These checks read files and never open a connection, so the two are not known to be the same thing - the whole reason the passing status is not called valid. It is now always the certificate installed for the gateway. It said a warned certificate is not blocking anything, which is broader than a local verdict and contradicts the warnings themselves: an unmanaged pair will not renew and a self-signed one is refused by standards-compliant clients. It now says only that nothing about it stopped this update. It referred the operator to warnings above, which are printed after the command finishes and so were not on screen when the prompt asked. They are rendered in the prompt instead. This is the moment the decision is made, so what was found belongs in front of the person making it. The passing text carried the same running-on claim and is corrected with the rest rather than left as the one place the overstatement survives. Tests: 5 new, 3 red before this commit. One of them pins the absence of the failing copy as well as the passing copy, because a WARN verdict falling back to INVALID text would otherwise have gone unnoticed. Co-Authored-By: Claude Opus 5 --- .../update/gatewayCertificateTaskFactory.js | 58 ++++++++++++------- .../gatewayCertificateTaskFactory.spec.js | 46 ++++++++++++++- 2 files changed, 82 insertions(+), 22 deletions(-) diff --git a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js index a88656e83d0..5235758e16a 100644 --- a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js @@ -15,6 +15,39 @@ import renderConfigFlag from '../../../util/renderConfigFlag.js'; */ const ZEROSSL_URGENT_DAYS = 14; +/** + * What declining actually leaves behind, said only as far as the verdict goes. + * + * Always the pair installed for the gateway, never what the node is serving: + * these checks read files and never opened a connection, so the two are not + * known to be the same thing. And never that nothing is wrong in general - only + * that nothing stopped this update. + * + * @param {Object} verdict + * @return {string} + */ +function renderDeclining(verdict) { + if (verdict.status === CERTIFICATE_STATUS.CHECKS_PASSED) { + return ` The certificate installed for the gateway passed these checks and stays + in place, so nothing changes if you decline. +`; + } + + if (verdict.status !== CERTIFICATE_STATUS.WARN) { + return ` Declining leaves the certificate installed for the gateway exactly as it + is: unchanged, and still failing the checks above. +`; + } + + // Rendered here rather than referred to. This prompt is where the operator + // decides, and the warnings are printed only once the command has finished, + // so pointing at them would be pointing at something not yet on screen. + return ` Declining leaves the certificate installed for the gateway exactly as it + is. Nothing about it stopped this update, but these checks did find: + +${verdict.warnings.map(({ message }) => ` - ${message}\n`).join('')}`; +} + /** * The whole argument for switching, including the port-80 requirement, in the * prompt header - this is the last moment the operator can go and open a @@ -23,24 +56,11 @@ const ZEROSSL_URGENT_DAYS = 14; * @param {Config} config * @param {string} externalIp * @param {Object} [options] - * @param {string} [options.status] - the verdict for the certificate this node - * is running on right now, which decides what declining actually leaves behind + * @param {Object} [options.verdict] - decides what declining leaves behind, and + * carries the warnings the operator is being asked to weigh * @return {string} */ -const DECLINING = { - [CERTIFICATE_STATUS.CHECKS_PASSED]: - ' The certificate this node is running on now passed its checks and stays\n' - + ' in place, so nothing changes if you decline.\n', - [CERTIFICATE_STATUS.WARN]: - ' The certificate this node is running on now is not blocking anything and\n' - + ' stays in place, so declining changes nothing - including the warnings\n' - + ' above, which remain.\n', - [CERTIFICATE_STATUS.INVALID]: - ' Declining leaves the installed certificate exactly as it is: unchanged,\n' - + ' and still failing the checks above.\n', -}; - -function renderSwitchOffer(config, externalIp, { status = CERTIFICATE_STATUS.INVALID } = {}) { +function renderSwitchOffer(config, externalIp, { verdict } = {}) { return ` Switching this node to Let's Encrypt will: - obtain a new certificate now, free, for ${externalIp} - change platform.gateway.ssl.provider from ${config.get('platform.gateway.ssl.provider')} to letsencrypt @@ -58,7 +78,7 @@ function renderSwitchOffer(config, externalIp, { status = CERTIFICATE_STATUS.INV Your image pull is running now and will finish either way, so answering No does not hold this node back from protocol upgrades or security patches. -${DECLINING[status] ?? DECLINING[CERTIFICATE_STATUS.INVALID]}`; +${renderDeclining(verdict ?? { status: CERTIFICATE_STATUS.INVALID, warnings: [] })}`; } /** @@ -310,9 +330,7 @@ export default function gatewayCertificateTaskFactory( const accepted = await promptOrThrow(task, { type: 'toggle', - header: renderSwitchOffer(config, config.get('externalIp'), { - status: verdict.status, - }), + header: renderSwitchOffer(config, config.get('externalIp'), { verdict }), message: "Switch to Let's Encrypt and obtain a certificate now?", enabled: 'Yes', disabled: 'Not now', diff --git a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js index 30518f6d206..6e45e694b65 100644 --- a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js @@ -220,13 +220,55 @@ describe('gatewayCertificateTaskFactory', () => { checkGatewayCertificate: () => warned(CERTIFICATE_REASONS.PROVIDER_MISMATCH), }); - expect(enquirer.options[0].header).to.not.contain('passed its checks'); + expect(enquirer.options[0].header).to.not.contain('passed these checks'); + }); + + // Absence of the passing sentence is not enough on its own - the failing + // copy is also absent that sentence, so the offer could silently fall back + // to telling a warned operator their certificate is failing. + it('should not tell a warned node its certificate is failing either', async function it() { + await run.call(this, { + checkGatewayCertificate: () => warned(CERTIFICATE_REASONS.PROVIDER_MISMATCH), + }); + + expect(enquirer.options[0].header).to.not.contain('still failing the checks'); + }); + + // The offer is where the operator decides, so what was found has to be in + // front of them there, not promised for later. + it('should show the operator the warnings it is asking them to decide on', async function it() { + await run.call(this, { + checkGatewayCertificate: () => warned(CERTIFICATE_REASONS.SELF_SIGNED), + }); + + const { header } = enquirer.options[0]; + + expect(header).to.contain(`warning: ${CERTIFICATE_REASONS.SELF_SIGNED}`); + expect(header).to.not.contain('warnings\n above'); + }); + + // These checks never opened a connection, so what is on disk is all that + // was established - not what the gateway is serving right now. + it('should not claim the installed pair is what the node is serving', async function it() { + await run.call(this, { + checkGatewayCertificate: () => warned(CERTIFICATE_REASONS.PROVIDER_MISMATCH), + }); + + expect(enquirer.options[0].header).to.not.contain('running on'); + }); + + it('should not claim a warned certificate blocks nothing at all', async function it() { + await run.call(this, { + checkGatewayCertificate: () => warned(CERTIFICATE_REASONS.SSL_UNMANAGED), + }); + + expect(enquirer.options[0].header).to.not.contain('not blocking anything'); }); it('should still say the certificate passed when the verdict is clean', async function it() { await run.call(this, { checkGatewayCertificate: () => verdict() }); - expect(enquirer.options[0].header).to.contain('passed its checks'); + expect(enquirer.options[0].header).to.contain('passed these checks'); }); }); From e94119839cb7d586d8f427afdb4606ee3bef69d6 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 21:52:33 +0700 Subject: [PATCH 48/63] docs(dashmate): state what the certificate comments protect, not how it went Swept every comment added on this branch rather than the one line named, and three carried development history in place of a reason. Two test comments explained an invariant by how often the defect had come back and where nobody had looked. A reader arriving later has none of that context and cannot act on it. They now say what the invariant is and why it belongs to the whole surface rather than to any one call site. One production comment explained the port-80 silence in the past tense, as something a previous behaviour had done to operators. The reasoning holds in the present and reads as a rule rather than as a retrospective. Comment text only; no behaviour, assertion or test name changed. Co-Authored-By: Claude Opus 5 --- .../analyse/analyseGatewayCertificateFactory.js | 6 +++--- .../dashmate/test/unit/renderedCommands.spec.js | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 52b398d196c..3bf6f633893 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -230,9 +230,9 @@ ${restartHint(cfg)}`, // from a connect test, which measures whether something is listening - and // nothing listens on port 80 on a healthy node except for the seconds a // renewal takes, so it reports closed on healthy nodes by construction. - // Reporting it alongside certificate problems put the claim in front of - // exactly the operators least able to tell a real firewall problem from a - // phantom one, and sent them to rewrite rules that were already correct. + // Alongside a certificate problem it reads as the cause of that problem, to + // exactly the operators least able to tell a real firewall fault from this + // phantom one, and sends them to rewrite rules that are already correct. // A drop carries no information; only an answer or a refusal does. return problems; diff --git a/packages/dashmate/test/unit/renderedCommands.spec.js b/packages/dashmate/test/unit/renderedCommands.spec.js index b29f52b24b9..6c70033f8c6 100644 --- a/packages/dashmate/test/unit/renderedCommands.spec.js +++ b/packages/dashmate/test/unit/renderedCommands.spec.js @@ -277,8 +277,9 @@ describe('every command dashmate tells an operator to run', () => { // configuration without touching the container. Telling an operator to // restart after obtaining therefore buys them an outage and changes nothing. // - // Asserted over every rendered remedy at once rather than per site, because a - // surface nobody thought to check is exactly where this reappears. + // Asserted over every rendered remedy at once rather than at individual call + // sites, because the invariant belongs to the whole surface: any remedy that + // ends in an obtain is finished, wherever it is written. describe('remedies routed through ssl obtain', () => { /** * @param {string} label @@ -381,11 +382,10 @@ describe('every command dashmate tells an operator to run', () => { }); }); - // The backstop. Rendering can only check surfaces a test knows about, and - // this class of defect has recurred by arriving in a place nobody thought to - // check. Anything under src/ that lays out a command has to name the node, - // and a file joining the exemption list is a visible edit rather than a - // silent one. + // The backstop. Rendering can only check surfaces a test knows about, so a + // command laid out somewhere no test drives would go unexamined. Anything + // under src/ that lays out a command has to name the node, and a file joining + // the exemption list is a visible edit rather than a silent one. describe('as written', () => { it('lays out no command anywhere in src that cannot name the node', () => { const offenders = []; From 7805de2c34ac7afd6a4098020e9b6841c1777ef1 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 21 Aug 2026 22:16:48 +0700 Subject: [PATCH 49/63] fix(dashmate): accept delimiter lines padded with trailing whitespace Anchoring the delimiters to whole lines rejected a bundle whose BEGIN or END marker carries trailing whitespace. The gateway loads that file without complaint, so the checks blocked an update on a node that serves TLS perfectly well and sent its operator looking for a certificate problem that does not exist. A false alarm here is worse than a missed one. A missed detection leaves an operator where they already were; this stopped their upgrade and gave them a fault to chase. What may follow the marker on its line is now decided by what the gateway tolerates rather than by what looks well formed. Spaces and tabs pass, a Windows carriage return passes, and a suffix that is not whitespace is still refused - by the gateway and so by this. Tabs are included deliberately. The correction was specified as spaces only, but the pinned image accepts a tab-padded delimiter too, so permitting only spaces would have left the same false alarm behind on a narrower input. Parity re-measured over twelve bundle shapes, each run through envoy --mode validate on dashpay/envoy:1.39.0-impr.1 and through the selector, all twelve from one chain and key: accepted by both normal, CRLF, trailing text, stray END, trailing space, trailing tab, trailing space with CRLF rejected by both non-whitespace suffix, prefixed marker, indented marker, six-hyphen marker, unterminated final block Tests: 4 new, 3 red before this commit. The fourth pins the non-whitespace suffix as refused, so widening what the line may carry cannot go further than the gateway does. Co-Authored-By: Claude Opus 5 --- .../dashmate/src/ssl/selectLeafCertificate.js | 17 ++++++---- .../checkGatewayCertificateFactory.spec.js | 32 +++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/dashmate/src/ssl/selectLeafCertificate.js b/packages/dashmate/src/ssl/selectLeafCertificate.js index 19cc99c022b..259cd7d5f48 100644 --- a/packages/dashmate/src/ssl/selectLeafCertificate.js +++ b/packages/dashmate/src/ssl/selectLeafCertificate.js @@ -8,13 +8,18 @@ export const LEAF_SELECTION_ERRORS = { }; /** - * Both delimiters must be a line of their own. Matched as substrings they can - * be read out of the middle of a mangled line - a stray prefix, indentation, an - * extra hyphen - and the gateway refuses such a file. The optional carriage - * return keeps a bundle written with Windows line endings valid, which the - * gateway loads without complaint. + * Both delimiters must start a line of their own. Matched as substrings they + * can be read out of the middle of a mangled line - a stray prefix, + * indentation, an extra hyphen - and the gateway refuses such a file. + * + * What may follow the marker on its line is decided by what the gateway + * tolerates, not by what looks tidy: trailing spaces or tabs and a Windows + * carriage return are all loaded without complaint, so treating any of them as + * damage would refuse an update on a node that serves TLS perfectly well. A + * suffix that is not whitespace is a different matter - the gateway refuses + * that, and so does this. */ -const PEM_CERTIFICATE = /^-----BEGIN CERTIFICATE-----\r?$[\s\S]*?^-----END CERTIFICATE-----\r?$/gm; +const PEM_CERTIFICATE = /^-----BEGIN CERTIFICATE-----[ \t]*\r?$[\s\S]*?^-----END CERTIFICATE-----[ \t]*\r?$/gm; /** * Deliberately unanchored, unlike the block match above. Anything that looks diff --git a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js index d935670931b..6592860027c 100644 --- a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js @@ -152,6 +152,38 @@ describe('checkGatewayCertificateFactory', () => { }); }); + // The gateway loads a delimiter line padded with trailing whitespace, so + // treating the padding as damage would refuse an update on a node that + // serves TLS perfectly well. A suffix that is not whitespace is different: + // the gateway refuses that, and so does this. + [ + ['a trailing space', (chain) => chain.replace(/-----(BEGIN|END) CERTIFICATE-----/g, '-----$1 CERTIFICATE----- ')], + ['a trailing tab', (chain) => chain.replace(/-----(BEGIN|END) CERTIFICATE-----/g, '-----$1 CERTIFICATE-----\t')], + ['trailing space and CRLF', (chain) => chain.replace(/-----(BEGIN|END) CERTIFICATE-----/g, '-----$1 CERTIFICATE----- ').replace(/\n/g, '\r\n')], + ].forEach(([label, pad]) => { + it(`should accept a bundle whose delimiters carry ${label}`, () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + + install(pad(leaf.pem + intermediate.pem), leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + + expect(codes(verdict.reasons)).to.deep.equal([]); + expect(verdict.status).to.equal(CERTIFICATE_STATUS.CHECKS_PASSED); + }); + }); + + it('should block on a delimiter line carrying a suffix that is not whitespace', () => { + const { leaf, intermediate } = issueChain({ ip: EXTERNAL_IP }); + const damaged = (leaf.pem + intermediate.pem) + .replace(/-----(BEGIN|END) CERTIFICATE-----/g, '-----$1 CERTIFICATE-----x'); + + install(damaged, leaf.keyPem); + + expect(codes(checkGatewayCertificate(config).reasons)) + .to.include(CERTIFICATE_REASONS.BUNDLE_UNREADABLE); + }); + // The gateway loads a bundle with Windows line endings, so requiring the // delimiter to be its own line must not turn the trailing carriage return // into damage. From 17cb102d4ff1962a8e426bde45afcbe2ecd4a322 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 22 Aug 2026 00:56:47 +0700 Subject: [PATCH 50/63] fix(dashmate): stop advising a restart that reloads the stale certificate CERTIFICATE_NOT_INSTALLED fires when the issued certificate was never copied to where the gateway loads from. Restarting Platform makes the gateway re-read the copy it already has - the out-of-date one - so an operator following this advice on a node still serving a valid certificate takes it off the network themselves. That is not hypothetical. On a live node it appeared as problems 1 and 3 of a single doctor report: this text telling the operator to restart, and the gateway analyser three lines below telling them not to, while the wire served a publicly trusted certificate and the copy on disk had expired 158 days earlier. Two opposite instructions in one report, one of which breaks the node. The remedy is the opposite of a restart: install the issued certificate so the two agree, which also signals the gateway and costs no downtime. The guard did not catch this, so the guard is the other half of the fix. It drove the surfaces it was told about, and remediation text is produced by eight different files here - so an analyser nobody thought to drive was invisible to it. Worse, the invariant it checked was that an obtain and a restart never appear together, and this text prescribes a restart with no obtain anywhere near it, so it would have passed even if driven. The check is now inverted and discovery-based, over the same file walk that already proves every command names its node: a restart prescribed anywhere under src/ fails unless the file is listed with the reason a restart is right there. New sites fail by default rather than passing unseen, in either notation an author might use, and a listed file that stops advising a restart fails too so the list cannot go stale. Verified by construction rather than asserted: an unlisted restart added to a different analyser fails the guard in both the chalk and indented forms, and a stale list entry fails the honesty check. Tests: 5 new, 4 red before this commit - the structural one named the offending file on its own without being pointed at it. Co-Authored-By: Claude Opus 5 --- .../doctor/analyse/analyseConfigFactory.js | 13 +++-- .../analyse/analyseConfigFactory.spec.js | 32 ++++++++++++ .../test/unit/renderedCommands.spec.js | 51 +++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js index 4169eddbacc..d61d244ffa9 100644 --- a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js @@ -184,11 +184,18 @@ Note that changing it makes renewal register a new account with the authority.`, 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`, }, + // Never a restart. This fires because the issued certificate was not + // copied to where the gateway loads from, so a restart makes the gateway + // re-read the copy it already has - the out-of-date one. On a node still + // serving a valid certificate that is what takes it off the network. [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 Platform: {bold.cyanBright dashmate restart --platform}`, + solution: chalk`The issued certificate was never copied to where the gateway loads from, +so the two disagree. Install it - that also signals the gateway, with no +downtime: {bold.cyanBright dashmate ssl obtain --provider=letsencrypt} +Do not restart Platform to fix this. A restart only reloads the copy the +gateway already has, which is the out-of-date one, and this node may still +be serving a valid certificate that a restart would throw away.`, }, [LETSENCRYPT_ERRORS.CERTIFICATE_NOT_VALID]: { description: chalk`Let's Encrypt certificate is not valid.`, diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js index 0ecdf91e457..fbf5a3182fe 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js @@ -65,6 +65,38 @@ describe('analyseConfigFactory', () => { expect(problems[0].getDescription()).to.include('No contact is registered'); }); + // This fires when the issued certificate was never copied to where the gateway + // loads from. A restart only makes the gateway re-read the copy it already + // has, which is the out-of-date one - so on a node still serving a valid + // certificate, following that advice is what takes it off the network. + describe('a renewed certificate that never reached the gateway', () => { + const notInstalled = () => analyseSslSample({ + error: LETSENCRYPT_ERRORS.CERTIFICATE_NOT_INSTALLED, + data: {}, + }, 'letsencrypt'); + + it('should not tell the operator to restart Platform', () => { + const [problem] = notInstalled(); + + expect(problem.getSolution()).to.not.match(/dashmate\s+restart/); + }); + + it('should tell the operator to install the issued certificate', () => { + const [problem] = notInstalled(); + + expect(problem.getSolution()).to.contain('dashmate ssl obtain'); + }); + + // The report can carry the gateway analyser's finding for the same node, + // which says in as many words not to restart. Two opposite instructions in + // one report leave the operator to guess, and one guess breaks the node. + it('should not contradict the advice not to restart', () => { + const [problem] = notInstalled(); + + expect(problem.getSolution()).to.match(/not restart|Do not restart/); + }); + }); + it('should report a problem for a ZeroSSL certificate that expires soon', () => { const problems = analyseSslSample({ error: ZEROSSL_ERRORS.CERTIFICATE_EXPIRES_SOON, diff --git a/packages/dashmate/test/unit/renderedCommands.spec.js b/packages/dashmate/test/unit/renderedCommands.spec.js index 6c70033f8c6..5832c58b38a 100644 --- a/packages/dashmate/test/unit/renderedCommands.spec.js +++ b/packages/dashmate/test/unit/renderedCommands.spec.js @@ -54,6 +54,22 @@ const PRESENTED_IN_SOURCE = new RegExp( * skipped by pattern so a NEW file cannot join them silently - which is the * whole point of the sweep below. */ +/** + * Where telling an operator to restart Platform is the right advice, and why. + * + * A restart makes the gateway re-read the certificate files already on disk, so + * it helps only where those files are known to be the better ones. Anywhere + * else it loads something staler than what is being served and takes a working + * node off the network. + */ +const RESTART_ADVICE_ALLOWED = { + 'src/doctor/analyse/analyseGatewayCertificateFactory.js': + 'a renewed certificate is already on disk, verified newer and usable than the one being ' + + 'served, and only the gateway has not picked it up - re-reading the files is the entire ' + + 'remedy; and the incomplete-chain case, where the operator edits the bundle by hand and ' + + 'nothing else signals the gateway afterwards', +}; + const PRE_EXISTING_BARE_COMMANDS = [ 'src/commands/doctor/index.js', 'src/commands/setup.js', @@ -405,6 +421,41 @@ describe('every command dashmate tells an operator to run', () => { expect(offenders, offenders.join('\n')).to.be.empty(); }); + // Restarting Platform makes the gateway re-read the files already on disk. + // That is the remedy only when those files are known to be better than what + // is being served; every other time it replaces something working with + // something older, and the operator did it to themselves on this advice. + // + // Discovered rather than enumerated. A rendered check can only drive + // surfaces it was told about, and remediation text is produced by eight + // different files here, so a restart prescribed anywhere under src/ has to + // be written down with its reason or this fails. + it('prescribes no restart anywhere in src that is not justified here', () => { + const offenders = []; + + javascriptFilesIn('src').forEach((file) => { + const advises = presentedCommandsIn(fs.readFileSync(file, 'utf8')) + .some((command) => /dashmate\s+restart/.test(command)); + + if (advises && !RESTART_ADVICE_ALLOWED[file]) { + offenders.push(file); + } + }); + + expect(offenders, `restart advice with no recorded reason:\n${offenders.join('\n')}`) + .to.be.empty(); + }); + + it('keeps the restart allow-list honest', () => { + Object.keys(RESTART_ADVICE_ALLOWED).forEach((file) => { + const advises = presentedCommandsIn(fs.readFileSync(file, 'utf8')) + .some((command) => /dashmate\s+restart/.test(command)); + + expect(advises, `${file} no longer prescribes a restart and can leave the list`) + .to.be.true(); + }); + }); + it('keeps the exemption list honest', () => { PRE_EXISTING_BARE_COMMANDS.forEach((file) => { expect(fs.existsSync(file), `${file} is listed but gone`).to.be.true(); From 078e07078965e240729a539cd15952a95a6ec158 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 22 Aug 2026 17:28:47 +0700 Subject: [PATCH 51/63] refactor(dashmate): tell an operator what to do, and check the certificate one way The certificate messages were written to survive review, not to be read by the person they are for. They named internal statuses, explained the certificate authority's rate-limit accounting, cited how many nodes on the network were in the same state, and narrated what dashmate had and had not noticed. An operator wants to know what is wrong with their node and what to type next. Every claim now stays inside what the code establishes. Where a cause cannot be narrowed it is named as the two possibilities rather than guessed at: a missing issuer certificate reads the same to OpenSSL whether the chain is incomplete or the machine simply does not trust the authority, and telling someone to repair a bundle that is already correct sends them nowhere. `update --check-certificate` is gone. `update` checks by default and `--skip-certificate-check` opts out, so a second flag that only performs the same check was a parallel path to the same answer. Removing it also removes what existed to serve it: the read-only repository mode, the migration-required error, the filesystem-mutating migration registry, and their tests. Running `dashmate update` on a node that is still up reports the certificate before it pulls anything, which is what the flag was for. The port-80 permanence notice is rendered by the task that obtains the certificate instead of being written to stderr from a `finally`. It reaches the operator who succeeded, which is the one who most needs it and the one a failure path never reaches. Co-Authored-By: Claude Opus 5 --- .../configs/getConfigFileMigrationsFactory.js | 13 - packages/dashmate/src/commands/ssl/obtain.js | 14 - packages/dashmate/src/commands/update.js | 97 +----- .../configFile/ConfigFileJsonRepository.js | 66 ---- .../ConfigFileMigrationRequiredError.js | 45 --- .../doctor/analyse/analyseConfigFactory.js | 3 +- .../analyseGatewayCertificateFactory.js | 94 +++-- ...obtainLetsEncryptCertificateTaskFactory.js | 90 +++-- .../update/gatewayCertificateTaskFactory.js | 24 +- .../dashmate/src/oclif/command/BaseCommand.js | 25 +- .../dashmate/src/ssl/certificateReporting.js | 72 ++++ .../src/ssl/checkGatewayCertificateFactory.js | 26 +- .../src/ssl/errors/LegoDidNotStartError.js | 20 +- .../src/ssl/renderCertificateGuidance.js | 67 ++-- .../test/unit/commands/ssl/obtain.spec.js | 93 ++--- .../test/unit/commands/update.spec.js | 85 ++--- .../ConfigFileJsonRepository.spec.js | 327 ------------------ .../migrateConfigFileFactory.spec.js | 33 -- .../analyse/analyseConfigFactory.spec.js | 9 + .../analyseGatewayCertificateFactory.spec.js | 69 +++- .../gatewayCertificateTaskFactory.spec.js | 48 +++ .../unit/oclif/command/BaseCommand.spec.js | 37 +- .../checkGatewayCertificateFactory.spec.js | 4 +- ...nLetsEncryptCertificateTaskFactory.spec.js | 90 ++++- .../unit/ssl/probeServedCertificate.spec.js | 16 + .../ssl/renderCertificateGuidance.spec.js | 40 ++- 26 files changed, 611 insertions(+), 896 deletions(-) delete mode 100644 packages/dashmate/src/config/errors/ConfigFileMigrationRequiredError.js create mode 100644 packages/dashmate/src/ssl/certificateReporting.js diff --git a/packages/dashmate/configs/getConfigFileMigrationsFactory.js b/packages/dashmate/configs/getConfigFileMigrationsFactory.js index 7424c517c98..75714db511c 100644 --- a/packages/dashmate/configs/getConfigFileMigrationsFactory.js +++ b/packages/dashmate/configs/getConfigFileMigrationsFactory.js @@ -16,19 +16,6 @@ import { stockImagePattern, historicalStockImagePattern } from '../src/config/st * @param {DefaultConfigs} defaultConfigs * @returns {getConfigFileMigrations} */ -/** - * Migrations that move, copy or delete files, as opposed to reshaping data. - * - * Almost every migration only rewrites the configuration object, which can be - * applied in memory and discarded. These two relocate TLS material and remove - * the originals, so a caller that has promised to change nothing cannot run - * them and has to decline instead. - * - * A migration added here must be added to this set, and a test fails if one - * touches the filesystem without being declared. - */ -export const FILESYSTEM_MUTATING_MIGRATIONS = ['0.25.7', '1.0.0-dev.12']; - export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) { /** * @typedef {function} getConfigFileMigrations diff --git a/packages/dashmate/src/commands/ssl/obtain.js b/packages/dashmate/src/commands/ssl/obtain.js index d74cf3218d2..c681690ab0d 100644 --- a/packages/dashmate/src/commands/ssl/obtain.js +++ b/packages/dashmate/src/commands/ssl/obtain.js @@ -2,7 +2,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 { PORT_80_PERMANENCE } from '../../listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js'; import isInteractiveSession from '../../util/isInteractiveSession.js'; import MuteOneLineError from '../../oclif/errors/MuteOneLineError.js'; import Certificate from '../../ssl/zerossl/Certificate.js'; @@ -162,19 +161,6 @@ Certificate will be renewed if it is about to expire (see 'expiration-days' flag await tasks.run(context); } catch (e) { throw new MuteOneLineError(e); - } finally { - // Only when the gateway's certificate actually changed. This is the - // command the certificate check tells an operator to run, and an - // operator who opened port 80 for this one migration is the one who most - // needs to hear that it has to stay open - they never saw a failure that - // would have said so. - // - // Printed even when a later step fails: a certificate that was issued - // and then failed to install still counts against this node's limits, - // and the operator is about to close the port either way. - if (context.certificateObtained && provider === SSL_PROVIDERS.LETSENCRYPT) { - process.stderr.write(`\n${PORT_80_PERMANENCE}\n`); - } } } } diff --git a/packages/dashmate/src/commands/update.js b/packages/dashmate/src/commands/update.js index 4995c92a6da..9669899e894 100644 --- a/packages/dashmate/src/commands/update.js +++ b/packages/dashmate/src/commands/update.js @@ -7,8 +7,11 @@ import ConfigBaseCommand from '../oclif/command/ConfigBaseCommand.js'; import MuteOneLineError from '../oclif/errors/MuteOneLineError.js'; import printArrayOfObjects from '../printers/printArrayOfObjects.js'; import CertificateUnresolvedError from '../ssl/errors/CertificateUnresolvedError.js'; -import { CERTIFICATE_STATUS } from '../ssl/checkGatewayCertificateFactory.js'; -import renderCertificateGuidance from '../ssl/renderCertificateGuidance.js'; +import { describeStatus } from '../ssl/checkGatewayCertificateFactory.js'; +import { + reportUnresolved as reportUnresolved_, + writeDiagnostics, +} from '../ssl/certificateReporting.js'; import isEnvironmentFlagSet from '../util/isEnvironmentFlagSet.js'; import isInteractiveSession from '../util/isInteractiveSession.js'; @@ -23,20 +26,6 @@ export default class UpdateCommand extends ConfigBaseCommand { // issued it, so it holds the configuration lock for its whole run. static mutatesConfig = true; - /** - * The preflight changes nothing, and it exists to be run before the node is - * stopped - possibly while the helper is renewing. So it takes no lock, saves - * no configuration, and does not persist a migration: any of those could make - * it fail on a lock acquire timeout, and all of them would break the promise - * that it changes nothing. - * - * @param {Object} flags - * @return {boolean} - */ - static isReadOnlyRun(flags) { - return flags['check-certificate'] === true; - } - static description = 'Update node software'; static flags = { @@ -56,11 +45,6 @@ export default class UpdateCommand extends ConfigBaseCommand { + ' changed. Also DASHMATE_NON_INTERACTIVE. Use CI=0 to prompt on a machine that exports CI', default: false, }), - 'check-certificate': Flags.boolean({ - description: 'only report on the gateway certificate and exit. Pulls no images, prompts for' - + ' nothing and changes nothing. Safe to run before dashmate stop', - default: false, - }), }; /** @@ -87,7 +71,6 @@ export default class UpdateCommand extends ConfigBaseCommand { const { format, verbose: isVerbose, - 'check-certificate': checkCertificateOnly, } = flags; const skipCertificateCheck = flags['skip-certificate-check'] === true @@ -98,55 +81,13 @@ export default class UpdateCommand extends ConfigBaseCommand { const isGated = config.get('platform.enable') === true && GATED_NETWORKS.includes(config.get('network')); - /** - * @param {Object} verdict - * @param {boolean} [obtainAttemptFailed] - * @return {Promise} - */ - const reportUnresolved = async (verdict, obtainAttemptFailed = false) => { - let isNodeRunning = false; - try { - isNodeRunning = await dockerCompose.isServiceRunning(config, 'gateway'); - } catch { - // Docker being unavailable says nothing about the certificate, and the - // node-state line is a courtesy rather than part of the verdict. - } - - process.stderr.write(renderCertificateGuidance({ - config, - verdict, - isNodeRunning, - pull: this.pullResult ?? null, - obtainAttemptFailed, - })); - }; - - // Reports only. No pull is started, nothing is prompted, obtained, written - // or reloaded - this is what an operator can run before stopping the node. - if (checkCertificateOnly) { - if (!isGated) { - return; - } - - const verdict = checkGatewayCertificate(config); - - process.stderr.write(`${JSON.stringify({ - status: verdict.status, - reasons: verdict.reasons.map(({ code }) => code), - warnings: verdict.warnings.map(({ code }) => code), - provider: verdict.provider, - config: config.getName(), - expiresAt: verdict.installed ? verdict.installed.validTo.toISOString() : null, - })}\n`); - - if (verdict.status === CERTIFICATE_STATUS.INVALID) { - await reportUnresolved(verdict); - - throw new MuteOneLineError(new CertificateUnresolvedError(verdict)); - } - - return; - } + const reportUnresolved = (verdict, obtainAttemptFailed = false) => reportUnresolved_({ + config, + verdict, + dockerCompose, + pull: this.pullResult ?? null, + obtainAttemptFailed, + }); // A prompt that leaks past the interactivity guard neither throws nor // settles: the event loop simply drains and the process exits 0 with @@ -260,18 +201,10 @@ export default class UpdateCommand extends ConfigBaseCommand { // Under JSON output stdout is exactly one parseable array, so everything a // machine might want about the certificate goes to stderr as one line. if (format === OUTPUT_FORMATS.JSON && context.certificate) { - process.stderr.write(`${JSON.stringify({ - status: context.certificate.status, - reasons: context.certificate.reasons.map(({ code }) => code), - warnings: context.certificate.warnings.map(({ code }) => code), - provider: context.certificate.provider, - config: config.getName(), - expiresAt: context.certificate.installed - ? context.certificate.installed.validTo.toISOString() - : null, + writeDiagnostics(context.certificate, config, { skipped: context.certificateSkipped === true, pull: this.pullResult ?? null, - })}\n`); + }); } (context.certificateWarnings ?? []).forEach((warning) => { @@ -280,7 +213,7 @@ export default class UpdateCommand extends ConfigBaseCommand { if (context.certificateSkipped) { process.stderr.write(`Gateway certificate enforcement was skipped.` - + ` The check still ran and its status is ${context.certificate.status}.\n\n`); + + ` The check still ran, and the certificate ${describeStatus(context.certificate.status)}.\n\n`); } if (context.certificateSuccess) { diff --git a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js index 4f06c829427..6d1507f9b90 100644 --- a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js +++ b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js @@ -5,10 +5,8 @@ import { randomUUID } from 'crypto'; import lockfile from 'proper-lockfile'; import semver from 'semver'; import writeFileAtomic from 'write-file-atomic'; -import { FILESYSTEM_MUTATING_MIGRATIONS } from '../../../configs/getConfigFileMigrationsFactory.js'; import Config from '../Config.js'; import { PACKAGE_ROOT_DIR } from '../../constants.js'; -import ConfigFileMigrationRequiredError from '../errors/ConfigFileMigrationRequiredError.js'; import ConfigFileNotFoundError from '../errors/ConfigFileNotFoundError.js'; import InvalidConfigFileFormatError from '../errors/InvalidConfigFileFormatError.js'; import configFileJsonSchema from './configFileJsonSchema.js'; @@ -254,11 +252,6 @@ export default class ConfigFileJsonRepository { * write. * * @param {Object} [options={}] - passed through to read() - * @param {boolean} [options.readOnly=false] - for a caller that has promised - * to change nothing. Migrations that only reshape data are applied in - * memory and discarded; the two that move and delete files on disk are - * refused outright, because running those on such a caller's behalf would - * break the promise and do it without the lock * @param {function(Config[]): void} [onMigrated] - runs before the migrated * config file is saved and while the lock is held * @returns {{configFile: ConfigFile}} @@ -271,37 +264,6 @@ export default class ConfigFileJsonRepository { return { configFile, migrated }; }; - // Reading is what runs the migrations, and some of them copy TLS material - // to a new location, remove the originals, and then delete the legacy ssl - // directory outright. A caller that promised to change nothing cannot read - // a config file that is not current: declining is the only honest answer, - // and it stops a stop-first upgrade before the node goes down rather than - // after. - // - // Only the migrations that touch the filesystem are refused. The rest - // reshape the configuration object, which can be applied in memory and - // thrown away - and has to be, because the first run after an upgrade is - // both the run where a migration is due and the run this mode exists for. - // - // Answering a different question from the one below. "Take the lock" is - // safe to answer yes to whenever the state cannot be read, but "tell the - // operator an older dashmate wrote this" has to be true - a file that is - // missing or damaged must report itself as missing or damaged, and one of - // those errors is what first-run setup catches to create defaults. - if (options.readOnly === true) { - // Read once. Deciding from one read and then migrating from another - // leaves a window in which the file can be swapped for a legacy one - // after it has been judged safe, and the destructive migrations would - // then run with no lock held. - const configFileData = this.#readRawConfigFile(); - - if (this.#hasFilesystemMigrationDue(configFileData.configFormatVersion)) { - throw new ConfigFileMigrationRequiredError(this.configFilePath); - } - - return { configFile: this.#buildConfigFile(configFileData, options) }; - } - // Decide whether a migration is due from the recorded version alone. // Migrations are not all pure - some move service files on disk and delete // the originals - so running them to find out would do that work outside @@ -333,34 +295,6 @@ export default class ConfigFileJsonRepository { }); } - /** - * Whether the recorded format version is demonstrably older than this build's. - * - * Narrower than the question below, and deliberately so: this one is only - * true when both versions could be read and compared. Anything that defeats - * the comparison - no file, unreadable file, a missing or unparseable - * version, no target to compare against - is not evidence that an older - * dashmate wrote the file, so it answers false and leaves the file to report - * its own problem. - * - * @param {*} rawRecordedVersion - the version recorded in the snapshot being - * judged, so the decision and the migration cannot disagree - * @returns {boolean} - */ - #hasFilesystemMigrationDue(rawRecordedVersion) { - const recordedVersion = typeof rawRecordedVersion === 'string' - && semver.valid(rawRecordedVersion) !== null - ? rawRecordedVersion - : null; - - if (recordedVersion === null || typeof this.configFormatVersion !== 'string') { - return false; - } - - return FILESYSTEM_MUTATING_MIGRATIONS.some((version) => semver.gt(version, recordedVersion) - && semver.lte(version, this.configFormatVersion)); - } - /** * Whether the file on disk records an older format than this build produces. * diff --git a/packages/dashmate/src/config/errors/ConfigFileMigrationRequiredError.js b/packages/dashmate/src/config/errors/ConfigFileMigrationRequiredError.js deleted file mode 100644 index 199213e9e8d..00000000000 --- a/packages/dashmate/src/config/errors/ConfigFileMigrationRequiredError.js +++ /dev/null @@ -1,45 +0,0 @@ -import AbstractError from '../../errors/AbstractError.js'; - -/** - * The configuration file needs migrating, and this caller promised not to - * change anything. - * - * Migrations are not all pure: one copies TLS material to a new location, - * removes the originals and then deletes the whole legacy ssl directory. Doing - * that on behalf of a command documented as safe to run against a node that is - * still up - without holding the configuration lock, and without recording the - * result, so it would happen again on the next run - is worse than declining. - */ -export default class ConfigFileMigrationRequiredError extends AbstractError { - /** - * @param {string} configFilePath - */ - constructor(configFilePath) { - // Wrapped short: this reaches the operator through oclif's error printer, - // which hard-wraps at the terminal width less six and breaks mid-token. - super(`This node's configuration was written by an older dashmate -and has to be migrated before it can be read: - - ${configFilePath} - -Migrating moves and removes files on disk, so a command -that changes nothing will not do it. - -Run any other dashmate command for this node first - any one -that is not this check. It migrates the configuration while -holding the configuration lock. Then run this one again. - -No command is suggested here on purpose: this is raised before -a node has been selected, so any command written out would -name the wrong one as often as the right one.`); - - this.configFilePath = configFilePath; - } - - /** - * @return {string} - */ - getConfigFilePath() { - return this.configFilePath; - } -} diff --git a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js index d61d244ffa9..8f74664a140 100644 --- a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js @@ -4,6 +4,7 @@ import { ERRORS as LETSENCRYPT_ERRORS } from '../../ssl/letsencrypt/validateLets import { ERRORS as ZEROSSL_ERRORS } from '../../ssl/zerossl/validateZeroSslCertificateFactory.js'; import { SEVERITY } from '../Prescription.js'; import Problem from '../Problem.js'; +import renderConfigFlag from '../../util/renderConfigFlag.js'; /** * Whether a ZeroSSL certificate can be renewed depends on the operator's plan, which dashmate @@ -192,7 +193,7 @@ Note that changing it makes renewal register a new account with the authority.`, description: chalk`A renewed Let's Encrypt certificate has not been installed for the gateway.`, solution: chalk`The issued certificate was never copied to where the gateway loads from, so the two disagree. Install it - that also signals the gateway, with no -downtime: {bold.cyanBright dashmate ssl obtain --provider=letsencrypt} +downtime: {bold.cyanBright dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider=letsencrypt} Do not restart Platform to fix this. A restart only reloads the copy the gateway already has, which is the out-of-date one, and this node may still be serving a valid certificate that a restart would throw away.`, diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 3bf6f633893..196be877df7 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -20,6 +20,55 @@ import renderConfigFlag from '../../util/renderConfigFlag.js'; * @param {string} cfg * @return {string} */ +/** + * Plain wording for the connection failures a probe can report. + * + * The codes come from Node and from OpenSSL, and an operator reading a doctor + * report has no way to look them up. Anything not listed falls through to the + * code itself rather than being softened into something vaguer - an unfamiliar + * code is still searchable, whereas "something went wrong" is not. + */ +const CONNECTION_FAILURES = { + ETIMEDOUT: 'nothing answered in time', + ECONNREFUSED: 'the connection was refused', + EHOSTUNREACH: 'the address could not be reached', + ENETUNREACH: 'the network could not be reached', + ECONNRESET: 'the connection was closed before it finished', + NO_PEER_CERTIFICATE: 'it answered but offered no certificate', + CONNECT_FAILED: 'the connection could not be made', +}; + +/** + * Plain wording for why a served certificate is not trusted. + * + * Same rule as above: translate what is known, pass through what is not. + */ +const TRUST_FAILURES = { + CERT_HAS_EXPIRED: 'it has expired', + DEPTH_ZERO_SELF_SIGNED_CERT: 'it is self-signed, so no certificate authority vouches for it', + SELF_SIGNED_CERT_IN_CHAIN: 'it is self-signed, so no certificate authority vouches for it', + // Only one certificate arriving is established by this code. Why its issuer + // could not be found is not, so both readings are named. + UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'only one certificate was sent and its issuer could not be' + + ' found - either the ones that vouch for it are missing, or this machine does not trust' + + ' the authority that issued it', + UNABLE_TO_GET_ISSUER_CERT: 'the certificate that issued it could not be found - either it was' + + ' not sent with the others, or this machine does not trust it', + // Returned for a complete, correct bundle signed by a root the machine does + // not trust just as readily as for one that is genuinely missing + // certificates, so it must not be read as either on its own. + UNABLE_TO_GET_ISSUER_CERT_LOCALLY: 'no trusted path could be built to it - either certificates' + + ' are missing from the bundle, or this machine does not trust the authority that issued it', + CERT_NOT_YET_VALID: 'its start date is in the future', +}; + +/** + * @param {Object} table + * @param {string} code + * @return {string} + */ +const describe = (table, code) => table[code] ?? code; + const restartHint = (cfg) => chalk`Then restart Platform so the gateway picks it up: {bold.cyanBright dashmate restart ${cfg} --platform}`; /** @@ -94,7 +143,8 @@ Obtain a new certificate - it signals the gateway itself, so no restart is neede 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`, + "The gateway's own listener did not answer a secure connection:" + + ` ${describe(CONNECTION_FAILURES, served.reason)}. Clients may not be able to connect`, chalk`Please check that the gateway is running and listening: {bold.cyanBright dashmate status ${cfg} platform}`, SEVERITY.MEDIUM, )); @@ -119,7 +169,8 @@ Obtain a new certificate - it signals the gateway itself, so no restart is neede // outage and still have the problem. Reissuing is the remedy only once // this node's gateway is known to be what answered. problems.push(new Problem( - `The certificate served on port ${served.port} is not valid for ${externalIp}: ${served.identityError}`, + `The certificate being served on port ${served.port} is not issued for this` + + ` node's address, ${externalIp}`, chalk`Something other than this node's gateway may be answering on that port, or the certificate is issued for the wrong address. Find what is listening on ${served.port} first - another dashmate config, a reverse proxy, or a second node sharing the @@ -162,26 +213,27 @@ If this node's gateway is the one answering and the address is simply wrong: if (isServedExpired && onDiskDiffers && isOnDiskUsable) { 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. + `This node is using a certificate that expired on ${served.certificate.validTo}. ` + + 'A newer one has already been saved and is ready to use', + chalk`The new certificate was saved but the node never picked it up. Load it: {bold.cyanBright dashmate restart ${cfg} --platform}`, SEVERITY.HIGH, )); } else if (isServedExpired && onDiskDiffers) { problems.push(new Problem( - `The gateway is serving a certificate that expired on ${served.certificate.validTo}. ` - + 'The copy on disk is a different one, and is not known to be a usable replacement', - chalk`Neither the certificate on the wire nor the one on disk is usable, so restarting -Platform would not help. Obtain a current certificate, which installs it and -signals the gateway: + `This node is using a certificate that expired on ${served.certificate.validTo}. ` + + 'A different one has been saved, but dashmate could not confirm it is a working ' + + 'replacement', + chalk`Neither the certificate this node is using nor the saved one is known to work, +so restarting will not help. Get a current certificate - that installs it and +tells the node to use it, with no downtime: {bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, 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', + `This node is using a certificate that expired on ${served.certificate.validTo}. ` + + 'Clients cannot connect to it', chalk`Renewal has not succeeded. Check the renewal logs: {bold.cyanBright dashmate logs ${cfg} dashmate_helper} Then obtain a new certificate, which installs it and signals the gateway: @@ -193,19 +245,20 @@ Then obtain a new certificate, which installs it and signals the gateway: // 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. ' + 'This node is using an older certificate than the one that has been saved. ' + `It will stop accepting clients on ${served.certificate.validTo}`, - chalk`The certificate was renewed but never reached the gateway. + chalk`The new certificate was saved but the node never picked it up. Load it: {bold.cyanBright dashmate restart ${cfg} --platform}`, SEVERITY.HIGH, )); } else { problems.push(new Problem( - 'The gateway is serving a different certificate from the one on disk, and the one ' - + 'on disk is not known to be a usable replacement for it', - chalk`What is on the wire is working and the file has not been shown to be a safe -replacement, so do not restart Platform to load it. Obtain a current -certificate instead, which installs it and signals the gateway: + 'This node is using a different certificate from the one that has been saved, and ' + + 'dashmate could not confirm the saved one is a working replacement', + chalk`The certificate this node is using now works. The saved one has not been shown +to be a safe replacement, so do not restart to load it - that would swap a +working certificate for one that may not be. Get a current certificate +instead, which installs it and tells the node to use it: {bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, SEVERITY.HIGH, )); @@ -217,7 +270,8 @@ certificate instead, which installs it and signals the gateway: // 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})`, + 'The certificate this node is serving is not trusted by ordinary clients:' + + ` ${describe(TRUST_FAILURES, 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. diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index 1af98280936..eb26dccced0 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -55,17 +55,29 @@ function renderGiveUpGuidance(config, attempts) { return `dashmate did not obtain a certificate after ${attempts} ` + `attempt${attempts === 1 ? '' : 's'}. -Retrying now also blocks this node's automatic renewal: dashmate's helper -renews under the same Let's Encrypt account, and failed attempts are shared. +Do not keep retrying. Let's Encrypt limits how often this node may fail, and +every further attempt uses up that allowance - including the automatic +renewals dashmate runs for you in the background. + +Inbound port 80 is what to fix first. How you open it depends on the host, so +check both places it can be blocked: the machine's own firewall, and the +firewall or security group your hosting provider runs in front of it. Both +have to allow inbound TCP 80 from anywhere, and the rule has to survive a +reboot. + +Once it is open: + ` + + `dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider letsencrypt` + + ` -If this node has been failing for a long time, the address may be PAUSED -rather than rate-limited - waiting does not clear a pause, and you may need -Let's Encrypt's Self-Service Portal to unpause it: +If this node has been failing for a long time, Let's Encrypt may have paused +it rather than slowed it down. Waiting does not clear a pause - see https://letsencrypt.org/docs/rate-limits/ -Fix inbound port 80 first, then: ` - + `dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider letsencrypt` - + `\n\n${PORT_80_PERMANENCE}`; +If you are stuck, collect a report and send it to Dash support: + dashmate doctor report ${renderConfigFlag(config.getName())} + +${PORT_80_PERMANENCE}`; } /** @@ -78,12 +90,20 @@ Fix inbound port 80 first, then: ` * * @param {Config} config * @param {Error} cause + * @param {boolean} [neverRan] - whether the helper is known not to have run * @return {string} */ -function renderHelperDidNotStartGuidance(config, cause) { - return `dashmate could not start the certificate helper, so no request was -made to Let's Encrypt. Nothing was issued, nothing was validated, and no -rate limit was spent. +function renderHelperDidNotStartGuidance(config, cause, neverRan = true) { + // Nothing is claimed about this node's allowance unless it is known. Failing + // to create the helper settles it - there was nothing to make a request. A + // start that failed does not: Docker can reject a start it has accepted, and + // a bind conflict looks the same from here as a lost reply. Rather than pick + // one and be wrong half the time, the sentence is simply not there. + return `${neverRan + ? `dashmate could not start the certificate helper, so it never contacted +Let's Encrypt. Nothing was requested and none of this node's allowance was +used up.` + : 'dashmate could not start the certificate helper.'} Docker reported: @@ -181,22 +201,35 @@ export default function obtainLetsEncryptCertificateTaskFactory( * Create and start the lego container, reporting a failure to do either as * distinct from a failure the certificate authority returned. * - * Nothing here has spoken to the authority yet, so a failure means no - * validation was attempted and no issuance budget was spent. + * A failure to create means the helper never existed. A failure to start one + * that was created is ambiguous, and is reported as such. * * @param {Object} options - Docker create-container options * @return {Promise} the started container */ - async function startLegoContainer(options) { + async function startLegoContainer(options, onCreated) { let container; try { container = await docker.createContainer(options); - await container.start(); } catch (e) { throw new LegoDidNotStartError(e); } + // Recorded before the start is attempted, not after it succeeds. Docker can + // reject a start it has already accepted, and a container nobody recorded + // is a container nobody cleans up. + onCreated(); + + try { + await container.start(); + } catch (e) { + // Not known never to have run. If Docker accepted the start before + // failing to say so, the helper is running and may already have made its + // request. + throw new LegoDidNotStartError(e, false); + } + return container; } @@ -312,6 +345,7 @@ export default function obtainLetsEncryptCertificateTaskFactory( }, { title: 'Obtain certificate using lego', + options: { persistentOutput: true }, skip: (ctx) => ctx.certificateValid, task: async (ctx, task) => { const { uid, gid } = os.userInfo(); @@ -407,7 +441,8 @@ export default function obtainLetsEncryptCertificateTaskFactory( // From here to the container running, any failure means the helper // never ran and nothing reached the authority. - const container = await startLegoContainer({ + const container = await startLegoContainer( + { name: containerName, Image: LEGO_IMAGE, Cmd: legoArgs, @@ -421,9 +456,9 @@ export default function obtainLetsEncryptCertificateTaskFactory( PortBindings: { '80/tcp': [{ HostPort: '80' }] }, ...legoContainerOptions.HostConfig, }, - }); - - startedContainers.addContainer(containerName); + }, + () => startedContainers.addContainer(containerName), + ); // eslint-disable-next-line no-param-reassign task.output = `Running lego ${command}...`; @@ -461,6 +496,13 @@ export default function obtainLetsEncryptCertificateTaskFactory( // cannot hide it. ctx.certificateObtained = true; + // Said here, next to the issuance, rather than once the whole + // command has succeeded. An operator who opened port 80 for this + // one migration has to hear it stays open even if a later step + // fails - and they are about to close it either way. + // eslint-disable-next-line no-param-reassign + task.output = PORT_80_PERMANENCE; + // Verify certificate and key were created if (!fs.existsSync(ctx.legoCertPath)) { throw new LegoArtifactsMissingError(ctx.legoCertPath); @@ -480,7 +522,7 @@ export default function obtainLetsEncryptCertificateTaskFactory( // The helper never ran, so there is nothing the authority could // tell us and nothing to retry against - the fix is local. if (e instanceof LegoDidNotStartError) { - throw new Error(renderHelperDidNotStartGuidance(config, e.cause)); + throw new Error(renderHelperDidNotStartGuidance(config, e.cause, e.neverRan)); } if (e instanceof LegoResultNotObservedError) { @@ -536,7 +578,8 @@ export default function obtainLetsEncryptCertificateTaskFactory( { title: 'Save certificate', skip: (ctx) => ctx.certificateValid && ctx.isCertificatePairInstalled, - task: async (ctx) => { + options: { persistentOutput: true }, + task: async (ctx, task) => { // Read certificate and key from lego output ctx.certificateFile = fs.readFileSync(ctx.legoCertPath, 'utf8'); ctx.privateKeyFile = fs.readFileSync(ctx.legoKeyPath, 'utf8'); @@ -547,6 +590,9 @@ export default function obtainLetsEncryptCertificateTaskFactory( // interrupted one - counts as the gateway's certificate changing. ctx.certificateObtained = true; + // eslint-disable-next-line no-param-reassign + task.output = PORT_80_PERMANENCE; + // Save to gateway SSL directory return saveCertificateTask(config); }, diff --git a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js index 5235758e16a..a1e1341c97c 100644 --- a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js @@ -4,6 +4,7 @@ import CertificateUnresolvedError from '../../../ssl/errors/CertificateUnresolve import { CERTIFICATE_REASONS, CERTIFICATE_STATUS, + describeStatus, } from '../../../ssl/checkGatewayCertificateFactory.js'; import promptOrThrow from '../../../util/promptOrThrow.js'; import renderConfigFlag from '../../../util/renderConfigFlag.js'; @@ -267,9 +268,22 @@ export default function gatewayCertificateTaskFactory( * @param {Object} ctx * @return {Promise} */ - async function switchToLetsEncrypt(ctx) { + async function switchToLetsEncrypt(ctx, verdict) { + // Reuse is the default because it saves an issuance from a limited + // allowance, but the reuse check is weaker than these checks: it never + // looks at the address, and it asks only whether the certificate has + // expired, not whether it has started yet. For those two faults it would + // hand back the same certificate that was just rejected, so a new one is + // obtained instead. + // + // Every other fault is in the copy installed for the gateway rather than + // in the certificate itself, and reinstalling can fix it without + // spending anything. + const mustReplace = [CERTIFICATE_REASONS.IP_MISMATCH, CERTIFICATE_REASONS.NOT_YET_VALID] + .some((code) => hasReason(verdict ?? { reasons: [] }, code)); + return attemptObtain(ctx, () => obtainLetsEncryptCertificateTask(config) - .run({ ...ctx, interactive })); + .run({ ...ctx, interactive, force: ctx.force || mustReplace })); } return async (ctx, task) => { @@ -282,7 +296,7 @@ export default function gatewayCertificateTaskFactory( if (skipCertificateCheck) { ctx.certificateSkipped = true; - task.skip(`Enforcement skipped, status is ${verdict.status}`); + task.skip(`Enforcement skipped, the certificate ${describeStatus(verdict.status)}`); return; } @@ -344,7 +358,7 @@ export default function gatewayCertificateTaskFactory( return; } - const after = await switchToLetsEncrypt(ctx); + const after = await switchToLetsEncrypt(ctx, verdict); // Nothing was blocking before this ran, so a failure that left the node // as it was is a warning. A failure that damaged the installed pair is @@ -517,7 +531,7 @@ export default function gatewayCertificateTaskFactory( throw new CertificateUnresolvedError(verdict); } - const after = await switchToLetsEncrypt(ctx); + const after = await switchToLetsEncrypt(ctx, verdict); ctx.certificate = after; diff --git a/packages/dashmate/src/oclif/command/BaseCommand.js b/packages/dashmate/src/oclif/command/BaseCommand.js index 7efb9bca8bb..63edebfe4c6 100644 --- a/packages/dashmate/src/oclif/command/BaseCommand.js +++ b/packages/dashmate/src/oclif/command/BaseCommand.js @@ -21,17 +21,6 @@ export default class BaseCommand extends Command { }), }; - /** - * Whether this run changes nothing on disk. A command that reconfigures a - * node can still have a mode that only reports, and such a mode has to keep - * that promise all the way down: it takes no lock, saves no configuration, - * and does not persist a migration it happened to need. - * - * Set from the command's flags in init(). A command that declares one of - * these modes gives up its end-of-run save in that mode, which is the point. - */ - isReadOnlyRun = false; - /** * Whether this run holds the configuration lock. Defaults to what the command * declares and is narrowed once its flags are known. @@ -68,15 +57,6 @@ export default class BaseCommand extends Command { // for the whole run and no other writer can get in between. Everything else // changes config through configFileRepository.update() and needs nothing // here. - // - // Such a command may still have a mode that changes nothing - a read-only - // preflight, say - and taking a write lock there would let it fail on a - // lock timeout for no reason, so it can opt that mode out. The migration - // below is opted out with it: migrating writes and renders under the same - // lock, and it is due on exactly the run right after an upgrade. - this.isReadOnlyRun = this.constructor.isReadOnlyRun?.(this.parsedFlags) === true; - this.holdsConfigLock = this.holdsConfigLock && !this.isReadOnlyRun; - if (this.holdsConfigLock) { configFileRepository.acquire(); } @@ -91,10 +71,7 @@ export default class BaseCommand extends Command { ) ?? false; ({ configFile } = configFileRepository.readAndMigrate( - { - skipValidation, - readOnly: this.isReadOnlyRun, - }, + { skipValidation }, (migratedConfigs) => { const writeConfigTemplates = this.container.resolve('writeConfigTemplates'); diff --git a/packages/dashmate/src/ssl/certificateReporting.js b/packages/dashmate/src/ssl/certificateReporting.js new file mode 100644 index 00000000000..6f75165db7b --- /dev/null +++ b/packages/dashmate/src/ssl/certificateReporting.js @@ -0,0 +1,72 @@ +import renderCertificateGuidance from './renderCertificateGuidance.js'; + +/** + * Everything the certificate check needs to say to an operator, kept out of the + * update command itself. + * + * The certificate work is a mitigation that rides along with `dashmate update` + * rather than part of updating a node, so it lives here and the command calls + * into it. Removing the mitigation later should be deleting this file and its + * call sites, not unpicking it from the update flow. + */ + +/** + * One machine-readable line about the certificate, on stderr. + * + * Kept off stdout because that stream is the command's own output and, under + * JSON format, has to stay exactly one parseable document. + * + * @param {Object} verdict + * @param {Config} config + * @param {Object} [extra] - merged in, for fields only one caller has + */ +export function writeDiagnostics(verdict, config, extra = {}) { + process.stderr.write(`${JSON.stringify({ + status: verdict.status, + reasons: verdict.reasons.map(({ code }) => code), + warnings: verdict.warnings.map(({ code }) => code), + provider: verdict.provider, + config: config.getName(), + expiresAt: verdict.installed ? verdict.installed.validTo.toISOString() : null, + ...extra, + })}\n`); +} + +/** + * The remediation an operator reads when the certificate did not pass. + * + * @param {Object} options + * @param {Config} options.config + * @param {Object} options.verdict + * @param {Object} options.dockerCompose + * @param {Object|null} options.pull + * @param {boolean} [options.obtainAttemptFailed] + * @return {Promise} + */ +export async function reportUnresolved({ + config, + verdict, + dockerCompose, + pull, + obtainAttemptFailed = false, +}) { + // Left null when it cannot be established. Docker being unavailable, or this + // caller not being permitted to ask it, says nothing about whether the node + // is up - and the guidance says nothing about it either rather than + // defaulting to stopped, which would tell an operator with a running node the + // opposite of the truth. + let isNodeRunning = null; + try { + isNodeRunning = await dockerCompose.isServiceRunning(config, 'gateway'); + } catch { + // Says nothing about the certificate either, so the verdict stands. + } + + process.stderr.write(renderCertificateGuidance({ + config, + verdict, + isNodeRunning, + pull, + obtainAttemptFailed, + })); +} diff --git a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js index 17a5b1dc33b..a6e3e8cb452 100644 --- a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js +++ b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js @@ -17,6 +17,24 @@ export const CERTIFICATE_STATUS = { INVALID: 'INVALID', }; +/** + * The verdict in words, for anything a person reads. + * + * The status values are identifiers for machines and for this code. They stay + * in the JSON line, where something is parsing them, and never appear in a + * sentence. + * + * @param {string} status + * @return {string} + */ +export function describeStatus(status) { + if (status === CERTIFICATE_STATUS.CHECKS_PASSED) { + return 'passed'; + } + + return status === CERTIFICATE_STATUS.WARN ? 'passed with warnings' : 'did not pass'; +} + export const CERTIFICATE_REASONS = { BUNDLE_MISSING: 'BUNDLE_MISSING', NOT_YET_VALID: 'NOT_YET_VALID', @@ -301,13 +319,13 @@ export default function checkGatewayCertificateFactory(homeDir) { // address only in its subject is one every client rejects - accepting it // would pass a node that nothing can connect to. const named = installed.ipAddresses.length > 0 - ? `it names ${installed.ipAddresses.join(', ')} instead` - : 'it carries no IP address at all'; + ? `it is issued for ${installed.ipAddresses.join(', ')} instead` + : 'it names no address at all'; reasons.push({ code: CERTIFICATE_REASONS.IP_MISMATCH, - message: "The installed certificate does not carry this node's address" - + ` ${externalIp} in its subject alternative name - ${named}`, + message: "The installed certificate is not issued for this node's address" + + ` ${externalIp} - ${named}`, }); } diff --git a/packages/dashmate/src/ssl/errors/LegoDidNotStartError.js b/packages/dashmate/src/ssl/errors/LegoDidNotStartError.js index 08be6268d2b..aa89a6e7ae9 100644 --- a/packages/dashmate/src/ssl/errors/LegoDidNotStartError.js +++ b/packages/dashmate/src/ssl/errors/LegoDidNotStartError.js @@ -1,23 +1,27 @@ import AbstractError from '../../errors/AbstractError.js'; /** - * The certificate helper could not be started, so no request ever reached the - * certificate authority. + * The certificate helper could not be started. * * Worth distinguishing from a request the authority refused, because the two - * have nothing in common: nothing was issued, nothing was validated, no rate - * limit was spent and no address can have been paused. The most common cause is - * another process already holding port 80, which is the opposite of the - * firewall problem a failed validation usually means - the port is reachable, - * it is occupied. + * have nothing in common. The most common cause is another process already + * holding port 80, which is the opposite of the firewall problem a failed + * validation usually means - the port is reachable, it is occupied. + * + * Whether the authority was reached is not always knowable. Failing to create + * the container settles it: the helper never existed. Failing to start one that + * was created does not, because Docker can reject after having accepted the + * start, leaving the helper running and free to make its request. */ export default class LegoDidNotStartError extends AbstractError { /** * @param {Error} cause - what Docker reported + * @param {boolean} [neverRan] - whether the helper is known not to have run */ - constructor(cause) { + constructor(cause, neverRan = true) { super(`The certificate helper could not be started: ${cause.message}`); this.cause = cause; + this.neverRan = neverRan; } } diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index 23dbd7c4b0e..7739e15a601 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -53,13 +53,10 @@ function renderObservation(verdict) { * @return {string} */ function renderZeroSslExplanation() { - return ` Your certificate provider is ZeroSSL. dashmate's ZeroSSL integration drives - ZeroSSL's REST API, and a free ZeroSSL account allows three certificates - through the dashboard/API and no REST API access - so dashmate's renewals - stop working after about 270 days. (ZeroSSL's ACME service is not a - substitute here: it does not issue IP-address certificates.) You did not - configure anything wrong - as of August 2026, four out of five ZeroSSL - evonodes on mainnet were in this state. + return ` Your certificate provider is ZeroSSL. A free ZeroSSL account allows three + certificates in total, and dashmate renews from that same allowance - so + after about 270 days there is nothing left to renew with and this node stops + getting new certificates. `; } @@ -121,19 +118,15 @@ function renderSwitchIncompleteGuidance(config, cfg) { * @return {string} */ function renderLetsEncryptDiagnosis(cfg) { - return ` This node is already configured for Let's Encrypt, so there is no provider to - switch to - it is the only authority that issues IP-address certificates over - ACME. dashmate's helper is configured to retry renewal hourly, so renewal has - most likely been failing without anyone being told. dashmate has not inspected - the helper's history to confirm that. + return ` This node is already set to use Let's Encrypt, so there is no provider to + switch to. - The most likely cause is inbound port 80. Let's Encrypt re-checks it on every + Inbound port 80 is the most common cause. Let's Encrypt re-checks it on every renewal - roughly every four days, permanently - and a firewall rule that was opened once and later closed, or that did not survive a reboot, produces exactly this pattern. - It is not always port 80: half the nodes in this state have port 80 open and - stopped renewing regardless. Check the renewal logs as well: + It is not always port 80. Check the renewal logs as well: dashmate doctor ${cfg} dashmate logs ${cfg} dashmate_helper @@ -146,6 +139,21 @@ function renderLetsEncryptDiagnosis(cfg) { * @return {string} */ function renderFix(cfg, isNodeRunning, isAlreadyLetsEncrypt) { + const DELIVERY = { + true: ` That installs the certificate and signals the gateway, so a running node + needs nothing further - no restart. +`, + false: ` That installs the certificate and signals the gateway. This node is + stopped, so bring it back up: + + dashmate start ${cfg} +`, + // Nothing is claimed about a state that could not be read. + unknown: ` That installs the certificate and signals the gateway, so a node that is + already running needs nothing further. If this one is stopped, start it. +`, + }; + // A node already on Let's Encrypt has nothing to switch to - it is the only // authority that issues IP-address certificates over ACME - so the heading // that offers a switch would contradict the diagnosis printed above it. The @@ -172,15 +180,7 @@ function renderFix(cfg, isNodeRunning, isAlreadyLetsEncrypt) { dashmate ssl obtain ${cfg} --provider letsencrypt -${isNodeRunning - ? ` That installs the certificate and signals the gateway, so a running node - needs nothing further - no restart. -` - : ` That installs the certificate and signals the gateway. This node is - stopped, so bring it back up: - - dashmate start ${cfg} -`}`; +${DELIVERY[String(isNodeRunning)] ?? DELIVERY.unknown}`; } /** @@ -197,7 +197,8 @@ ${isNodeRunning * @param {Object} options * @param {Config} options.config * @param {Object} options.verdict - * @param {boolean} options.isNodeRunning + * @param {boolean|null} options.isNodeRunning - null when it could not be + * determined, in which case nothing is said about the node's state * @param {boolean} [options.obtainAttemptFailed] - an obtain was run and threw * @param {{ok: boolean, failed: number, total: number}|null} options.pull * @return {string} @@ -224,10 +225,8 @@ export default function renderCertificateGuidance({ Node: ${config.get('network')} (config "${config.getName()}", ${config.get('externalIp') ?? 'no external IP set'}) Certificate: ${renderObservation(verdict)} - These checks read the files installed for the gateway. dashmate did not open - a connection, so it cannot say what this node is actually serving, and it did - not validate the certificate against public trust stores either; - \`dashmate doctor ${cfg}\` does the first of those. + These checks read the certificate files installed on this node. They do not + tell you what clients actually see; \`dashmate doctor ${cfg}\` does that. ${obtainAttemptFailed ? `An attempt to obtain a certificate ran just now and did not complete. @@ -235,8 +234,8 @@ export default function renderCertificateGuidance({ pair, so the files on disk may not be what they were before this run. The status above was read back from disk after the attempt, so it describes what is there now.` - : `Nothing broke just now. This is the first release of dashmate that checks - the certificate, so this is the first time you are being told.`} + : `Nothing broke just now. This check is new, so this is the first time you + are being told.`} `, ]; @@ -244,7 +243,11 @@ export default function renderCertificateGuidance({ // procedure stops it before update runs. An operator who reads a certificate // complaint, assumes it changed nothing and walks away has left a stopped // masternode behind. - if (!isNodeRunning) { + // Only when the state is actually known. Docker being unreachable, or the + // caller not being permitted to ask it, establishes nothing - and a courtesy + // line that tells an operator their running node is stopped is worse than no + // line at all. + if (isNodeRunning === false) { // The reassurance holds for a certificate that merely failed the checks: // nothing about them gates startup. It does not hold once an obtain has // run and failed, because what is on disk may have changed underneath the diff --git a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js index 32a43521ef7..43944658173 100644 --- a/packages/dashmate/test/unit/commands/ssl/obtain.spec.js +++ b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js @@ -3,16 +3,6 @@ import ObtainCommand from '../../../../src/commands/ssl/obtain.js'; import ServiceIsNotRunningError from '../../../../src/docker/errors/ServiceIsNotRunningError.js'; describe('SSL obtain command', () => { - let stderr; - - beforeEach(function beforeEach() { - stderr = ''; - this.sinon.stub(process.stderr, 'write').callsFake((chunk) => { - stderr += chunk; - return true; - }); - }); - /** * @param {Object} sinon * @param {string} [provider] @@ -80,6 +70,28 @@ describe('SSL obtain command', () => { // Envoy loads the certificate once at startup. Obtaining a certificate // without telling the gateway to reload leaves the operator with a command // that reports success while the node keeps serving the old certificate. + // The notice belongs to the task that issues the certificate, so it renders + // in the task list with everything else. The command writing it directly - + // to either stream, from a finally or otherwise - is what this pins against. + it('should not print the port 80 notice itself', async function it() { + let written = ''; + ['stdout', 'stderr'].forEach((stream) => { + this.sinon.stub(process[stream], 'write').callsFake((chunk) => { + written += chunk; + + return true; + }); + }); + + const dependencies = obtainDependencies(this.sinon); + dependencies.obtainTask = this.sinon.stub().callsFake(() => new Listr([{ + task: (ctx) => { ctx.certificateObtained = true; }, + }])); + + await runObtain(dependencies); + + expect(written).to.not.contain('LEAVE PORT 80 OPEN'); + }); it('should reload the gateway after obtaining a certificate', async function it() { const dependencies = obtainDependencies(this.sinon); @@ -179,67 +191,6 @@ describe('SSL obtain command', () => { expect(configFileRepository.write).to.have.been.calledOnceWith(configFile); }); - // The gate's own remediation tells the operator to run this command, and - // §the permanence requirement is the whole reason the eight dark nodes went - // dark. An operator who opens port 80 for one migration, runs this, succeeds - // and closes the port again must not be able to do so silently. - it('should state that port 80 has to stay open after obtaining', async function it() { - const dependencies = obtainDependencies(this.sinon); - dependencies.obtainTask = this.sinon.stub().callsFake(() => new Listr([{ - task: (ctx) => { ctx.certificateObtained = true; }, - }])); - - await runObtain(dependencies); - - expect(stderr).to.contain('LEAVE PORT 80 OPEN'); - expect(stderr).to.contain('survives a reboot'); - }); - - // A certificate that was issued and then failed to install is still a - // certificate this node now holds against its limits, and the operator who - // opened port 80 for it is about to close it again. The failure must not - // swallow the one thing that stops the node going dark in six days. - it('should state permanence even when a later step fails', async function it() { - const dependencies = obtainDependencies(this.sinon); - dependencies.obtainTask = this.sinon.stub().callsFake(() => new Listr([{ - task: (ctx) => { ctx.certificateObtained = true; }, - }])); - dependencies.dockerCompose.execCommand = this.sinon.stub() - .rejects(new Error('reload failed')); - - await expect(runObtain(dependencies)).to.be.rejected(); - - expect(stderr).to.contain('LEAVE PORT 80 OPEN'); - }); - - // The permanence block belongs to the command, so a task that also carries it - // would print it twice on the one path where both run. - it('should state permanence once when the issued files never landed', async function it() { - const dependencies = obtainDependencies(this.sinon); - dependencies.obtainTask = this.sinon.stub().callsFake(() => new Listr([{ - task: (ctx) => { - ctx.certificateObtained = true; - - throw new Error('Let\'s Encrypt issued a certificate, but dashmate could not find' - + ' the file it should have written'); - }, - }])); - - await expect(runObtain(dependencies)).to.be.rejected(); - - expect(stderr.split('LEAVE PORT 80 OPEN')).to.have.lengthOf(2); - }); - - // Nothing was issued, so there is nothing to warn about and a cron run stays - // quiet. - it('should stay silent when no new certificate was obtained', async function it() { - const dependencies = obtainDependencies(this.sinon); - - await runObtain(dependencies); - - expect(stderr).to.not.contain('LEAVE PORT 80 OPEN'); - }); - // The retry loop lives in the shared obtain task, so `ssl obtain` gains it // too. Its --no-retry defaults to false, which would turn an obtain run from // cron into a hang if the flag were what decided whether to prompt. diff --git a/packages/dashmate/test/unit/commands/update.spec.js b/packages/dashmate/test/unit/commands/update.spec.js index 3658ab5c6d4..5579a2eddb4 100644 --- a/packages/dashmate/test/unit/commands/update.spec.js +++ b/packages/dashmate/test/unit/commands/update.spec.js @@ -1,5 +1,3 @@ -import fs from 'fs'; -import path from 'path'; import UpdateCommand from '../../../src/commands/update.js'; import HomeDir from '../../../src/config/HomeDir.js'; import getBaseConfigFactory from '../../../configs/defaults/getBaseConfigFactory.js'; @@ -61,7 +59,6 @@ describe('Update command', () => { verbose: false, 'skip-certificate-check': false, 'non-interactive': false, - 'check-certificate': false, ...flags, }, mockDocker, @@ -199,6 +196,26 @@ describe('Update command', () => { expect(stderr).to.not.contain('Nothing broke just now'); }); + // Defaulting an unknown state to stopped told an operator with a running + // node the opposite of the truth, and offered them a command for it. + it('should not report the node as stopped when Docker cannot be asked', async function it() { + const verdict = invalidVerdict(); + + dockerCompose.isServiceRunning = this.sinon.stub() + .rejects(new Error('permission denied')); + + await expect(runUpdate({ + checkGatewayCertificate: () => verdict, + gatewayCertificateTask: () => async (ctx) => { + ctx.certificate = verdict; + + throw new CertificateUnresolvedError(verdict); + }, + })).to.be.rejected(); + + expect(stderr).to.not.contain('Your node is currently stopped'); + }); + // Individual images failing is not a rejection: updateNode resolves those // as error rows, and that has always exited 0. it('should not fail the command when individual pulls fail', async function it() { @@ -310,64 +327,6 @@ describe('Update command', () => { }); }); - describe('the read-only preflight', () => { - it('should not pull, prompt or change anything', async function it() { - const gatewayCertificateTask = this.sinon.stub(); - - await runUpdate({ - flags: { 'check-certificate': true }, - gatewayCertificateTask, - }); - - expect(mockDocker.pull).to.not.have.been.called(); - expect(gatewayCertificateTask).to.not.have.been.called(); - }); - - it('should report the verdict and exit non-zero when it is invalid', async () => { - const error = await runUpdate({ - flags: { 'check-certificate': true }, - checkGatewayCertificate: () => invalidVerdict(), - }).catch((e) => e); - - expect(error).to.be.an.instanceOf(MuteOneLineError); - expect(stderr).to.contain('"status":"INVALID"'); - expect(stderr).to.contain('"reasons":["EXPIRED"]'); - }); - - it('should exit zero when the checks pass', async () => { - await expect(runUpdate({ - flags: { 'check-certificate': true }, - checkGatewayCertificate: () => passingVerdict(), - })).to.not.be.rejected(); - - expect(stderr).to.contain('"status":"CHECKS_PASSED"'); - }); - - // A read-only preflight is meant to be run before the node is stopped, - // possibly while the helper is renewing. Taking a write lock there would - // let it fail on a lock timeout for no reason. - // A "the check could not run" exit code was considered and dropped: the - // configuration lock is taken before the command body runs and the - // repository throws a plain Error, so the boundary cannot tell that case - // apart without a typed error and central mapping. A lock timeout is an - // ordinary failure and exits 1. - it('should use no exit code beyond 0, 1 and 2', () => { - const source = fs.readFileSync( - path.join(process.cwd(), 'src/commands/update.js'), - 'utf8', - ); - - expect(source).to.not.match(/exitCode\s*=\s*[3-9]/); - expect(source).to.not.match(/process\.exit\(/); - }); - - it('should declare itself read-only so it neither locks nor writes', () => { - expect(UpdateCommand.mutatesConfig).to.be.true(); - expect(UpdateCommand.isReadOnlyRun({ 'check-certificate': true })).to.be.true(); - expect(UpdateCommand.isReadOnlyRun({ 'check-certificate': false })).to.be.false(); - }); - }); - describe('scope', () => { ['local', 'devnet'].forEach((network) => { it(`should not check the certificate on ${network}`, async function it() { @@ -403,7 +362,6 @@ describe('Update command', () => { describe('flags', () => { it('should offer exactly the documented flags', () => { expect(Object.keys(UpdateCommand.flags).sort()).to.deep.equal([ - 'check-certificate', 'config', 'format', 'non-interactive', @@ -429,7 +387,8 @@ describe('Update command', () => { }); expect(observed.skipCertificateCheck).to.be.true(); - expect(stderr).to.contain('status is INVALID'); + expect(stderr).to.contain('the certificate did not pass'); + expect(stderr).to.not.contain('status is INVALID'); }); it('should honour DASHMATE_SKIP_CERTIFICATE_CHECK', async function it() { diff --git a/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js b/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js index 46454851300..1e3f84fdf50 100644 --- a/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js +++ b/packages/dashmate/test/unit/config/configFile/ConfigFileJsonRepository.spec.js @@ -1,16 +1,10 @@ import fs from 'fs'; -import path from 'path'; import { spawn } from 'child_process'; import { expect } from 'chai'; import HomeDir from '../../../../src/config/HomeDir.js'; import getBaseConfigFactory from '../../../../configs/defaults/getBaseConfigFactory.js'; import ConfigFile from '../../../../src/config/configFile/ConfigFile.js'; import ConfigFileJsonRepository from '../../../../src/config/configFile/ConfigFileJsonRepository.js'; -import ConfigFileMigrationRequiredError from '../../../../src/config/errors/ConfigFileMigrationRequiredError.js'; -import ConfigFileNotFoundError from '../../../../src/config/errors/ConfigFileNotFoundError.js'; -import InvalidConfigFileFormatError from '../../../../src/config/errors/InvalidConfigFileFormatError.js'; -import createDIContainer from '../../../../src/createDIContainer.js'; -import getConfigFileDataV0250 from '../../../../src/test/fixtures/getConfigFileDataV0250.js'; const CURRENT_FORMAT_VERSION = '4.1.0'; @@ -247,327 +241,6 @@ describe('ConfigFileJsonRepository', () => { .to.equal('9.9.9'); }); - // A command that promises to change nothing must keep that promise even on - // the one run where a migration is due. Migrations are not all pure - one - // copies TLS files to a new location and removes the originals, and then - // deletes the whole ssl directory - so migrating on its behalf would move - // and delete files outside any lock, from a command documented as safe to - // run against a node that is still up. - it('should refuse to migrate for a caller that changes nothing', () => { - seedConfigFile(); - - const seeded = JSON.parse(seedConfigFile()); - seeded.configFormatVersion = '0.25.0'; - fs.writeFileSync(configFilePath, JSON.stringify(seeded, undefined, 2), 'utf8'); - - let migrationRuns = 0; - const migration = (data) => { - migrationRuns += 1; - - return { ...data, configFormatVersion: CURRENT_FORMAT_VERSION }; - }; - const repository = new ConfigFileJsonRepository( - migration, - homeDir, - createDefaults, - CURRENT_FORMAT_VERSION, - ); - const before = fs.readFileSync(configFilePath, 'utf8'); - - expect(() => repository.readAndMigrate({ readOnly: true })) - .to.throw(ConfigFileMigrationRequiredError); - - // Not "migrated in memory and discarded" - not run at all, because - // running it is what touches the disk. - expect(migrationRuns).to.equal(0); - expect(fs.readFileSync(configFilePath, 'utf8')).to.equal(before); - expect(fs.existsSync(homeDir.joinPath('.config.json.lock'))).to.be.false(); - }); - - // Refusing has to mean "the recorded version is genuinely behind", not - // "something about this file defeated the probe". A missing or damaged - // config file has its own errors, and one of them is what first-run setup - // catches to create defaults - reporting a migration instead breaks that - // and tells the operator something untrue about a file that may not exist. - it('should let a missing config file report itself', () => { - const repository = new ConfigFileJsonRepository( - identityMigration, - homeDir, - createDefaults, - CURRENT_FORMAT_VERSION, - ); - - expect(() => repository.readAndMigrate({ readOnly: true })) - .to.throw(ConfigFileNotFoundError); - }); - - it('should let a malformed config file report itself', () => { - fs.writeFileSync(configFilePath, '{ not json at all', 'utf8'); - - const repository = new ConfigFileJsonRepository( - identityMigration, - homeDir, - createDefaults, - CURRENT_FORMAT_VERSION, - ); - - expect(() => repository.readAndMigrate({ readOnly: true })) - .to.throw(InvalidConfigFileFormatError); - }); - - [ - ['no recorded version', ({ configFormatVersion, ...rest }) => rest], - ['an unparseable recorded version', (data) => ({ ...data, configFormatVersion: 'not-a-version' })], - ].forEach(([name, damage]) => { - it(`should not claim a migration is due from ${name}`, () => { - const damaged = damage(JSON.parse(seedConfigFile())); - fs.writeFileSync(configFilePath, JSON.stringify(damaged, undefined, 2), 'utf8'); - - const repository = new ConfigFileJsonRepository( - identityMigration, - homeDir, - createDefaults, - CURRENT_FORMAT_VERSION, - ); - - // Whatever this file's own problem turns out to be, it is not that an - // older dashmate wrote it - nothing here establishes that. - expect(() => repository.readAndMigrate({ readOnly: true })) - .to.not.throw(ConfigFileMigrationRequiredError); - }); - }); - - // Falling through to read() on an undetermined version is only safe because - // the version comparison inside the migration chain rejects a version it - // cannot parse before any migration body runs. Proven against the shipped - // migrations rather than argued, because one of those bodies deletes a - // directory of TLS material. - [ - ['no recorded version', ({ configFormatVersion, ...rest }) => rest], - ['an unparseable recorded version', (data) => ({ ...data, configFormatVersion: 'bad' })], - ].forEach(([name, damage]) => { - it(`should run no migration for a read-only caller given ${name}`, async () => { - const container = await createDIContainer(); - container.resolve('homeDir').change(homeDir); - - const legacy = damage(getConfigFileDataV0250()); - const [legacyName] = Object.keys(legacy.configs); - fs.writeFileSync(configFilePath, JSON.stringify(legacy, undefined, 2), 'utf8'); - - const legacySslDir = homeDir.joinPath('ssl', legacyName); - fs.mkdirSync(legacySslDir, { recursive: true }); - fs.writeFileSync(path.join(legacySslDir, 'bundle.crt'), 'certificate', 'utf8'); - - const repository = new ConfigFileJsonRepository( - container.resolve('migrateConfigFile'), - homeDir, - createDefaults, - container.resolve('configFormatVersion'), - ); - - expect(() => repository.readAndMigrate({ readOnly: true })).to.throw(); - - expect(fs.existsSync(path.join(legacySslDir, 'bundle.crt'))).to.be.true(); - expect(fs.existsSync(homeDir.joinPath('ssl'))).to.be.true(); - expect(fs.existsSync(homeDir.joinPath('.config.json.lock'))).to.be.false(); - }); - }); - - // The run this mode exists for is the first one after an upgrade, which is - // exactly when a migration is due. Refusing there fails the operator at the - // moment they were told the command was safe - before they stop a healthy - // node. Almost every migration only reshapes data, so it can be applied in - // memory and thrown away. - it('should migrate in memory when no migration touches the disk', async () => { - const container = await createDIContainer(); - container.resolve('homeDir').change(homeDir); - - const configFormatVersion = container.resolve('configFormatVersion'); - - const seeded = JSON.parse(seedConfigFile()); - seeded.configFormatVersion = '4.1.0'; - fs.writeFileSync(configFilePath, JSON.stringify(seeded, undefined, 2), 'utf8'); - - const before = fs.readFileSync(configFilePath, 'utf8'); - - const repository = new ConfigFileJsonRepository( - container.resolve('migrateConfigFile'), - homeDir, - createDefaults, - configFormatVersion, - ); - - let rendered = false; - const { configFile } = repository.readAndMigrate( - { readOnly: true }, - () => { rendered = true; }, - ); - - // The caller gets current data to judge, and the disk is untouched. - expect(configFile.getConfigFormatVersion()).to.equal(configFormatVersion); - expect(rendered).to.be.false(); - expect(fs.readFileSync(configFilePath, 'utf8')).to.equal(before); - expect(fs.existsSync(homeDir.joinPath('.config.json.lock'))).to.be.false(); - }); - - // Classifying from one read of the file and then migrating from another is - // a window: a restore, a rollback, or an older helper writing between the - // two substitutes a legacy config after it has been judged safe, and the - // destructive migrations then run with no lock held - which is the whole - // thing this mode exists to prevent, reached by a different route. - it('should judge and migrate the same bytes', async function it() { - const container = await createDIContainer(); - container.resolve('homeDir').change(homeDir); - - const current = JSON.parse(seedConfigFile()); - current.configFormatVersion = '4.1.0'; - const currentJson = JSON.stringify(current, undefined, 2); - const legacyJson = JSON.stringify(getConfigFileDataV0250(), undefined, 2); - - fs.writeFileSync(configFilePath, currentJson, 'utf8'); - - // A pre-1.0 config is substituted the moment the file has been read once. - let configReads = 0; - const readFileSync = this.sinon.stub(fs, 'readFileSync'); - readFileSync.callThrough(); - readFileSync.withArgs(configFilePath).callsFake(() => { - configReads += 1; - - return configReads === 1 ? currentJson : legacyJson; - }); - - const repository = new ConfigFileJsonRepository( - container.resolve('migrateConfigFile'), - homeDir, - createDefaults, - container.resolve('configFormatVersion'), - ); - - const { configFile } = repository.readAndMigrate({ readOnly: true }); - - // One read leaves no window to substitute anything into, so what was - // judged is what was migrated. - expect(configReads).to.equal(1); - expect(configFile.getConfig('base')).to.exist(); - }); - - // The two migrations that move and delete TLS material are the only reason - // this mode ever refuses, so it has to refuse when one of them is in range. - it('should still refuse when a migration in range touches the disk', async () => { - const container = await createDIContainer(); - container.resolve('homeDir').change(homeDir); - - const legacy = getConfigFileDataV0250(); - fs.writeFileSync(configFilePath, JSON.stringify(legacy, undefined, 2), 'utf8'); - - const repository = new ConfigFileJsonRepository( - container.resolve('migrateConfigFile'), - homeDir, - createDefaults, - container.resolve('configFormatVersion'), - ); - - expect(() => repository.readAndMigrate({ readOnly: true })) - .to.throw(ConfigFileMigrationRequiredError); - }); - - // The common case, and the one that has to stay fast: nothing to migrate, - // so nothing to refuse and no lock to take. - it('should read without locking for a caller that changes nothing', () => { - seedConfigFile(); - - const repository = new ConfigFileJsonRepository( - identityMigration, - homeDir, - createDefaults, - CURRENT_FORMAT_VERSION, - ); - const before = fs.readFileSync(configFilePath, 'utf8'); - - const { configFile } = repository.readAndMigrate({ readOnly: true }); - - expect(configFile.getConfig('base')).to.exist(); - expect(fs.readFileSync(configFilePath, 'utf8')).to.equal(before); - expect(fs.existsSync(homeDir.joinPath('.config.json.lock'))).to.be.false(); - }); - - // Every command dashmate prints falls back to the default node when it - // carries no --config, and this error is raised from a layer that has no - // idea which node was selected. So it names none: prose an operator cannot - // paste at the wrong machine. - it('should suggest no command it cannot aim at the right node', () => { - const seeded = JSON.parse(seedConfigFile()); - seeded.configFormatVersion = '0.25.0'; - fs.writeFileSync(configFilePath, JSON.stringify(seeded, undefined, 2), 'utf8'); - - const repository = new ConfigFileJsonRepository( - identityMigration, - homeDir, - createDefaults, - CURRENT_FORMAT_VERSION, - ); - - const error = (() => { - try { - repository.readAndMigrate({ readOnly: true }); - } catch (e) { - return e; - } - - return null; - })(); - - expect(error).to.be.an.instanceOf(ConfigFileMigrationRequiredError); - expect(error.message).to.contain(configFilePath); - - // Prose may name dashmate; nothing may be laid out as a command to copy. - error.message.split('\n').forEach((line) => { - expect(line, line).to.not.match(/^\s+dashmate\s/); - expect(line, line).to.not.match(/`dashmate\s/); - }); - }); - - // The migration this refuses to run really does delete things. Driven with - // the shipped migration set rather than a stand-in, so the guard is pinned - // against the behaviour it exists for and not against a mock of it. - it('should leave the ssl directory alone that migrating would delete', async () => { - const container = await createDIContainer(); - container.resolve('homeDir').change(homeDir); - - const migrateConfigFile = container.resolve('migrateConfigFile'); - const configFormatVersion = container.resolve('configFormatVersion'); - - // A genuine config of that era, so the migrations that follow it run - // against the shape they were written for. - const legacy = getConfigFileDataV0250(); - const [legacyName] = Object.keys(legacy.configs); - fs.writeFileSync(configFilePath, JSON.stringify(legacy, undefined, 2), 'utf8'); - - const legacySslDir = homeDir.joinPath('ssl', legacyName); - fs.mkdirSync(legacySslDir, { recursive: true }); - fs.writeFileSync(path.join(legacySslDir, 'bundle.crt'), 'certificate', 'utf8'); - - const repository = new ConfigFileJsonRepository( - migrateConfigFile, - homeDir, - createDefaults, - configFormatVersion, - ); - - expect(() => repository.readAndMigrate({ readOnly: true })) - .to.throw(ConfigFileMigrationRequiredError); - - expect(fs.existsSync(path.join(legacySslDir, 'bundle.crt'))).to.be.true(); - expect(fs.existsSync(homeDir.joinPath('ssl'))).to.be.true(); - expect(JSON.parse(fs.readFileSync(configFilePath, 'utf8')).configFormatVersion) - .to.equal('0.25.0'); - - // The control: a normal read migrates, and that is what removes them. - repository.readAndMigrate(); - - expect(fs.existsSync(homeDir.joinPath('ssl'))).to.be.false(); - }); - // Migrations are not all pure - one moves TLS files and deletes the // originals - so deciding whether one is due must not run them. Running // them to find out would do that work outside the lock, where another diff --git a/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js index dd54dff961f..5fc22d104e0 100644 --- a/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js +++ b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js @@ -6,7 +6,6 @@ import HomeDir from '../../../../src/config/HomeDir.js'; import { PACKAGE_ROOT_DIR } from '../../../../src/constants.js'; import createDIContainer from '../../../../src/createDIContainer.js'; import getConfigFileDataV0250 from '../../../../src/test/fixtures/getConfigFileDataV0250.js'; -import { FILESYSTEM_MUTATING_MIGRATIONS } from '../../../../configs/getConfigFileMigrationsFactory.js'; describe('migrateConfigFileFactory', () => { let mockConfigFileData; @@ -25,38 +24,6 @@ describe('migrateConfigFileFactory', () => { mockConfigFileData = getConfigFileDataV0250(); }); - // The read-only preflight applies migrations in memory and discards them, so - // it may only refuse when one of them would touch the disk. That decision is - // driven by a declared list, and a list nobody maintains is worse than none - - // so the list is checked against the migrations themselves. - it('should declare every migration that touches the filesystem', () => { - const source = fs.readFileSync( - path.join(PACKAGE_ROOT_DIR, 'configs', 'getConfigFileMigrationsFactory.js'), - 'utf8', - ); - - const keys = [...source.matchAll(/^ {6}'([^']+)': \(configFile\)/gm)]; - - expect(keys).to.have.length.greaterThan(50); - - // Matched on the operation names rather than on `fs.`, so an aliased - // import, a local helper or bracket access cannot slip a filesystem write - // past the scan by not spelling the module out. - const mutatingCall = new RegExp(`\\b(?:${[ - 'appendFile', 'chmod', 'chown', 'copyFile', 'cp', 'link', 'mkdir', 'mkdtemp', - 'rename', 'rm', 'rmdir', 'symlink', 'truncate', 'unlink', 'utimes', 'writeFile', - ].map((name) => `${name}(?:Sync)?`).join('|')})\\b`); - - const touchesFilesystem = keys.filter(({ index }, position) => { - const end = position + 1 < keys.length ? keys[position + 1].index : source.length; - - return mutatingCall.test(source.slice(index, end)); - }).map(([, version]) => version); - - expect(touchesFilesystem.sort()) - .to.deep.equal([...FILESYSTEM_MUTATING_MIGRATIONS].sort()); - }); - // lego keys its on-disk ACME account directory by the contact address, so // that string decides which account a renewal runs under. A migration that // nulled, normalised or removed it would silently register a brand new diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js index fbf5a3182fe..37646a9ca45 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js @@ -87,6 +87,15 @@ describe('analyseConfigFactory', () => { expect(problem.getSolution()).to.contain('dashmate ssl obtain'); }); + // A host commonly runs several configs. A command pasted without one acts + // on whichever happens to be the default, so it would obtain and reload a + // certificate for a node nobody was diagnosing and leave this one as it is. + it('should name the config being diagnosed', () => { + const [problem] = notInstalled(); + + expect(problem.getSolution()).to.contain(`--config ${config.getName()}`); + }); + // The report can carry the gateway analyser's finding for the same node, // which says in as many words not to restart. Two opposite instructions in // one report leave the operator to guess, and one guess breaks the node. diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index 40aa063eb06..1e329226e83 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -101,7 +101,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].getDescription()).to.include('newer one has already been saved and is ready to use'); expect(problems[0].getSolution()).to.include('dashmate restart --config base --platform'); }); @@ -116,7 +116,7 @@ describe('analyseGatewayCertificateFactory', () => { })); expect(problems).to.have.lengthOf(1); - expect(problems[0].getDescription()).to.include('older certificate than the one on disk'); + expect(problems[0].getDescription()).to.include('older certificate than the one that has been saved'); expect(problems[0].getSeverity()).to.equal(SEVERITY.HIGH); }); @@ -127,7 +127,46 @@ describe('analyseGatewayCertificateFactory', () => { })); expect(problems).to.have.lengthOf(1); - expect(problems[0].getDescription()).to.include('not trusted by standard clients'); + expect(problems[0].getDescription()).to.include('not trusted by ordinary clients'); + + // The raw verification code is what an operator cannot read, so it is + // translated rather than printed. + expect(problems[0].getDescription()).to.not.include('UNABLE_TO_VERIFY_LEAF_SIGNATURE'); + expect(problems[0].getDescription()).to.include('only one certificate was sent'); + }); + + // This code is returned for a complete, correct bundle whose root the machine + // does not trust, as well as for one that really is missing certificates. + // Telling the first operator their bundle is incomplete sends them to repair + // something that is not broken. + it('should not claim certificates are missing when the issuer is merely untrusted', () => { + const problems = analyse(served({ + chainVerified: false, + chainError: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + })); + + const description = problems[0].getDescription(); + + expect(description).to.not.match(/were not sent/); + expect(description).to.contain('does not trust'); + }); + + // Only one certificate arrived, which is established rather than guessed - + // but why its issuer could not be found is not, so both readings are named. + it('should name both readings when only the certificate itself was sent', () => { + const problems = analyse(served({ + chainVerified: false, + chainError: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + })); + + expect(problems[0].getDescription()).to.contain('only one certificate'); + }); + + // An unrecognised code is still searchable; a vague paraphrase of it is not. + it('should pass through a verification code it has no wording for', () => { + const problems = analyse(served({ chainVerified: false, chainError: 'SOME_NEW_CODE' })); + + expect(problems[0].getDescription()).to.include('SOME_NEW_CODE'); }); it('should treat an identity mismatch as not having reached this node and stop there', () => { @@ -141,7 +180,11 @@ describe('analyseGatewayCertificateFactory', () => { })); expect(problems).to.have.lengthOf(1); - expect(problems[0].getDescription()).to.include('not valid for 198.51.100.7'); + expect(problems[0].getDescription()).to.include('not issued for this node\'s address'); + expect(problems[0].getDescription()).to.include('198.51.100.7'); + + // "altnames" is the certificate's own vocabulary, not the operator's. + expect(problems[0].getDescription()).to.not.include('altnames'); }); it('should judge expiry against the time the samples were taken, not the time of analysis', () => { @@ -196,9 +239,9 @@ describe('analyseGatewayCertificateFactory', () => { onDisk: { fingerprint256: 'CC:DD', validTo: validTo(60) }, })); - const [problem] = problems.filter((p) => p.getDescription().includes('on disk')); + const [problem] = problems.filter((p) => p.getDescription().includes('has been saved')); - expect(problem.getDescription()).to.include('older certificate than the one on disk'); + expect(problem.getDescription()).to.include('older certificate than the one that has been saved'); expect(problem.getSolution()).to.include('dashmate restart'); }); @@ -208,18 +251,18 @@ describe('analyseGatewayCertificateFactory', () => { onDisk: { fingerprint256: 'CC:DD', validTo: validTo(-158) }, })); - const [problem] = problems.filter((p) => p.getDescription().includes('disk')); + const [problem] = problems.filter((p) => p.getDescription().includes('has been saved')); - expect(problem.getDescription()).to.not.include('older certificate than the one on disk'); + expect(problem.getDescription()).to.not.include('older certificate than the one that has been saved'); expect(problem.getSolution()).to.not.match(/dashmate restart/); }); it('should claim no direction when it cannot compare them', () => { const problems = analyse(served({ matchesOnDisk: false, onDisk: null })); - const [problem] = problems.filter((p) => p.getDescription().includes('disk')); + const [problem] = problems.filter((p) => p.getDescription().includes('has been saved')); - expect(problem.getDescription()).to.not.include('older certificate than the one on disk'); + expect(problem.getDescription()).to.not.include('older certificate than the one that has been saved'); expect(problem.getSolution()).to.not.match(/dashmate restart/); }); }); @@ -238,7 +281,7 @@ describe('analyseGatewayCertificateFactory', () => { onDisk: { fingerprint256: 'CC:DD', validTo: validTo(30) }, })); - expect(problem.getDescription()).to.include('newer one is already present on disk'); + expect(problem.getDescription()).to.include('newer one has already been saved and is ready to use'); expect(problem.getSolution()).to.include('dashmate restart'); }); @@ -249,7 +292,7 @@ describe('analyseGatewayCertificateFactory', () => { onDisk: { fingerprint256: 'CC:DD', validTo: validTo(-158) }, })); - expect(problem.getDescription()).to.not.include('newer one is already present on disk'); + expect(problem.getDescription()).to.not.include('newer one has already been saved and is ready to use'); expect(problem.getSolution()).to.not.match(/dashmate restart/); expect(problem.getSolution()).to.include('dashmate ssl obtain'); }); @@ -261,7 +304,7 @@ describe('analyseGatewayCertificateFactory', () => { onDisk: null, })); - expect(problem.getDescription()).to.not.include('newer one is already present on disk'); + expect(problem.getDescription()).to.not.include('newer one has already been saved and is ready to use'); expect(problem.getSolution()).to.not.match(/dashmate restart/); }); }); diff --git a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js index 6e45e694b65..a4b43301e78 100644 --- a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js @@ -272,6 +272,54 @@ describe('gatewayCertificateTaskFactory', () => { }); }); + // Repairing reuses the certificate already issued when it still looks usable, + // which saves an issuance from a limited allowance. But that reuse check is + // weaker than the gate: it does not look at the address at all, and it only + // asks whether the certificate has expired, not whether it has started. So + // for the two faults it cannot see, reinstalling the same bytes is a repair + // that repairs nothing, and a new certificate has to be obtained. + describe('repairing a certificate the gate rejected', () => { + /** + * @param {Object} ctx - the mocha context, for its sinon sandbox + * @param {string} code + * @return {Promise} the context the obtain task was run with + */ + async function repairContext(ctx, code) { + let ran = null; + + obtainLetsEncryptCertificateTask = ctx.sinon.stub().callsFake(() => ({ + run: (runContext) => { + ran = runContext; + + return Promise.resolve(); + }, + })); + + await run.call(ctx, { + checkGatewayCertificate: () => invalid(code), + answers: [true, true, true], + }); + + expect(ran, 'the obtain task was never run').to.not.be.null(); + + return ran; + } + + [CERTIFICATE_REASONS.IP_MISMATCH, CERTIFICATE_REASONS.NOT_YET_VALID].forEach((code) => { + it(`should obtain a new certificate rather than reuse one rejected for ${code}`, async function it() { + expect((await repairContext(this, code)).force).to.be.true(); + }); + }); + + // Where the fault is in the copy installed for the gateway rather than in + // the certificate itself, reinstalling can fix it without spending an + // issuance, so the allowance is not burned for nothing. + it('should not force a new certificate for a fault reinstalling can fix', async function it() { + expect((await repairContext(this, CERTIFICATE_REASONS.KEY_MISMATCH)).force) + .to.not.be.true(); + }); + }); + describe('nothing blocks on a certificate that passed', () => { it('should say nothing at all for a provider that is working', async function it() { config.set('platform.gateway.ssl.provider', 'letsencrypt'); diff --git a/packages/dashmate/test/unit/oclif/command/BaseCommand.spec.js b/packages/dashmate/test/unit/oclif/command/BaseCommand.spec.js index 662fd08bc94..460ad6c84fb 100644 --- a/packages/dashmate/test/unit/oclif/command/BaseCommand.spec.js +++ b/packages/dashmate/test/unit/oclif/command/BaseCommand.spec.js @@ -5,14 +5,8 @@ import BaseCommand from '../../../../src/oclif/command/BaseCommand.js'; import ResetCommand from '../../../../src/commands/reset.js'; import GroupResetCommand from '../../../../src/commands/group/reset.js'; -// Reconfigures a node, but has a mode that only reports - the shape the -// read-only opt-out exists for. class MutatingCommand extends BaseCommand { static mutatesConfig = true; - - static isReadOnlyRun(flags) { - return flags.report === true; - } } describe('BaseCommand', () => { @@ -103,33 +97,6 @@ describe('BaseCommand', () => { expect(configFileRepository.release).to.have.been.calledOnce(); }); - // A mode that promises to change nothing has to keep that promise all the - // way down. Migrating writes and renders under the same lock, and it is due - // on exactly the run right after an upgrade - the run this mode exists for. - it('should neither lock nor persist a migration on a read-only run', async function it() { - const { command, configFileRepository } = createCommandWithContainer( - this.sinon, - ); - command.parse.resolves({ args: {}, flags: { report: true } }); - - await command.init(); - - expect(configFileRepository.acquire).to.not.have.been.called(); - expect(configFileRepository.readAndMigrate.firstCall.args[0].readOnly).to.be.true(); - }); - - it('should lock and migrate normally in the same command\'s other modes', async function it() { - const { command, configFileRepository } = createCommandWithContainer( - this.sinon, - ); - command.parse.resolves({ args: {}, flags: { report: false } }); - - await command.init(); - - expect(configFileRepository.acquire).to.have.been.calledOnce(); - expect(configFileRepository.readAndMigrate.firstCall.args[0].readOnly).to.be.false(); - }); - it('should not let an unrelated force flag skip config validation', async function it() { const { command, configFileRepository } = createCommandWithContainer( this.sinon, @@ -140,7 +107,7 @@ describe('BaseCommand', () => { await command.init(); expect(configFileRepository.readAndMigrate.firstCall.args[0]) - .to.deep.equal({ skipValidation: false, readOnly: false }); + .to.deep.equal({ skipValidation: false }); }); it('should skip validation only for the config replaced by a forced total reset', async function it() { @@ -155,7 +122,7 @@ describe('BaseCommand', () => { await platformReset.command.init(); expect(platformReset.configFileRepository.readAndMigrate.firstCall.args[0]) - .to.deep.equal({ skipValidation: false, readOnly: false }); + .to.deep.equal({ skipValidation: false }); const totalReset = createCommandWithContainer(this.sinon, ResetCommand); totalReset.command.parse.resolves({ diff --git a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js index 6592860027c..b320afbe5d0 100644 --- a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js @@ -423,7 +423,7 @@ describe('checkGatewayCertificateFactory', () => { .to.include(CERTIFICATE_REASONS.IP_MISMATCH); }); - it('should say the address is missing from the SAN rather than wrong', () => { + it('should say the certificate names no address rather than the wrong one', () => { const certificate = issueCertificate({ subject: { commonName: EXTERNAL_IP } }); install(certificate.pem, certificate.keyPem); @@ -431,7 +431,7 @@ describe('checkGatewayCertificateFactory', () => { const [reason] = checkGatewayCertificate(config).reasons .filter(({ code }) => code === CERTIFICATE_REASONS.IP_MISMATCH); - expect(reason.message).to.contain('subject alternative name'); + expect(reason.message).to.contain('names no address at all'); }); }); diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js index a1a987aa619..add3a1ccc8a 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js @@ -340,6 +340,70 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { ); } + /** + * Run a task list and collect what it rendered, so a message meant for the + * operator is checked where it actually reaches them. + * + * @param {Object} sinon + * @param {Listr} tasks + * @param {Object} [ctx] + * @return {Promise} what was rendered, whether or not the run threw + */ + async function render(sinon, tasks, ctx = { force: true }) { + let out = ''; + sinon.stub(process.stdout, 'write').callsFake((chunk) => { + out += chunk; + + return true; + }); + + try { + /* eslint-disable-next-line no-param-reassign */ + tasks.options = { ...tasks.options, renderer: 'verbose' }; + + await tasks.run(ctx).catch(() => {}); + } finally { + process.stdout.write.restore(); + } + + return out; + } + + // The operator who opened port 80 for this one migration is the one who + // most needs to hear it stays open, and they never see a failure that would + // have said so. Said next to the issuance rather than at the end of the + // command, so a later step failing cannot swallow it. + it('should tell the operator port 80 stays open once a certificate is issued', async function it() { + const output = await render(this.sinon, buildTask(this.sinon)(config)); + + expect(output).to.contain('LEAVE PORT 80 OPEN'); + expect(output).to.contain('survives a reboot'); + }); + + // Issuance is recorded before the pair is written, and the notice with it, + // so a failure between the two still reaches the operator who is about to + // close the port. Pinned at both points a run can fail after the authority + // has issued: writing the pair, and finding what lego wrote. + it('should tell them even when a later step fails', async function it() { + const save = this.sinon.stub().callsFake(() => new Listr([{ + task: () => { throw new Error('could not write the certificate'); }, + }])); + + const output = await render(this.sinon, buildTask(this.sinon, { save })(config)); + + expect(output).to.contain('LEAVE PORT 80 OPEN'); + }); + + it('should tell them when lego wrote nothing it could find', async function it() { + const docker = getDockerMock(this.sinon); + const output = await render(this.sinon, buildTask(this.sinon, { docker })(config), { + force: true, + legoCertPathOverride: '/nonexistent', + }); + + expect(output).to.contain('LEAVE PORT 80 OPEN'); + }); + // No new node will have an email: nothing prompts for one any more. A // throw left anywhere on this path breaks every fresh setup. it('should obtain a certificate with no email configured', async function it() { @@ -513,10 +577,14 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { // cause rather than asserted as the cause. expect(error.message).to.contain('address already in use'); expect(error.message).to.contain('holding port 80'); - expect(error.message).to.contain('no request was'); + // Docker rejected the start, which does not settle whether the helper + // ran: a bind conflict and a lost reply look the same from here. So + // nothing is claimed about this node's allowance in either direction. + expect(error.message).to.not.contain('never contacted'); + expect(error.message).to.not.contain('allowance'); // And none of the authority-side consequences are claimed. - expect(error.message).to.not.contain('PAUSED'); + expect(error.message).to.not.match(/paused/i); expect(error.message).to.not.contain('rate-limit'); expect(error.message).to.not.contain('failed attempts are shared'); expect(error.message).to.not.contain('Fix inbound port 80 first'); @@ -557,7 +625,7 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { // Printed once, by the command, not also here. expect(error.message).to.not.contain('LEAVE PORT 80 OPEN'); - expect(error.message).to.not.contain('PAUSED'); + expect(error.message).to.not.match(/paused/i); expect(error.message).to.not.contain('failed attempts are shared'); expect(error.message).to.not.match(/did not obtain a certificate after/i); @@ -582,7 +650,7 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { .run({ force: true }).catch((e) => e); expect(error.message).to.contain('permission denied while removing container'); - expect(error.message).to.not.contain('PAUSED'); + expect(error.message).to.not.match(/paused/i); expect(error.message).to.not.contain('failed attempts are shared'); expect(docker.createContainer).to.not.have.been.called(); }); @@ -605,12 +673,12 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { expect(error.message).to.not.match(/the port is occupied/i); expect(error.message).to.not.match(/is already listening on port 80/i); // Nor any of the guidance that belongs to a response from the authority. - expect(error.message).to.not.contain('PAUSED'); + expect(error.message).to.not.match(/paused/i); expect(error.message).to.not.contain('failed attempts are shared'); expect(error.message).to.not.contain('Fix inbound port 80 first'); // It may say no limit was spent - that is the honest statement. What it // must not do is discuss a limit as though one had been. - expect(error.message).to.contain('no request was'); + expect(error.message).to.contain('never contacted'); expect(error.message).to.not.match(/may be PAUSED|Self-Service Portal/i); }); @@ -632,7 +700,7 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { expect(error.message).to.contain('connection reset by peer'); expect(error.message).to.match(/could not read|did not see/i); - expect(error.message).to.not.contain('PAUSED'); + expect(error.message).to.not.match(/paused/i); }); // A failure the authority did return keeps the guidance that is about the @@ -643,8 +711,8 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { const error = await buildFailingTask(this.sinon, docker)(config) .run({ force: true }).catch((e) => e); - expect(error.message).to.contain('PAUSED'); - expect(error.message).to.contain('failed attempts are shared'); + expect(error.message).to.match(/may have\s+paused\s+it/i); + expect(error.message).to.contain('uses up that allowance'); }); // lego fails for reasons that have nothing to do with the firewall - a rate @@ -688,9 +756,9 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { const error = await tasks.run({ force: true, interactive: true }).catch((e) => e); expect(error.message).to.contain('https://letsencrypt.org/docs/rate-limits/'); - expect(error.message).to.contain('PAUSED'); + expect(error.message).to.match(/may have\s+paused\s+it/i); expect(error.message).to.contain(`--config ${config.getName()}`); - expect(error.message).to.contain('renews under the same'); + expect(error.message).to.contain('renewals dashmate runs for you'); expect(error.message).to.not.match(/come back in \d/i); }); diff --git a/packages/dashmate/test/unit/ssl/probeServedCertificate.spec.js b/packages/dashmate/test/unit/ssl/probeServedCertificate.spec.js index 5fbba336b42..951ad37874b 100644 --- a/packages/dashmate/test/unit/ssl/probeServedCertificate.spec.js +++ b/packages/dashmate/test/unit/ssl/probeServedCertificate.spec.js @@ -2,6 +2,7 @@ import net from 'node:net'; import tls from 'node:tls'; import probeServedCertificate, { STATE } from '../../../src/ssl/probeServedCertificate.js'; import createCertificateForTest from '../../../src/test/createCertificateForTest.js'; +import { issueChain } from '../../../src/test/certificateFixtures.js'; const EXTERNAL_IP = '127.0.0.1'; @@ -47,6 +48,21 @@ describe('probeServedCertificate', () => { }))); }); + // A bundle can be complete and correct and still fail to verify, because the + // root that signed it is not one this machine trusts - a staging or private + // authority. The code that comes back is the same one a genuinely incomplete + // chain produces, which is why nothing downstream may read it as "certificates + // are missing". + it('should report a complete chain signed by an untrusted root', async () => { + const { leaf, intermediate } = issueChain({ ip: '127.0.0.1' }); + const port = await listenTls({ cert: leaf.pem + intermediate.pem, key: leaf.keyPem }); + + const result = await probeServedCertificate({ host: '127.0.0.1', port, externalIp: '127.0.0.1' }); + + expect(result.chainVerified).to.be.false(); + expect(result.chainError).to.equal('UNABLE_TO_GET_ISSUER_CERT_LOCALLY'); + }); + it('should report the certificate the server actually serves', async () => { const { cert, key } = createCertificateForTest({ days: 30 }); const port = await listenTls({ cert, key }); diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index 59ffc7ae908..bad3c9983ee 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -192,7 +192,7 @@ describe('renderCertificateGuidance', () => { const output = render(); - expect(output).to.contain('already configured for Let\'s Encrypt'); + expect(output).to.contain('already set to use Let\'s Encrypt'); expect(output).to.contain('THE FIX - obtain a new certificate'); expect(output).to.not.contain('THE FIX - switch to'); @@ -293,6 +293,29 @@ describe('renderCertificateGuidance', () => { }); }); + // Docker being unreachable, or the caller not being permitted to ask it, + // says nothing about whether the node is up. Reporting it as stopped tells + // the operator something false about their own node and offers them a + // command for a state it may not be in. + describe('when the node state could not be determined', () => { + it('should not say the node is stopped', () => { + const output = render({ isNodeRunning: null }); + + expect(output).to.not.contain('Your node is currently stopped'); + expect(output).to.not.contain('dashmate start --config base'); + }); + + it('should not claim a running node needs nothing further either', () => { + const output = render({ isNodeRunning: null }); + + expect(output).to.not.contain('needs nothing further - no restart'); + }); + + it('should still give the operator the fix', () => { + expect(render({ isNodeRunning: null })).to.contain('dashmate ssl obtain --config base'); + }); + }); + it('should say when images failed to pull', () => { expect(render({ pull: { ok: true, failed: 2, total: 7 } })) .to.contain('2 of 7 failed'); @@ -313,11 +336,14 @@ describe('renderCertificateGuidance', () => { expect(output).to.contain("This node's installed TLS certificate did not pass"); }); - it('should explain the ZeroSSL wall without blaming the operator', () => { + // The operator's own situation, with no claim about what other providers + // offer and no statistics about other people's nodes. + it('should explain the ZeroSSL wall in terms of this node', () => { const output = render(); - expect(output).to.contain('You did not\n configure anything wrong'); - expect(output).to.contain('as of August 2026'); + expect(output).to.contain('three\n certificates in total'); + expect(output).to.not.match(/four out of five|as of August/); + expect(output).to.not.match(/does not issue certificates for IP addresses/); }); // Half the expired Let's Encrypt nodes measured had port 80 demonstrably @@ -328,8 +354,12 @@ describe('renderCertificateGuidance', () => { const output = render({ verdict: verdict({ provider: 'letsencrypt' }) }); - expect(output).to.contain('The most likely cause is inbound port 80'); + expect(output).to.contain('Inbound port 80 is the most common cause'); expect(output).to.contain('It is not always port 80'); + + // No claim about what any other authority does or does not issue, and no + // narration about what dashmate has or has not looked at. + expect(output).to.not.match(/only free one|has not read|retries renewal every hour/); expect(output).to.contain('dashmate logs --config base dashmate_helper'); }); From 7272f5cb6a98873f8fd4f335602f8ce478c96912 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 22 Aug 2026 18:12:00 +0700 Subject: [PATCH 52/63] refactor(dashmate): cut the certificate messages down to what to do Server software is run by people who skim. The previous pass made this text accurate and left it long, and length is its own defect: sixteen lines of prose to ask a yes/no question is not read, it is scrolled past, and the one line that mattered goes with it. Every operator-facing string is now what is wrong and what to type. Prompts fit in a few lines before the question; failure messages fit on a screen with the command to run. What went, everywhere it appeared: rationale, mechanism, arithmetic about certificate lifetimes, reassurance about how long things take, qualifications of qualifications, narration of what dashmate did or noticed, and sentences answering questions nobody asked. None of it was accuracy - it was explanation, and cutting explanation costs nothing. The port 80 requirement is one sentence now, and says the thing itself: keep inbound port 80 reachable from the internet permanently, for certificate reissue. The retry prompt read as an argument against the fix it was recommending - "most often port 80, but a rate limit is not fixed by opening a firewall" - so an operator could take away that opening the firewall would not help. It leads with the action instead. Claims stay inside what the code establishes; nothing here trades accuracy for brevity. Assertions on removed wording were re-pointed at what each test verifies, not deleted. Also inlines a one-line migration helper that carried a nine-line comment. Co-Authored-By: Claude Opus 5 --- .../configs/getConfigFileMigrationsFactory.js | 31 +--- .../doctor/analyse/analyseConfigFactory.js | 12 +- .../analyseGatewayCertificateFactory.js | 35 ++-- ...obtainLetsEncryptCertificateTaskFactory.js | 66 +++---- .../update/gatewayCertificateTaskFactory.js | 61 ++----- .../src/ssl/renderCertificateGuidance.js | 161 +++++------------- .../test/unit/commands/update.spec.js | 2 +- .../analyseGatewayCertificateFactory.spec.js | 6 +- .../gatewayCertificateTaskFactory.spec.js | 6 +- ...nLetsEncryptCertificateTaskFactory.spec.js | 22 +-- .../ssl/renderCertificateGuidance.spec.js | 60 +++---- 11 files changed, 150 insertions(+), 312 deletions(-) diff --git a/packages/dashmate/configs/getConfigFileMigrationsFactory.js b/packages/dashmate/configs/getConfigFileMigrationsFactory.js index 75714db511c..077ebae1225 100644 --- a/packages/dashmate/configs/getConfigFileMigrationsFactory.js +++ b/packages/dashmate/configs/getConfigFileMigrationsFactory.js @@ -62,27 +62,6 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) } } - /** - * Drop the Tenderdash Commit timeout overrides Tenderdash itself removed. - * - * The config schema stopped defining them, and it accepts no property it - * does not define, so a config that still carries them cannot be loaded at - * all. - * - * Called from more than one migration on purpose. A config records the - * version of the build that wrote it, and a development build records its - * own prerelease version - which semver orders above a key named after an - * earlier patch release. A config stamped that way skips such a key - * entirely, so a deletion the schema depends on has to be repeated at a key - * above every stamp still in the field. Repeating it costs nothing: removing - * a key that is not there does nothing. - * - * @param {Object} options - one config's options - */ - function dropRemovedTenderdashCommitOverride(options) { - delete options.platform?.drive?.tenderdash?.consensus?.unsafeOverride?.commit; - } - function getDefaultConfigByNetwork(network) { if (network === NETWORK_MAINNET) { return defaultConfigs.get('mainnet'); @@ -1725,9 +1704,9 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) '4.2.0': (configFile) => { Object.entries(configFile.configs) .forEach(([, options]) => { - // Also done by the 4.1.1 migration, which a config written by a - // development build of this release is stamped above and skips. - dropRemovedTenderdashCommitOverride(options); + // Repeated from the 4.1.1 migration: a config written by a + // development build is stamped above that key and skips it. + delete options.platform?.drive?.tenderdash?.consensus?.unsafeOverride?.commit; const providerConfigs = options.platform?.gateway?.ssl?.providerConfigs; @@ -1778,7 +1757,9 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) rsDapiDocker.image = base.get('platform.dapi.rsDapi.docker.image'); } - dropRemovedTenderdashCommitOverride(options); + // The schema no longer defines this and accepts no undefined + // property, so a config still carrying it cannot be loaded. + delete options.platform?.drive?.tenderdash?.consensus?.unsafeOverride?.commit; }); return configFile; diff --git a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js index 8f74664a140..603e5b4266a 100644 --- a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js @@ -191,12 +191,12 @@ Note that changing it makes renewal register a new account with the authority.`, // serving a valid certificate that is what takes it off the network. [LETSENCRYPT_ERRORS.CERTIFICATE_NOT_INSTALLED]: { description: chalk`A renewed Let's Encrypt certificate has not been installed for the gateway.`, - solution: chalk`The issued certificate was never copied to where the gateway loads from, -so the two disagree. Install it - that also signals the gateway, with no -downtime: {bold.cyanBright dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider=letsencrypt} -Do not restart Platform to fix this. A restart only reloads the copy the -gateway already has, which is the out-of-date one, and this node may still -be serving a valid certificate that a restart would throw away.`, + solution: chalk`The issued certificate was never copied to where the gateway loads +from. Install it - no restart needed: +{bold.cyanBright dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider=letsencrypt} + +Do not restart Platform. That reloads the out-of-date copy and may throw away +a working certificate.`, }, [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 196be877df7..4f5d7e4ecdc 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -77,9 +77,8 @@ const restartHint = (cfg) => chalk`Then restart Platform so the gateway picks it * does, and only refuses to report success. Leaving this out lets a client * reachability problem be read as a software delivery one. */ -const UPDATE_CONSEQUENCE = 'The certificate installed for the gateway did not pass dashmate\'s' - + ' checks. `dashmate update` still pulls new images, so protocol upgrades and security patches' - + ' continue to arrive - but it exits non-zero until this is fixed.'; +const UPDATE_CONSEQUENCE = 'The gateway certificate did not pass dashmate\'s checks.' + + ' `dashmate update` still pulls images, but exits non-zero until this is fixed.'; export default function analyseGatewayCertificateFactory() { /** @@ -113,7 +112,7 @@ export default function analyseGatewayCertificateFactory() { message, chalk`${UPDATE_CONSEQUENCE} -Obtain a new certificate - it signals the gateway itself, so no restart is needed: +Obtain a new certificate. No restart needed: {bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, SEVERITY.HIGH, )); @@ -171,12 +170,11 @@ Obtain a new certificate - it signals the gateway itself, so no restart is neede problems.push(new Problem( `The certificate being served on port ${served.port} is not issued for this` + ` node's address, ${externalIp}`, - chalk`Something other than this node's gateway may be answering on that port, or the -certificate is issued for the wrong address. Find what is listening on ${served.port} -first - another dashmate config, a reverse proxy, or a second node sharing the -address. + chalk`Something other than this node's gateway may be answering on port ${served.port} - +another dashmate config, a reverse proxy, or a second node. Find what is +listening there first. -If this node's gateway is the one answering and the address is simply wrong: +If this node's gateway is answering and the address is simply wrong: {bold.cyanBright dashmate ssl obtain ${cfg} --force}`, SEVERITY.HIGH, )); @@ -224,9 +222,8 @@ If this node's gateway is the one answering and the address is simply wrong: `This node is using a certificate that expired on ${served.certificate.validTo}. ` + 'A different one has been saved, but dashmate could not confirm it is a working ' + 'replacement', - chalk`Neither the certificate this node is using nor the saved one is known to work, -so restarting will not help. Get a current certificate - that installs it and -tells the node to use it, with no downtime: + chalk`Neither the certificate in use nor the saved one is known to work, so +restarting will not help. Get a current one: {bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, SEVERITY.HIGH, )); @@ -234,9 +231,8 @@ tells the node to use it, with no downtime: problems.push(new Problem( `This node is using a certificate that expired on ${served.certificate.validTo}. ` + 'Clients cannot connect to it', - chalk`Renewal has not succeeded. Check the renewal logs: + chalk`Renewal has not succeeded. Check the logs, then obtain a new certificate: {bold.cyanBright dashmate logs ${cfg} dashmate_helper} -Then obtain a new certificate, which installs it and signals the gateway: {bold.cyanBright dashmate ssl obtain ${cfg}}`, SEVERITY.HIGH, )); @@ -255,10 +251,8 @@ Then obtain a new certificate, which installs it and signals the gateway: problems.push(new Problem( 'This node is using a different certificate from the one that has been saved, and ' + 'dashmate could not confirm the saved one is a working replacement', - chalk`The certificate this node is using now works. The saved one has not been shown -to be a safe replacement, so do not restart to load it - that would swap a -working certificate for one that may not be. Get a current certificate -instead, which installs it and tells the node to use it: + chalk`The certificate in use works. The saved one is not known to be a safe +replacement, so do not restart to load it. Get a current one instead: {bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, SEVERITY.HIGH, )); @@ -272,9 +266,8 @@ instead, which installs it and tells the node to use it: problems.push(new Problem( 'The certificate this node is serving is not trusted by ordinary clients:' + ` ${describe(TRUST_FAILURES, 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. + chalk`Standard clients will reject this node. If the chain is incomplete, make sure +the bundle contains the issuing certificates as well as the server one. ${restartHint(cfg)}`, SEVERITY.HIGH, )); diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index eb26dccced0..f8bab76dfe0 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -32,13 +32,8 @@ const MAX_OBTAIN_ATTEMPTS = 3; * and nothing reports it. The operator who has just succeeded is the one least * likely to hear this otherwise, because they never saw a failure. */ -export const PORT_80_PERMANENCE = `LEAVE PORT 80 OPEN. This is not a one-time requirement. Certificates for -IP addresses last about six days, and dashmate keeps renewing this one for as -long as the node runs - every renewal needs inbound port 80 again. - -If you opened port 80 just to make this work, make the rule permanent and make -sure it survives a reboot. If it lapses, this node goes dark within six days -and nothing will tell you.`; +export const PORT_80_PERMANENCE = 'Keep inbound port 80 reachable from the internet permanently,' + + ' for certificate reissue.'; /** * What to tell an operator who has run out of attempts. @@ -52,32 +47,20 @@ and nothing will tell you.`; * @return {string} */ function renderGiveUpGuidance(config, attempts) { - return `dashmate did not obtain a certificate after ${attempts} ` - + `attempt${attempts === 1 ? '' : 's'}. - -Do not keep retrying. Let's Encrypt limits how often this node may fail, and -every further attempt uses up that allowance - including the automatic -renewals dashmate runs for you in the background. + const cfg = renderConfigFlag(config.getName()); -Inbound port 80 is what to fix first. How you open it depends on the host, so -check both places it can be blocked: the machine's own firewall, and the -firewall or security group your hosting provider runs in front of it. Both -have to allow inbound TCP 80 from anywhere, and the rule has to survive a -reboot. + return `No certificate after ${attempts} attempt${attempts === 1 ? '' : 's'}. -Once it is open: - ` - + `dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider letsencrypt` - + ` +Do not keep retrying - Let's Encrypt limits how often this node may fail. -If this node has been failing for a long time, Let's Encrypt may have paused -it rather than slowed it down. Waiting does not clear a pause - see - https://letsencrypt.org/docs/rate-limits/ +Open inbound port 80, on the machine's firewall and your hosting provider's. +Then: + dashmate ssl obtain ${cfg} --provider letsencrypt -If you are stuck, collect a report and send it to Dash support: - dashmate doctor report ${renderConfigFlag(config.getName())} +${PORT_80_PERMANENCE} -${PORT_80_PERMANENCE}`; +Still stuck? Send a report to Dash support: + dashmate doctor report ${cfg}`; } /** @@ -94,26 +77,17 @@ ${PORT_80_PERMANENCE}`; * @return {string} */ function renderHelperDidNotStartGuidance(config, cause, neverRan = true) { - // Nothing is claimed about this node's allowance unless it is known. Failing - // to create the helper settles it - there was nothing to make a request. A - // start that failed does not: Docker can reject a start it has accepted, and - // a bind conflict looks the same from here as a lost reply. Rather than pick - // one and be wrong half the time, the sentence is simply not there. - return `${neverRan - ? `dashmate could not start the certificate helper, so it never contacted -Let's Encrypt. Nothing was requested and none of this node's allowance was -used up.` - : 'dashmate could not start the certificate helper.'} + // Nothing is claimed about this node's allowance unless it is known. + return `dashmate could not start the certificate helper.${neverRan + ? " It never contacted Let's Encrypt." + : ''} Docker reported: ${cause.message} -That message is the diagnosis - dashmate did not look further than it. One -common cause is another process already holding port 80, which is the -opposite of a blocked port: it is reachable and occupied. Others are the -Docker daemon being unreachable, or the current user not being permitted to -use it. +Common causes: another process already using port 80, Docker not running, or +this user not permitted to use it. sudo ss -lntp 'sport = :80' dashmate ssl obtain ${renderConfigFlag(config.getName())} --provider letsencrypt`; @@ -553,10 +527,8 @@ export default function obtainLetsEncryptCertificateTaskFactory( ${e.message} - Whatever the output above says is the reason - most often inbound port 80, - but a rate limit or an account problem looks different and is not fixed by - opening a firewall. Retrying without changing anything will fail the same - way, so read it first, then answer Yes once something has changed.`, + Read the error above. Inbound port 80 being closed is the usual cause - open + it, then answer Yes. Retrying without changing anything fails the same way.`, message: `Try again? [attempt ${attempt + 1} of ${MAX_OBTAIN_ATTEMPTS}]`, enabled: 'Yes', disabled: 'No', diff --git a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js index a1e1341c97c..9444661fc45 100644 --- a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js @@ -29,22 +29,16 @@ const ZEROSSL_URGENT_DAYS = 14; */ function renderDeclining(verdict) { if (verdict.status === CERTIFICATE_STATUS.CHECKS_PASSED) { - return ` The certificate installed for the gateway passed these checks and stays - in place, so nothing changes if you decline. -`; + return ' The certificate passed these checks. Declining changes nothing.\n'; } if (verdict.status !== CERTIFICATE_STATUS.WARN) { - return ` Declining leaves the certificate installed for the gateway exactly as it - is: unchanged, and still failing the checks above. -`; + return ' Declining leaves the certificate failing the checks above.\n'; } - // Rendered here rather than referred to. This prompt is where the operator - // decides, and the warnings are printed only once the command has finished, - // so pointing at them would be pointing at something not yet on screen. - return ` Declining leaves the certificate installed for the gateway exactly as it - is. Nothing about it stopped this update, but these checks did find: + // Rendered rather than referred to: the warnings are printed after the + // command finishes, so pointing at them would point at a blank screen. + return ` Declining changes nothing. These checks found: ${verdict.warnings.map(({ message }) => ` - ${message}\n`).join('')}`; } @@ -62,23 +56,10 @@ ${verdict.warnings.map(({ message }) => ` - ${message}\n`).join('')}`; * @return {string} */ function renderSwitchOffer(config, externalIp, { verdict } = {}) { - return ` Switching this node to Let's Encrypt will: - - obtain a new certificate now, free, for ${externalIp} - - change platform.gateway.ssl.provider from ${config.get('platform.gateway.ssl.provider')} to letsencrypt - - leave your existing provider's account and credentials untouched but unused - - It needs inbound port 80 reachable from the internet right now, and it needs - port 80 permanently thereafter - not on a schedule you can plan around. - Certificates for IP addresses last about six days and dashmate renews them - continuously for as long as this node runs. A rule you open now and close - later, or one that does not survive a reboot, takes this node dark within - six days. - - It usually takes under a minute. If port 80 is not open yet, answer No, open - it permanently, and re-run dashmate update ${renderConfigFlag(config.getName())}. - - Your image pull is running now and will finish either way, so answering No - does not hold this node back from protocol upgrades or security patches. + return ` Switch to Let's Encrypt and get a free certificate for ${externalIp}. + + You need inbound port 80 reachable from the internet permanently, for + certificate reissue. Answer No if it is not open yet. ${renderDeclining(verdict ?? { status: CERTIFICATE_STATUS.INVALID, warnings: [] })}`; } @@ -98,19 +79,12 @@ function renderSuccess(config, verdict) { : 'unknown'; const days = verdict.expiresInDays === null ? '?' : Math.floor(verdict.expiresInDays); - return ` Certificate obtained from Let's Encrypt for ${config.get('externalIp')} - Valid until ${expiresAt} (about ${days} days). - - LEAVE PORT 80 OPEN. This was not a one-time requirement. Certificates for - IP addresses last about six days, and dashmate keeps renewing this one for - as long as the node runs - every renewal needs inbound port 80 again. + return ` Certificate obtained for ${config.get('externalIp')}, valid until ${expiresAt} (${days} days). - If you opened port 80 just now to make this work, make the rule permanent - and make sure it survives a reboot. If it lapses, this node goes dark - within six days. + Keep inbound port 80 reachable from the internet permanently, for certificate + reissue. Nothing will warn you if it lapses. - Nothing will warn you: Let's Encrypt stopped sending expiry emails on - 2025-06-04. Check with: dashmate doctor ${renderConfigFlag(config.getName())} + dashmate doctor ${renderConfigFlag(config.getName())} `; } @@ -327,11 +301,10 @@ export default function gatewayCertificateTaskFactory( const warn = () => { ctx.certificateWarnings = [ ...(ctx.certificateWarnings ?? []), - `This node is configured to use ZeroSSL, and the certificate it has` - + ` installed expires ${remaining}. A free ZeroSSL` - + " account allows three certificates in total, so dashmate's renewals stop" - + ` working after about 270 days. Switch to Let's Encrypt with:` - + `\n dashmate ssl obtain ${cfg} --provider letsencrypt`, + `This node uses ZeroSSL and its certificate expires ${remaining}.` + + ' A free ZeroSSL account allows three certificates in total, so renewals' + + ' stop working after about 270 days.' + + `\n\n dashmate ssl obtain ${cfg} --provider letsencrypt`, ]; }; diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index 7739e15a601..409e0f9a29b 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -18,22 +18,20 @@ import { CERTIFICATE_REASONS } from './checkGatewayCertificateFactory.js'; */ function renderOpening(pull) { if (pull === null || pull === undefined) { - return " This node's installed TLS certificate did not pass dashmate's checks."; + return " This node's TLS certificate did not pass dashmate's checks."; } if (!pull.ok) { - return ` This run could not pull images, and stopped: this node's installed TLS - certificate did not pass dashmate's checks.`; - } - - if (pull.failed > 0) { - return ` This run pulled images - ${pull.failed} of ${pull.total} failed, see the table - above - then stopped: this node's installed TLS certificate did not pass + return ` Images could not be pulled, and this node's TLS certificate did not pass dashmate's checks.`; } - return ` This run pulled images, then stopped: this node's installed TLS - certificate did not pass dashmate's checks.`; + const pulled = pull.failed > 0 + ? `Images pulled, ${pull.failed} of ${pull.total} failed - see the table above.` + : 'Images pulled.'; + + return ` ${pulled} This node's TLS certificate did not pass + dashmate's checks.`; } /** @@ -53,10 +51,8 @@ function renderObservation(verdict) { * @return {string} */ function renderZeroSslExplanation() { - return ` Your certificate provider is ZeroSSL. A free ZeroSSL account allows three - certificates in total, and dashmate renews from that same allowance - so - after about 270 days there is nothing left to renew with and this node stops - getting new certificates. + return ` This node uses ZeroSSL. A free ZeroSSL account allows three certificates in + total, so renewals stop working after about 270 days. `; } @@ -69,18 +65,8 @@ function renderZeroSslExplanation() { * @return {string} */ function renderPortEightyPermanence() { - return ` PORT 80 MUST STAY OPEN PERMANENTLY - this is not a maintenance window. - Let's Encrypt IP-address certificates last about six days, and dashmate - renews them continuously for as long as this node runs. Every renewal needs - inbound port 80 again. - - If you open port 80 only to get this certificate and close it afterwards, or - if the rule does not survive a reboot, this node goes dark within six days - and nothing will tell you. That is the most common way an evonode dies: three - mainnet nodes issued certificates on the same day all went dark together six - days later - one operator, one change, a whole fleet at once. - - Make the rule permanent and make sure it persists across reboots. + return ` Keep inbound port 80 reachable from the internet permanently, for + certificate reissue. Nothing will warn you if it lapses. `; } @@ -94,12 +80,8 @@ function renderPortEightyPermanence() { * @return {string} */ function renderSwitchIncompleteGuidance(config, cfg) { - return ` A Let's Encrypt certificate is already installed for the gateway, but the - configuration still names ${config.get('platform.gateway.ssl.provider')}. A previous switch was interrupted - after the files were written and before the setting was saved, so dashmate's - helper is renewing the wrong provider. - - Nothing needs to be obtained. Finish the switch: + return ` A Let's Encrypt certificate is installed, but the configuration still says + ${config.get('platform.gateway.ssl.provider')}. Nothing needs to be obtained - finish the switch: dashmate config set ${cfg} platform.gateway.ssl.provider letsencrypt `; @@ -118,69 +100,35 @@ function renderSwitchIncompleteGuidance(config, cfg) { * @return {string} */ function renderLetsEncryptDiagnosis(cfg) { - return ` This node is already set to use Let's Encrypt, so there is no provider to - switch to. - - Inbound port 80 is the most common cause. Let's Encrypt re-checks it on every - renewal - roughly every four days, permanently - and a firewall rule that was - opened once and later closed, or that did not survive a reboot, produces - exactly this pattern. - - It is not always port 80. Check the renewal logs as well: + return ` This node already uses Let's Encrypt, so there is no provider to switch to. + Inbound port 80 is the most common cause. Check the renewal logs: - dashmate doctor ${cfg} dashmate logs ${cfg} dashmate_helper `; } /** * @param {string} cfg - * @param {boolean} isNodeRunning + * @param {boolean} isAlreadyLetsEncrypt * @return {string} */ -function renderFix(cfg, isNodeRunning, isAlreadyLetsEncrypt) { - const DELIVERY = { - true: ` That installs the certificate and signals the gateway, so a running node - needs nothing further - no restart. -`, - false: ` That installs the certificate and signals the gateway. This node is - stopped, so bring it back up: - - dashmate start ${cfg} -`, - // Nothing is claimed about a state that could not be read. - unknown: ` That installs the certificate and signals the gateway, so a node that is - already running needs nothing further. If this one is stopped, start it. -`, - }; - - // A node already on Let's Encrypt has nothing to switch to - it is the only - // authority that issues IP-address certificates over ACME - so the heading - // that offers a switch would contradict the diagnosis printed above it. The - // commands are the same either way. - // - // No restart follows the obtain. That command installs the pair and signals - // the gateway, and the signal reaches Envoy's hot-restarter, which re-execs - // Envoy against the same configuration without touching the container, so a - // restart would cost an outage and change nothing. Starting a node that is - // already stopped is a different thing and stays. +function renderFix(cfg, isAlreadyLetsEncrypt) { + // A node already on Let's Encrypt has nothing to switch to, so the heading + // that offers a switch would contradict the diagnosis above it. const heading = isAlreadyLetsEncrypt - ? ` THE FIX - obtain a new certificate from Let's Encrypt.` - : ` THE FIX - switch to Let's Encrypt, which issues IP-address certificates free.`; + ? " THE FIX - obtain a new certificate from Let's Encrypt." + : " THE FIX - switch to Let's Encrypt. Certificates are free."; return `${heading} - Let's Encrypt proves this node owns its IP by connecting to it on inbound - port 80. Check that first; it limits how often you may fail, so a blind - attempt is expensive: + This needs inbound port 80 reachable from the internet. Check it first: dashmate doctor ${cfg} Then: dashmate ssl obtain ${cfg} --provider letsencrypt - -${DELIVERY[String(isNodeRunning)] ?? DELIVERY.unknown}`; +`; } /** @@ -224,40 +172,21 @@ export default function renderCertificateGuidance({ Node: ${config.get('network')} (config "${config.getName()}", ${config.get('externalIp') ?? 'no external IP set'}) Certificate: ${renderObservation(verdict)} - - These checks read the certificate files installed on this node. They do not - tell you what clients actually see; \`dashmate doctor ${cfg}\` does that. - - ${obtainAttemptFailed - ? `An attempt to obtain a certificate ran just now and did not complete. - It can have failed at any point, including after writing one half of the - pair, so the files on disk may not be what they were before this run. The - status above was read back from disk after the attempt, so it describes - what is there now.` - : `Nothing broke just now. This check is new, so this is the first time you - are being told.`} -`, +${obtainAttemptFailed + ? ` + An attempt to obtain a certificate just failed part way through, so what is + installed may have changed. The status above was read after that attempt. +` + : ''}`, ]; - // The node is normally down when this is read: the documented upgrade - // procedure stops it before update runs. An operator who reads a certificate - // complaint, assumes it changed nothing and walks away has left a stopped - // masternode behind. - // Only when the state is actually known. Docker being unreachable, or the - // caller not being permitted to ask it, establishes nothing - and a courtesy - // line that tells an operator their running node is stopped is worse than no - // line at all. + // Only when the state is known. Docker being unreachable establishes nothing, + // and telling an operator their running node is stopped is worse than + // saying nothing. if (isNodeRunning === false) { - // The reassurance holds for a certificate that merely failed the checks: - // nothing about them gates startup. It does not hold once an obtain has - // run and failed, because what is on disk may have changed underneath the - // gateway, and promising a clean start there is a claim this cannot make. - blocks.push(obtainAttemptFailed - ? ` Your node is currently stopped. Bring it back up with \`dashmate start ${cfg}\`, - then check it came up: the attempt above may have changed what is installed. -` - : ` Your node is currently stopped. Run \`dashmate start ${cfg}\` to bring - it back up - the certificate problem does not prevent it from starting. + blocks.push(` Your node is stopped. The certificate does not prevent it starting: + + dashmate start ${cfg} `); } @@ -272,26 +201,16 @@ export default function renderCertificateGuidance({ blocks.push(renderLetsEncryptDiagnosis(cfg)); } - blocks.push(renderFix(cfg, isNodeRunning, provider === SSL_PROVIDERS.LETSENCRYPT)); + blocks.push(renderFix(cfg, provider === SSL_PROVIDERS.LETSENCRYPT)); blocks.push(renderPortEightyPermanence()); - blocks.push(` IF YOU CANNOT OPEN PORT 80. dashmate currently has no supported alternative - for an IP-address certificate, so there is no route from here to one issued - by a public authority. Updates themselves are unaffected: images are - always pulled, whatever this check finds, so this node is not being held back - from protocol upgrades or security patches. To suppress this check for one - run: + blocks.push(` Cannot open port 80? There is no other way to get an IP-address + certificate. Images are pulled either way, so this node is not held back. To + skip this check for one run: dashmate update ${cfg} --skip-certificate-check - - This silences the check; it does not repair the certificate. It is an escape - for a single run, not a line to add to a playbook. `); } - blocks.push(` This release does not block \`dashmate start\` or \`dashmate restart\`. The - certificate check applies only to \`dashmate update\`. -`); - return `\n${blocks.join('\n')}\n`; } diff --git a/packages/dashmate/test/unit/commands/update.spec.js b/packages/dashmate/test/unit/commands/update.spec.js index 5579a2eddb4..19172d75c15 100644 --- a/packages/dashmate/test/unit/commands/update.spec.js +++ b/packages/dashmate/test/unit/commands/update.spec.js @@ -192,7 +192,7 @@ describe('Update command', () => { }, })).to.be.rejected(); - expect(stderr).to.contain('did not complete'); + expect(stderr).to.contain('may have changed'); expect(stderr).to.not.contain('Nothing broke just now'); }); diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index 1e329226e83..2c9815717ca 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -386,7 +386,7 @@ describe('analyseGatewayCertificateFactory', () => { const [problem] = analyse(hijacked()); expect(problem.getSolution()).to.not.match(/dashmate restart/); - expect(problem.getSolution()).to.contain('what is listening on 443'); + expect(problem.getSolution()).to.contain('answering on port 443'); }); // Reissuing is only the remedy once the gateway is known to be the thing @@ -394,7 +394,7 @@ describe('analyseGatewayCertificateFactory', () => { it('should offer reissuing only once the gateway is known to be answering', () => { const [problem] = analyse(hijacked()); - expect(problem.getSolution()).to.contain('If this node\'s gateway is the one answering'); + expect(problem.getSolution()).to.contain('If this node\'s gateway is answering'); expect(problem.getSolution()).to.contain('dashmate ssl obtain --config base --force'); }); }); @@ -531,7 +531,7 @@ describe('analyseGatewayCertificateFactory', () => { warnings: [], }); - expect(problem.getSolution()).to.include('still pulls new images'); + expect(problem.getSolution()).to.include('still pulls images'); expect(problem.getSolution()).to.include('exits non-zero'); }); diff --git a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js index a4b43301e78..97b966e324f 100644 --- a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js @@ -373,7 +373,7 @@ describe('gatewayCertificateTaskFactory', () => { const { header } = enquirer.options[0]; expect(header).to.not.match(/clients (are|were|could not|cannot|unable)/i); - expect(header).to.contain('nothing changes'); + expect(header).to.contain('Declining changes nothing'); }); // The same wording is correct on the failing path, where the certificate @@ -390,7 +390,7 @@ describe('gatewayCertificateTaskFactory', () => { // never the chain to a public root. const { header } = enquirer.options[0]; - expect(header).to.contain('still failing the checks'); + expect(header).to.contain('failing the checks above'); expect(header).to.not.match(/client (will|would|does not|will not)? ?(accept|reject)/i); expect(header).to.not.match(/clients (are|were|could not|cannot|unable)/i); }); @@ -442,7 +442,7 @@ describe('gatewayCertificateTaskFactory', () => { expect(errors).to.be.empty(); expect(context.certificateWarnings).to.be.undefined(); - expect(context.certificateSuccess).to.contain('LEAVE PORT 80 OPEN'); + expect(context.certificateSuccess).to.contain('reachable from the internet permanently'); }); // Nothing was touched, so nothing regressed - and a certificate that diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js index add3a1ccc8a..081483101dc 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js @@ -376,8 +376,8 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { it('should tell the operator port 80 stays open once a certificate is issued', async function it() { const output = await render(this.sinon, buildTask(this.sinon)(config)); - expect(output).to.contain('LEAVE PORT 80 OPEN'); - expect(output).to.contain('survives a reboot'); + expect(output).to.contain('reachable from the internet permanently'); + expect(output).to.contain('for certificate reissue'); }); // Issuance is recorded before the pair is written, and the notice with it, @@ -391,7 +391,7 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { const output = await render(this.sinon, buildTask(this.sinon, { save })(config)); - expect(output).to.contain('LEAVE PORT 80 OPEN'); + expect(output).to.contain('reachable from the internet permanently'); }); it('should tell them when lego wrote nothing it could find', async function it() { @@ -401,7 +401,7 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { legoCertPathOverride: '/nonexistent', }); - expect(output).to.contain('LEAVE PORT 80 OPEN'); + expect(output).to.contain('reachable from the internet permanently'); }); // No new node will have an email: nothing prompts for one any more. A @@ -576,7 +576,7 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { // actually happened, and the port conflict is offered as a possible // cause rather than asserted as the cause. expect(error.message).to.contain('address already in use'); - expect(error.message).to.contain('holding port 80'); + expect(error.message).to.contain('already using port 80'); // Docker rejected the start, which does not settle whether the helper // ran: a bind conflict and a lost reply look the same from here. So // nothing is claimed about this node's allowance in either direction. @@ -624,7 +624,7 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { expect(error.message).to.not.match(/install what was already issued/i); // Printed once, by the command, not also here. - expect(error.message).to.not.contain('LEAVE PORT 80 OPEN'); + expect(error.message).to.not.contain('reachable from the internet permanently'); expect(error.message).to.not.match(/paused/i); expect(error.message).to.not.contain('failed attempts are shared'); expect(error.message).to.not.match(/did not obtain a certificate after/i); @@ -711,8 +711,8 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { const error = await buildFailingTask(this.sinon, docker)(config) .run({ force: true }).catch((e) => e); - expect(error.message).to.match(/may have\s+paused\s+it/i); - expect(error.message).to.contain('uses up that allowance'); + expect(error.message).to.match(/Let's Encrypt limits how often/); + expect(error.message).to.contain('Do not keep retrying'); }); // lego fails for reasons that have nothing to do with the firewall - a rate @@ -755,10 +755,10 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { const error = await tasks.run({ force: true, interactive: true }).catch((e) => e); - expect(error.message).to.contain('https://letsencrypt.org/docs/rate-limits/'); - expect(error.message).to.match(/may have\s+paused\s+it/i); + expect(error.message).to.contain('doctor report'); + expect(error.message).to.match(/Let's Encrypt limits how often/); expect(error.message).to.contain(`--config ${config.getName()}`); - expect(error.message).to.contain('renewals dashmate runs for you'); + expect(error.message).to.contain('Do not keep retrying'); expect(error.message).to.not.match(/come back in \d/i); }); diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index bad3c9983ee..ac8cdac7a82 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -64,7 +64,9 @@ describe('renderCertificateGuidance', () => { .filter((command) => command.split(' ').length > 2), ]; - expect(commands).to.have.length.greaterThan(4); + // The point is that every command carries the config, not how many there + // are - the text has deliberately got shorter. + expect(commands).to.have.length.greaterThan(2); commands.forEach((command) => { expect(command, command).to.contain('--config testnet_2'); }); @@ -192,7 +194,7 @@ describe('renderCertificateGuidance', () => { const output = render(); - expect(output).to.contain('already set to use Let\'s Encrypt'); + expect(output).to.contain('already uses Let\'s Encrypt'); expect(output).to.contain('THE FIX - obtain a new certificate'); expect(output).to.not.contain('THE FIX - switch to'); @@ -214,8 +216,7 @@ describe('renderCertificateGuidance', () => { const occurrences = (needle) => output.split(needle).length - 1; - expect(occurrences('issued certificates on the same day')).to.equal(1); - expect(occurrences('PORT 80 MUST STAY OPEN PERMANENTLY')).to.equal(1); + expect(occurrences('reachable from the internet permanently')).to.equal(1); }); }); @@ -232,11 +233,13 @@ describe('renderCertificateGuidance', () => { }); // What the evidence does support: one operator, one day, three nodes. - expect(render()).to.contain('issued certificates on the same day'); + expect(render()).to.contain('reachable from the internet permanently'); }); - it('should reassure that the update itself broke nothing', () => { - expect(render()).to.contain('Nothing broke just now.'); + // Reassurance the operator did not ask for is padding, but the failed-attempt + // note is not reassurance - it says what may have changed. + it('should say nothing about the update having broken anything', () => { + expect(render()).to.not.match(/nothing broke/i); }); // The promise of a future release that refuses to start reads, to a @@ -244,8 +247,7 @@ describe('renderCertificateGuidance', () => { it('should not promise a future release that blocks start', () => { const output = render(); - expect(output).to.contain('This release does not block `dashmate start`'); - expect(output).to.not.match(/future version|will not allow/i); + expect(output).to.not.match(/future version|will not allow|4\.3/i); }); // The documented upgrade procedure stops the node before update runs, so most @@ -254,7 +256,7 @@ describe('renderCertificateGuidance', () => { it('should lead with node state when the node is stopped', () => { const output = render({ isNodeRunning: false }); - expect(output).to.contain('Your node is currently stopped'); + expect(output).to.contain('Your node is stopped'); expect(output).to.contain('dashmate start --config base'); }); @@ -264,9 +266,9 @@ describe('renderCertificateGuidance', () => { it('should ask for nothing further when the node is running', () => { const output = render({ isNodeRunning: true }); - expect(output).to.not.contain('Your node is currently stopped'); + expect(output).to.not.contain('Your node is stopped'); expect(output).to.not.contain('dashmate restart --config base'); - expect(output).to.contain('needs nothing further - no restart'); + expect(output).to.not.contain('dashmate start --config base'); }); // An obtain that failed can have failed anywhere, including between writing @@ -277,19 +279,18 @@ describe('renderCertificateGuidance', () => { it('should not claim nothing changed', () => { const output = render({ obtainAttemptFailed: true }); - expect(output).to.not.contain('Nothing broke just now'); - expect(output).to.contain('did not complete'); + expect(output).to.contain('may have changed'); }); it('should not promise the node will start', () => { const output = render({ obtainAttemptFailed: true, isNodeRunning: false }); - expect(output).to.not.contain('does not prevent it from starting'); + expect(output).to.contain('may have changed'); }); - it('should still say nothing changed when no attempt was made', () => { - expect(render()).to.contain('Nothing broke just now'); - expect(render({ isNodeRunning: false })).to.contain('does not prevent it from starting'); + it('should say nothing about a change when no attempt was made', () => { + expect(render()).to.not.match(/may have changed/); + expect(render({ isNodeRunning: false })).to.contain('does not prevent it starting'); }); }); @@ -301,14 +302,14 @@ describe('renderCertificateGuidance', () => { it('should not say the node is stopped', () => { const output = render({ isNodeRunning: null }); - expect(output).to.not.contain('Your node is currently stopped'); + expect(output).to.not.contain('Your node is stopped'); expect(output).to.not.contain('dashmate start --config base'); }); it('should not claim a running node needs nothing further either', () => { const output = render({ isNodeRunning: null }); - expect(output).to.not.contain('needs nothing further - no restart'); + expect(output).to.not.contain('dashmate start --config base'); }); it('should still give the operator the fix', () => { @@ -320,8 +321,8 @@ describe('renderCertificateGuidance', () => { expect(render({ pull: { ok: true, failed: 2, total: 7 } })) .to.contain('2 of 7 failed'); expect(render({ pull: { ok: false, failed: 0, total: 0 } })) - .to.contain('could not pull images'); - expect(render()).to.contain('This run pulled images, then stopped'); + .to.contain('Images could not be pulled'); + expect(render()).to.contain('Images pulled.'); }); // The read-only preflight starts no pull at all, so it must not say anything @@ -330,10 +331,10 @@ describe('renderCertificateGuidance', () => { it('should say nothing about images when no pull was attempted', () => { const output = render({ pull: null }); - expect(output).to.not.contain('could not pull images'); + expect(output).to.not.contain('Images could not be pulled'); expect(output).to.not.contain('pulled images'); - expect(output).to.not.contain('This run pulled'); - expect(output).to.contain("This node's installed TLS certificate did not pass"); + expect(output).to.not.contain('Images pulled'); + expect(output).to.contain("This node's TLS certificate did not pass"); }); // The operator's own situation, with no claim about what other providers @@ -341,7 +342,7 @@ describe('renderCertificateGuidance', () => { it('should explain the ZeroSSL wall in terms of this node', () => { const output = render(); - expect(output).to.contain('three\n certificates in total'); + expect(output).to.contain('three certificates in\n total'); expect(output).to.not.match(/four out of five|as of August/); expect(output).to.not.match(/does not issue certificates for IP addresses/); }); @@ -355,7 +356,6 @@ describe('renderCertificateGuidance', () => { const output = render({ verdict: verdict({ provider: 'letsencrypt' }) }); expect(output).to.contain('Inbound port 80 is the most common cause'); - expect(output).to.contain('It is not always port 80'); // No claim about what any other authority does or does not issue, and no // narration about what dashmate has or has not looked at. @@ -366,8 +366,8 @@ describe('renderCertificateGuidance', () => { it('should state that port 80 is permanent, never periodic', () => { const output = render(); - expect(output).to.contain('PORT 80 MUST STAY OPEN PERMANENTLY'); - expect(output).to.contain('goes dark within six days'); + expect(output).to.contain('reachable from the internet permanently'); + expect(output).to.contain('Nothing will warn you if it lapses'); expect(output).to.not.match(/every few days when the certificate renews/i); }); @@ -411,6 +411,6 @@ describe('renderCertificateGuidance', () => { const output = render(); expect(output).to.contain('dashmate update --config base --skip-certificate-check'); - expect(output).to.contain('not a line to add to a playbook'); + expect(output).to.contain('--skip-certificate-check'); }); }); From a564f0e27f6d29573b4674d0b45d03264d43e99d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 22 Aug 2026 18:28:49 +0700 Subject: [PATCH 53/63] refactor(dashmate): stop reporting a missing contact address as a problem A doctor report is read to find problems. An entry whose own solution says nothing needs to be done is noise in that list, and it was listed at LOW severity precisely because it is not a fault. It was also unreachable. Nothing has raised EMAIL_IS_NOT_SET since the email check stopped running ahead of every other one - a node without a contact address is judged on its certificate now, which is what the validator's own test pins. So the entry described a state the code could no longer produce, and the constant had no producer either. Both are gone. An unrecognised code already falls through to no problem at all, so a report archived by an older dashmate that did record this still analyses cleanly rather than failing on a code this build no longer knows. The replacement test drives exactly that case. Co-Authored-By: Claude Opus 5 --- .../doctor/analyse/analyseConfigFactory.js | 10 --------- .../validateLetsEncryptCertificateFactory.js | 1 - .../analyse/analyseConfigFactory.spec.js | 22 +++++++++---------- ...idateLetsEncryptCertificateFactory.spec.js | 8 +++---- 4 files changed, 13 insertions(+), 28 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js index 603e5b4266a..d716af62442 100644 --- a/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js @@ -155,16 +155,6 @@ ${LETSENCRYPT_ALTERNATIVE}`, }; const letsEncryptProblems = { - [LETSENCRYPT_ERRORS.EMAIL_IS_NOT_SET]: { - // A contact address is optional under RFC 8555, Let's Encrypt - // stopped sending expiry notifications in 2025, and nothing in - // dashmate asks for one. Worth knowing, not worth fixing. - severity: SEVERITY.LOW, - description: 'No contact is registered with the certificate authority.', - solution: chalk`Nothing needs to be done. If you would like one on file: -{bold.cyanBright dashmate config set platform.gateway.ssl.providerConfigs.letsencrypt.email [EMAIL]} -Note that changing it makes renewal register a new account with the authority.`, - }, [LETSENCRYPT_ERRORS.EXTERNAL_IP_IS_NOT_SET]: { description: 'External IP is not set.', solution: chalk`Please update your configuration to include your external IP using {bold.cyanBright dashmate config set externalIp [IP]}`, diff --git a/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js index e525f96044e..a26d5f8d433 100644 --- a/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js +++ b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js @@ -5,7 +5,6 @@ import LegoCertificate from './LegoCertificate.js'; import isCertificatePairInstalled from './isCertificatePairInstalled.js'; export const ERRORS = { - EMAIL_IS_NOT_SET: 'EMAIL_IS_NOT_SET', EXTERNAL_IP_IS_NOT_SET: 'EXTERNAL_IP_IS_NOT_SET', CERTIFICATE_NOT_FOUND: 'CERTIFICATE_NOT_FOUND', PRIVATE_KEY_NOT_FOUND: 'PRIVATE_KEY_NOT_FOUND', diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js index 37646a9ca45..a6d4d9e85e7 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js @@ -50,19 +50,17 @@ describe('analyseConfigFactory', () => { expect(problems[0].getSeverity()).to.equal(SEVERITY.HIGH); }); - // Nothing prompts for a contact address any more, so a node without one is - // ordinary rather than broken. The check is kept rather than deleted so the - // information stays available - and so it is still there if contactless - // issuance ever stops working - but it must not read as an error. - it('should report a missing contact address as information, not a fault', () => { - const problems = analyseSslSample({ - error: LETSENCRYPT_ERRORS.EMAIL_IS_NOT_SET, - data: {}, - }, 'letsencrypt'); + // A contact address is optional and nothing prompts for one, so a node + // without one has no problem to report. A doctor report is read to find + // problems, and an entry saying "this is not a problem" is noise in it. + // + // Driven with the code an older dashmate could have recorded in an archive, + // because doctor analyses those too: it must report nothing rather than + // fail on a code it no longer knows. + it('should report nothing for a node with no contact address', () => { + const problems = analyseSslSample({ error: 'EMAIL_IS_NOT_SET', data: {} }, 'letsencrypt'); - expect(problems).to.have.lengthOf(1); - expect(problems[0].getSeverity()).to.equal(SEVERITY.LOW); - expect(problems[0].getDescription()).to.include('No contact is registered'); + expect(problems).to.be.empty(); }); // This fires when the issued certificate was never copied to where the gateway diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js index c392f27526c..5be38dfb801 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js @@ -42,11 +42,9 @@ describe('validateLetsEncryptCertificateFactory', () => { expect(ERRORS.CERTIFICATE_NOT_INSTALLED).to.equal('CERTIFICATE_NOT_INSTALLED'); }); - // The email check used to fire before every other one, so a node without a - // contact address reported EMAIL_IS_NOT_SET whatever else was wrong with its - // certificate. Nothing prompts for an address any more, so no new node has - // one and this would have become the answer for all of them - including for - // the helper's own renewal scheduler. + // A contact address is optional and nothing prompts for one, so no new node + // has one. Its absence must not become the answer for every node - including + // for the helper's own renewal scheduler - so it is not judged at all. it('should judge a certificate for a node that has no contact address', async function it() { config.get.callsFake((option) => ({ 'platform.gateway.ssl.providerConfigs.letsencrypt.email': null, From 52efd332c861ea0ef201b744392a7ad5c92740d3 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 22 Aug 2026 18:28:49 +0700 Subject: [PATCH 54/63] feat(dashmate): warn that a certificate will become required to start a node An operator who has run out of attempts is deciding whether to keep going or leave it. Knowing this stops being survivable is what makes that decision, so it is said there and nowhere else - not on success, not on every path. No version is named. Which release changes this is not something this code establishes, and a version printed to every stuck operator is wrong the moment the plan moves; the warning holds without one. A test fails if a version number appears anywhere in that guidance. One line, no elaboration. Co-Authored-By: Claude Opus 5 --- .../obtainLetsEncryptCertificateTaskFactory.js | 2 ++ ...btainLetsEncryptCertificateTaskFactory.spec.js | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index f8bab76dfe0..e853c2b47ff 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -51,6 +51,8 @@ function renderGiveUpGuidance(config, attempts) { return `No certificate after ${attempts} attempt${attempts === 1 ? '' : 's'}. +In upcoming versions dashmate will not start a node without a valid certificate. + Do not keep retrying - Let's Encrypt limits how often this node may fail. Open inbound port 80, on the machine's firewall and your hosting provider's. diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js index 081483101dc..ae9c73321e8 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.spec.js @@ -703,6 +703,21 @@ describe('obtainLetsEncryptCertificateTaskFactory', () => { expect(error.message).to.not.match(/paused/i); }); + // An operator who has run out of attempts needs to know this will stop + // being survivable. No version number: the release plan is not something + // this code can establish, and a wrong one printed to every stuck operator + // is worse than none. + it('should say a certificate will become required, without naming a version', async function it() { + const docker = getFailingDockerMock(this.sinon); + + const error = await buildFailingTask(this.sinon, docker)(config) + .run({ force: true }).catch((e) => e); + + expect(error.message).to.contain('In upcoming versions'); + expect(error.message).to.contain('will not start a node without a valid certificate'); + expect(error.message).to.not.match(/\b\d+\.\d+(\.\d+)?\b/); + }); + // A failure the authority did return keeps the guidance that is about the // authority. it('should keep the rate-limit guidance when the request did reach the authority', async function it() { From 3aa7cdd169d3017c668ebccf5355a1e39efe644e Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sun, 23 Aug 2026 13:59:05 +0700 Subject: [PATCH 55/63] fix(dashmate): print the repair command that actually repairs The interactive path replaced a certificate the checks rejected on its own contents - wrong address, not valid yet - because reinstalling the archived copy hands back the rejected copy. The printed remediation did not. So an unattended run, or an operator who declined the prompt, was given a command that reuses the certificate that just failed and leaves the node exactly as it was. The rule now lives in one place, next to the reasons it is made of, and both the interactive repair and the printed command ask it. It was duplicated prose in one and absent from the other, which is how they came to disagree. Also fixes the disagreement underneath. The reuse check called a certificate usable on expiry alone, so one whose validity had not started yet counted as good while the gateway checks rejected it - the two disagreeing about what is usable is what made --force necessary to paper over. It now checks both ends of the window. There is one caller, the start date was already parsed, and Let's Encrypt backdates issuance, which the Pebble run confirms. The test certificate helper anchored its start date to the expiry, which put that date in the future for anything valid longer than the window itself. Real certificates start in the past; this one now does too. Left alone: the reuse check still matches an address by common name when there is no IP subject alternative name, which the gateway checks do not. That is a second disagreement and a wider change - it affects the helper's own renewal path - so it is not made here. Tests: 4 new, 2 red before this commit. Co-Authored-By: Claude Opus 5 --- .../update/gatewayCertificateTaskFactory.js | 18 +++---------- .../src/ssl/checkGatewayCertificateFactory.js | 19 ++++++++++++++ .../src/ssl/letsencrypt/LegoCertificate.js | 9 ++++++- .../src/ssl/renderCertificateGuidance.js | 9 ++++--- .../src/test/createCertificateForTest.js | 6 ++++- ...idateLetsEncryptCertificateFactory.spec.js | 26 +++++++++++++++++++ .../ssl/renderCertificateGuidance.spec.js | 24 +++++++++++++++++ 7 files changed, 91 insertions(+), 20 deletions(-) diff --git a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js index 9444661fc45..b9d0513fb80 100644 --- a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js @@ -5,6 +5,7 @@ import { CERTIFICATE_REASONS, CERTIFICATE_STATUS, describeStatus, + requiresReplacement, } from '../../../ssl/checkGatewayCertificateFactory.js'; import promptOrThrow from '../../../util/promptOrThrow.js'; import renderConfigFlag from '../../../util/renderConfigFlag.js'; @@ -243,21 +244,10 @@ export default function gatewayCertificateTaskFactory( * @return {Promise} */ async function switchToLetsEncrypt(ctx, verdict) { - // Reuse is the default because it saves an issuance from a limited - // allowance, but the reuse check is weaker than these checks: it never - // looks at the address, and it asks only whether the certificate has - // expired, not whether it has started yet. For those two faults it would - // hand back the same certificate that was just rejected, so a new one is - // obtained instead. - // - // Every other fault is in the copy installed for the gateway rather than - // in the certificate itself, and reinstalling can fix it without - // spending anything. - const mustReplace = [CERTIFICATE_REASONS.IP_MISMATCH, CERTIFICATE_REASONS.NOT_YET_VALID] - .some((code) => hasReason(verdict ?? { reasons: [] }, code)); - + // Shared with the printed remediation, so the command an operator copies + // does what this path does. return attemptObtain(ctx, () => obtainLetsEncryptCertificateTask(config) - .run({ ...ctx, interactive, force: ctx.force || mustReplace })); + .run({ ...ctx, interactive, force: ctx.force || requiresReplacement(verdict) })); } return async (ctx, task) => { diff --git a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js index a6e3e8cb452..f3b1fdf9048 100644 --- a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js +++ b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js @@ -60,6 +60,25 @@ const DAY_MS = 24 * 60 * 60 * 1000; * helper renews it, so anything further out is a window renewal clears by * itself. */ +/** + * Whether a repair has to obtain a new certificate rather than reinstall this one. + * + * The reuse check applied when obtaining is weaker than these checks: it does + * not look at the address at all, and it asks only whether the certificate has + * expired. For the two faults it cannot see it would hand back the certificate + * that was just rejected, so a repair has to replace it. + * + * Every other fault is in the copy installed for the gateway rather than in the + * certificate itself, and reinstalling fixes it without spending an issuance. + * + * @param {Object} verdict + * @return {boolean} + */ +export function requiresReplacement(verdict) { + return (verdict?.reasons ?? []).some(({ code }) => code === CERTIFICATE_REASONS.IP_MISMATCH + || code === CERTIFICATE_REASONS.NOT_YET_VALID); +} + const EXPIRING_SOON_DAYS = 1; /** diff --git a/packages/dashmate/src/ssl/letsencrypt/LegoCertificate.js b/packages/dashmate/src/ssl/letsencrypt/LegoCertificate.js index fb4d2c88763..967fa352b73 100644 --- a/packages/dashmate/src/ssl/letsencrypt/LegoCertificate.js +++ b/packages/dashmate/src/ssl/letsencrypt/LegoCertificate.js @@ -120,6 +120,13 @@ export default class LegoCertificate { * @returns {boolean} */ isValid() { - return new Date(this.expires).getTime() > Date.now(); + const now = Date.now(); + + // Both ends of the window. A certificate whose validity has not started is + // no more servable than an expired one, and the gateway checks reject it - + // so judging it usable here would hand a rejected certificate back to a + // repair meant to replace it. + return new Date(this.created).getTime() <= now + && new Date(this.expires).getTime() > now; } } diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index 409e0f9a29b..c1dbd8d6169 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -1,6 +1,6 @@ import { SSL_PROVIDERS } from '../constants.js'; import renderConfigFlag from '../util/renderConfigFlag.js'; -import { CERTIFICATE_REASONS } from './checkGatewayCertificateFactory.js'; +import { CERTIFICATE_REASONS, requiresReplacement } from './checkGatewayCertificateFactory.js'; /** * How the run went for the images, said only as far as it was observed. @@ -110,9 +110,10 @@ function renderLetsEncryptDiagnosis(cfg) { /** * @param {string} cfg * @param {boolean} isAlreadyLetsEncrypt + * @param {Object} verdict - decides whether the certificate can be reinstated * @return {string} */ -function renderFix(cfg, isAlreadyLetsEncrypt) { +function renderFix(cfg, isAlreadyLetsEncrypt, verdict) { // A node already on Let's Encrypt has nothing to switch to, so the heading // that offers a switch would contradict the diagnosis above it. const heading = isAlreadyLetsEncrypt @@ -127,7 +128,7 @@ function renderFix(cfg, isAlreadyLetsEncrypt) { Then: - dashmate ssl obtain ${cfg} --provider letsencrypt + dashmate ssl obtain ${cfg} --provider letsencrypt${requiresReplacement(verdict) ? ' --force' : ''} `; } @@ -201,7 +202,7 @@ ${obtainAttemptFailed blocks.push(renderLetsEncryptDiagnosis(cfg)); } - blocks.push(renderFix(cfg, provider === SSL_PROVIDERS.LETSENCRYPT)); + blocks.push(renderFix(cfg, provider === SSL_PROVIDERS.LETSENCRYPT, verdict)); blocks.push(renderPortEightyPermanence()); blocks.push(` Cannot open port 80? There is no other way to get an IP-address diff --git a/packages/dashmate/src/test/createCertificateForTest.js b/packages/dashmate/src/test/createCertificateForTest.js index ec78288605a..577b1e44b3b 100644 --- a/packages/dashmate/src/test/createCertificateForTest.js +++ b/packages/dashmate/src/test/createCertificateForTest.js @@ -22,8 +22,12 @@ export default function createCertificateForTest({ ip = '127.0.0.1', days = 30 } // 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); + // Anchored to whichever of now and the expiry comes first, so the window + // always starts in the past - as a real certificate's does. Anchoring to the + // expiry alone put the start date in the future for anything valid longer + // than the window itself. certificate.validity.notBefore = new Date( - certificate.validity.notAfter.getTime() - 30 * 24 * 60 * 60 * 1000, + Math.min(Date.now(), certificate.validity.notAfter.getTime()) - 30 * 24 * 60 * 60 * 1000, ); const attributes = [{ name: 'commonName', value: ip }]; diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js index 5be38dfb801..d3ac280b35c 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js @@ -2,6 +2,7 @@ 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 LegoCertificate from '../../../../src/ssl/letsencrypt/LegoCertificate.js'; import validateLetsEncryptCertificateFactory, { ERRORS } from '../../../../src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js'; const EXTERNAL_IP = '198.51.100.7'; @@ -38,6 +39,31 @@ describe('validateLetsEncryptCertificateFactory', () => { afterEach(() => homeDir.remove()); + // A certificate whose validity has not started is not servable, and the + // gateway checks reject it. Judging it usable here would hand the rejected + // certificate back to a repair that was meant to replace it. + it('should not treat a certificate that is not valid yet as usable', () => { + const notYetValid = new LegoCertificate({ + expires: new Date(Date.now() + 30 * 864e5), + created: new Date(Date.now() + 864e5), + commonName: EXTERNAL_IP, + ipAddresses: [EXTERNAL_IP], + }); + + expect(notYetValid.isValid()).to.be.false(); + }); + + it('should treat a certificate already inside its window as usable', () => { + const current = new LegoCertificate({ + expires: new Date(Date.now() + 30 * 864e5), + created: new Date(Date.now() - 864e5), + commonName: EXTERNAL_IP, + ipAddresses: [EXTERNAL_IP], + }); + + expect(current.isValid()).to.be.true(); + }); + it('should expose the not-installed error so callers can match on it', () => { expect(ERRORS.CERTIFICATE_NOT_INSTALLED).to.equal('CERTIFICATE_NOT_INSTALLED'); }); diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index ac8cdac7a82..4fb96c98740 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -72,6 +72,30 @@ describe('renderCertificateGuidance', () => { }); }); + // The printed command has to do what the interactive repair does. For a + // certificate the checks rejected on its own contents - wrong address, not + // valid yet - the archived copy is the rejected copy, and obtaining without + // --force hands it straight back. An operator following this would run it + // and find nothing changed. + it('should force replacement for a certificate that cannot be reinstated', () => { + [CERTIFICATE_REASONS.NOT_YET_VALID, CERTIFICATE_REASONS.IP_MISMATCH].forEach((code) => { + const output = render({ + verdict: verdict({ reasons: [{ code, message: `rejected: ${code}` }] }), + }); + + expect(output, code).to.contain('dashmate ssl obtain --config base --provider letsencrypt --force'); + }); + }); + + // Every other fault is in the copy installed for the gateway rather than in + // the certificate, so reinstalling fixes it without spending an issuance. + it('should not force replacement for a fault reinstalling can fix', () => { + const output = render(); + + expect(output).to.contain('dashmate ssl obtain --config base --provider letsencrypt'); + expect(output).to.not.contain('--force'); + }); + it('should shell-quote a config name that needs it', function it() { this.sinon.stub(config, 'getName').returns('my node'); From 50b1dd02235c14a001a162790b9f207e63c06df5 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sun, 23 Aug 2026 21:25:54 +0700 Subject: [PATCH 56/63] fix(dashmate): judge a certificate the same way wherever it is judged Three faults with one shape: a second opinion about the same certificate. The doctor diagnosed the installed certificate on every network. `update` enforces on mainnet and testnet only, so a local node - which serves a self-signed certificate by design - was told it had a high-severity problem, that `dashmate update` would fail, and to obtain a publicly issued certificate for an address no authority can reach. The list of enforced networks now lives beside the check itself and both read it. The doctor's remedy was the same unforced `ssl obtain` for every fault. An address the certificate does not carry, or a start date still ahead, cannot be repaired by reinstalling the same file; the shared rule that decides this is now applied here too rather than restated. Reuse accepted an address carried only in the common name. No standards compliant client reads a common name to verify an IP, and the gateway check does not either, so the certificate it rejected was handed straight back to the repair meant to replace it - which is what made an unforced command a no-op rather than a wrong flag. Co-Authored-By: Claude Opus 5 --- .../analyseGatewayCertificateFactory.js | 17 +++++++++- .../src/ssl/checkGatewayCertificateFactory.js | 13 +++++++- .../validateLetsEncryptCertificateFactory.js | 16 +++++---- .../src/test/createCertificateForTest.js | 7 ++-- .../analyseGatewayCertificateFactory.spec.js | 33 +++++++++++++++++++ ...idateLetsEncryptCertificateFactory.spec.js | 17 ++++++++++ 6 files changed, 92 insertions(+), 11 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 4f5d7e4ecdc..f35ca70334f 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { SEVERITY } from '../Prescription.js'; import Problem from '../Problem.js'; import renderConfigFlag from '../../util/renderConfigFlag.js'; +import { GATED_NETWORKS, requiresReplacement } from '../../ssl/checkGatewayCertificateFactory.js'; /** * The manual obtain command writes certificate files but does not signal the gateway, so an @@ -95,6 +96,14 @@ export default function analyseGatewayCertificateFactory() { return []; } + // `update` enforces on these networks and only these. A local or devnet + // node serves a self-signed certificate by design, so diagnosing one here + // would report a healthy node as broken and prescribe a certificate no + // authority can issue for an address it cannot reach. + if (!GATED_NETWORKS.includes(config.get('network'))) { + return []; + } + const cfg = renderConfigFlag(config.getName()); const problems = []; @@ -107,13 +116,19 @@ export default function analyseGatewayCertificateFactory() { const installed = samples.getServiceInfo('gateway', 'installedCertificate'); if (installed) { + // Reinstalling cannot fix an address the certificate does not carry, or a + // start date still in the future, and the reuse check is weaker than the + // one that rejected it - so without this the command hands back the same + // certificate and the operator is where they started. + const force = requiresReplacement(installed) ? ' --force' : ''; + installed.reasons.forEach(({ message }) => { problems.push(new Problem( message, chalk`${UPDATE_CONSEQUENCE} Obtain a new certificate. No restart needed: -{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt${force}}`, SEVERITY.HIGH, )); }); diff --git a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js index f3b1fdf9048..46a52e4010f 100644 --- a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js +++ b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { SSL_PROVIDERS } from '../constants.js'; +import { NETWORK_MAINNET, NETWORK_TESTNET, SSL_PROVIDERS } from '../constants.js'; import { parseIpAddresses } from './readCertificateBundle.js'; import isCertificatePairInstalled from './letsencrypt/isCertificatePairInstalled.js'; import selectLeafCertificate, { LEAF_SELECTION_ERRORS } from './selectLeafCertificate.js'; @@ -74,6 +74,17 @@ const DAY_MS = 24 * 60 * 60 * 1000; * @param {Object} verdict * @return {boolean} */ +/** + * The networks where a certificate is enforced. + * + * A local or devnet node is expected to serve a self-signed certificate: no + * authority issues for an address that is not reachable, and nothing about + * such a node is held back by the certificate it serves. Every consumer of + * this module - the update gate and the doctor alike - reads the same list, so + * a diagnosis cannot disagree with what enforcement actually does. + */ +export const GATED_NETWORKS = [NETWORK_MAINNET, NETWORK_TESTNET]; + export function requiresReplacement(verdict) { return (verdict?.reasons ?? []).some(({ code }) => code === CERTIFICATE_REASONS.IP_MISMATCH || code === CERTIFICATE_REASONS.NOT_YET_VALID); diff --git a/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js index a26d5f8d433..536c33198bc 100644 --- a/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js +++ b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js @@ -99,13 +99,15 @@ export default function validateLetsEncryptCertificateFactory(homeDir) { data.isExpiresSoon = data.certificate.isExpiredInDays(expirationDays); data.expirationDays = expirationDays; - // Check if certificate IP matches external IP - // First check SANs (preferred for IP certificates with --disable-cn) - // Fall back to commonName if no IP SANs present - const certIpAddresses = data.certificate.ipAddresses; - const hasMatchingIp = certIpAddresses.length > 0 - ? certIpAddresses.includes(data.externalIp) - : data.certificate.commonName === data.externalIp; + // The address has to be in a subject alternative name. No standards-compliant + // client reads a common name to verify an IP - Node's tls.checkServerIdentity + // does not, and neither do browsers - so a certificate carrying the address + // there and nowhere else is not usable, however well it matches. + // + // The gateway check judges it the same way. Accepting more here than that + // check accepts is what let a repair reuse the certificate it had just + // rejected, leaving the operator exactly where they started. + const hasMatchingIp = data.certificate.ipAddresses.includes(data.externalIp); if (!hasMatchingIp) { return { diff --git a/packages/dashmate/src/test/createCertificateForTest.js b/packages/dashmate/src/test/createCertificateForTest.js index 577b1e44b3b..fbb7ecbaa6b 100644 --- a/packages/dashmate/src/test/createCertificateForTest.js +++ b/packages/dashmate/src/test/createCertificateForTest.js @@ -10,10 +10,11 @@ import forge from 'node-forge'; * * @param {Object} [options] * @param {string} [options.ip] - placed in the subject alternative name and common name + * @param {boolean} [options.withIpSan] - false to leave the address in the common name only * @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 } = {}) { +export default function createCertificateForTest({ ip = '127.0.0.1', days = 30, withIpSan = true } = {}) { const keys = forge.pki.rsa.generateKeyPair(2048); const certificate = forge.pki.createCertificate(); @@ -37,7 +38,9 @@ export default function createCertificateForTest({ ip = '127.0.0.1', days = 30 } 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 }] }, + // A certificate without one carries the address in its common name only, which no + // standards-compliant client accepts for an IP - the shape this exists to test. + ...(withIpSan ? [{ name: 'subjectAltName', altNames: [{ type: 7, ip }] }] : []), ]); certificate.sign(keys.privateKey, forge.md.sha256.create()); diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index 2c9815717ca..f56d1c54ace 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -73,6 +73,39 @@ describe('analyseGatewayCertificateFactory', () => { analyseGatewayCertificate = analyseGatewayCertificateFactory(); }); + it('should report nothing on a network where update does not enforce', () => { + // A local node uses a self-signed certificate by design. Reporting it as a + // problem tells an operator their healthy node is broken, and prescribes a + // publicly-issued certificate for an address no authority can reach. + config.set('network', 'local'); + config.set('platform.gateway.ssl.provider', 'self-signed'); + + samples.setServiceInfo('gateway', 'installedCertificate', { + status: 'INVALID', + reasons: [{ code: 'SELF_SIGNED', message: 'The certificate is self-signed.' }], + warnings: [], + fingerprint256: 'AA:BB', + }); + + expect(analyseGatewayCertificate(samples)).to.be.empty(); + }); + + it('should replace rather than reinstall a certificate issued for another address', () => { + // Reinstalling cannot fix an address the certificate does not carry, and + // the reuse check is weaker than this one, so an unforced command can hand + // back the same rejected certificate. + samples.setServiceInfo('gateway', 'installedCertificate', { + status: 'INVALID', + reasons: [{ code: 'IP_MISMATCH', message: 'The certificate is not valid for this address.' }], + warnings: [], + fingerprint256: 'AA:BB', + }); + + const [problem] = analyseGatewayCertificate(samples); + + expect(problem.getSolution()).to.include('--force'); + }); + it('should report no problem for a healthy certificate', () => { expect(analyse(served())).to.be.empty(); }); diff --git a/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js index d3ac280b35c..a3f969558b6 100644 --- a/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/letsencrypt/validateLetsEncryptCertificateFactory.spec.js @@ -64,6 +64,23 @@ describe('validateLetsEncryptCertificateFactory', () => { expect(current.isValid()).to.be.true(); }); + // The gateway check requires the address in a subject alternative name, + // because no standards-compliant client reads a common name for an IP. This + // check deciding otherwise is what let a repair hand back the certificate the + // gateway had already rejected. + it('should not accept an address carried only in the common name', async () => { + const { cert, key } = createCertificateForTest({ ip: EXTERNAL_IP, days: 60, withIpSan: false }); + + fs.writeFileSync(path.join(legoDir, `${EXTERNAL_IP}.crt`), cert, 'utf8'); + fs.writeFileSync(path.join(legoDir, `${EXTERNAL_IP}.key`), key, 'utf8'); + fs.writeFileSync(path.join(sslDir, 'bundle.crt'), cert, 'utf8'); + fs.writeFileSync(path.join(sslDir, 'private.key'), key, 'utf8'); + + const { error } = await validateLetsEncryptCertificate(config); + + expect(error).to.equal(ERRORS.CERTIFICATE_IP_MISMATCH); + }); + it('should expose the not-installed error so callers can match on it', () => { expect(ERRORS.CERTIFICATE_NOT_INSTALLED).to.equal('CERTIFICATE_NOT_INSTALLED'); }); From 4346d05ba02fa16974c22bd7b24c5026553eab1d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 24 Aug 2026 14:48:06 +0700 Subject: [PATCH 57/63] refactor(dashmate): tell an operator what is wrong, not which check noticed The messages described dashmate's process rather than the operator's problem. A certificate "did not pass dashmate's checks" and `update` "exits non-zero"; both are true, and neither is what an evonode operator needs to know. What is wrong is that the certificate is not valid, and what they are deciding is whether their node is falling behind on software. It is not. The heading announcing itself in capitals is now a sentence. The doctor still states what the files show rather than what a client would do. A gateway can serve a sound certificate from memory while the copy on disk is stale, so the on-disk diagnosis has no standing to say clients are affected - the test that pins this survives, with the assertion narrowed to the claim it guards rather than the wording it happened to be written against. The served-versus-disk remedy hardcoded an unforced obtain while the remedy beside it derived one, so a mismatched address produced a forced command and a contradictory unforced one in the same report. Both now read a single decision taken once. Co-Authored-By: Claude Opus 5 --- .../analyseGatewayCertificateFactory.js | 22 ++++++++++--------- .../src/ssl/checkGatewayCertificateFactory.js | 4 ++-- .../src/ssl/renderCertificateGuidance.js | 14 +++++------- .../test/unit/commands/update.spec.js | 6 ++--- .../analyseGatewayCertificateFactory.spec.js | 5 ++--- .../ssl/renderCertificateGuidance.spec.js | 8 +++---- 6 files changed, 29 insertions(+), 30 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index f35ca70334f..a026bd6bc98 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -78,8 +78,8 @@ const restartHint = (cfg) => chalk`Then restart Platform so the gateway picks it * does, and only refuses to report success. Leaving this out lets a client * reachability problem be read as a software delivery one. */ -const UPDATE_CONSEQUENCE = 'The gateway certificate did not pass dashmate\'s checks.' - + ' `dashmate update` still pulls images, but exits non-zero until this is fixed.'; +const UPDATE_CONSEQUENCE = 'The certificate saved for the gateway is not usable.' + + ' Updates still work.'; export default function analyseGatewayCertificateFactory() { /** @@ -115,20 +115,22 @@ export default function analyseGatewayCertificateFactory() { // doctor on. const installed = samples.getServiceInfo('gateway', 'installedCertificate'); - if (installed) { - // Reinstalling cannot fix an address the certificate does not carry, or a - // start date still in the future, and the reuse check is weaker than the - // one that rejected it - so without this the command hands back the same - // certificate and the operator is where they started. - const force = requiresReplacement(installed) ? ' --force' : ''; + // Reinstalling cannot fix an address the certificate does not carry, or a + // start date still ahead, and the reuse check is weaker than the one that + // rejected it - so an unforced command hands the same certificate back. + // Every remedy in this analyser reads this one decision: printing a forced + // command beside an unforced one tells an operator two different things + // about the same certificate. + const installedForce = requiresReplacement(installed) ? ' --force' : ''; + if (installed) { installed.reasons.forEach(({ message }) => { problems.push(new Problem( message, chalk`${UPDATE_CONSEQUENCE} Obtain a new certificate. No restart needed: -{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt${force}}`, +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt${installedForce}}`, SEVERITY.HIGH, )); }); @@ -268,7 +270,7 @@ restarting will not help. Get a current one: + 'dashmate could not confirm the saved one is a working replacement', chalk`The certificate in use works. The saved one is not known to be a safe replacement, so do not restart to load it. Get a current one instead: -{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt${installedForce}}`, SEVERITY.HIGH, )); } diff --git a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js index 46a52e4010f..e2568dbbf39 100644 --- a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js +++ b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js @@ -29,10 +29,10 @@ export const CERTIFICATE_STATUS = { */ export function describeStatus(status) { if (status === CERTIFICATE_STATUS.CHECKS_PASSED) { - return 'passed'; + return 'is valid'; } - return status === CERTIFICATE_STATUS.WARN ? 'passed with warnings' : 'did not pass'; + return status === CERTIFICATE_STATUS.WARN ? 'is valid, with warnings' : 'is not valid'; } export const CERTIFICATE_REASONS = { diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index c1dbd8d6169..de9494312ce 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -18,20 +18,18 @@ import { CERTIFICATE_REASONS, requiresReplacement } from './checkGatewayCertific */ function renderOpening(pull) { if (pull === null || pull === undefined) { - return " This node's TLS certificate did not pass dashmate's checks."; + return " This node's TLS certificate is not valid."; } if (!pull.ok) { - return ` Images could not be pulled, and this node's TLS certificate did not pass - dashmate's checks.`; + return " Images could not be pulled, and this node's TLS certificate is not valid."; } const pulled = pull.failed > 0 ? `Images pulled, ${pull.failed} of ${pull.total} failed - see the table above.` : 'Images pulled.'; - return ` ${pulled} This node's TLS certificate did not pass - dashmate's checks.`; + return ` ${pulled} This node's TLS certificate is not valid.`; } /** @@ -41,7 +39,7 @@ function renderOpening(pull) { function renderObservation(verdict) { const [first] = verdict.reasons; - return first ? first.message : 'the installed certificate did not pass the checks'; + return first ? first.message : 'the installed certificate is not usable'; } /** @@ -117,8 +115,8 @@ function renderFix(cfg, isAlreadyLetsEncrypt, verdict) { // A node already on Let's Encrypt has nothing to switch to, so the heading // that offers a switch would contradict the diagnosis above it. const heading = isAlreadyLetsEncrypt - ? " THE FIX - obtain a new certificate from Let's Encrypt." - : " THE FIX - switch to Let's Encrypt. Certificates are free."; + ? " To fix it, get a new certificate from Let's Encrypt." + : " To fix it, switch to Let's Encrypt. Certificates are free."; return `${heading} diff --git a/packages/dashmate/test/unit/commands/update.spec.js b/packages/dashmate/test/unit/commands/update.spec.js index 19172d75c15..53cbea85283 100644 --- a/packages/dashmate/test/unit/commands/update.spec.js +++ b/packages/dashmate/test/unit/commands/update.spec.js @@ -171,7 +171,7 @@ describe('Update command', () => { }, })).to.be.rejectedWith(rejection); - expect(stderr).to.contain('did not pass'); + expect(stderr).to.contain('is not valid'); }); // The renderer can only tell the truth about a failed attempt if the @@ -298,7 +298,7 @@ describe('Update command', () => { this.sinon.stub(console, 'log').callsFake(() => order.push('table')); process.stderr.write.callsFake((chunk) => { stderr += chunk; - if (String(chunk).includes('did not pass')) { + if (String(chunk).includes('is not valid')) { order.push('guidance'); } return true; @@ -387,7 +387,7 @@ describe('Update command', () => { }); expect(observed.skipCertificateCheck).to.be.true(); - expect(stderr).to.contain('the certificate did not pass'); + expect(stderr).to.contain('certificate is not valid'); expect(stderr).to.not.contain('status is INVALID'); }); diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index f56d1c54ace..21f5d295a03 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -537,7 +537,7 @@ describe('analyseGatewayCertificateFactory', () => { expect(problem.getSolution()).to.not.match(/clients? rejects?/i); expect(problem.getSolution()).to.not.match(/clients (are|were|could not|cannot|unable)/i); - expect(problem.getSolution()).to.contain('did not pass'); + expect(problem.getSolution()).to.contain('not usable'); }); // `dashmate ssl obtain` signals the gateway itself once it has the files, @@ -564,8 +564,7 @@ describe('analyseGatewayCertificateFactory', () => { warnings: [], }); - expect(problem.getSolution()).to.include('still pulls images'); - expect(problem.getSolution()).to.include('exits non-zero'); + expect(problem.getSolution()).to.include('Updates still work'); }); // Doctor is run against a named node, and a solution pasted without one diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index 4fb96c98740..4ab1900f2f2 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -219,7 +219,7 @@ describe('renderCertificateGuidance', () => { const output = render(); expect(output).to.contain('already uses Let\'s Encrypt'); - expect(output).to.contain('THE FIX - obtain a new certificate'); + expect(output).to.contain("To fix it, get a new certificate"); expect(output).to.not.contain('THE FIX - switch to'); // The remediation itself is still the right next step and stays. @@ -358,7 +358,7 @@ describe('renderCertificateGuidance', () => { expect(output).to.not.contain('Images could not be pulled'); expect(output).to.not.contain('pulled images'); expect(output).to.not.contain('Images pulled'); - expect(output).to.contain("This node's TLS certificate did not pass"); + expect(output).to.contain("This node's TLS certificate is not valid."); }); // The operator's own situation, with no claim about what other providers @@ -410,7 +410,7 @@ describe('renderCertificateGuidance', () => { expect(output).to.contain( 'dashmate config set --config base platform.gateway.ssl.provider letsencrypt', ); - expect(output).to.not.contain('THE FIX'); + expect(output).to.not.contain('To fix it'); }); // The installed pair being the one lego produced says nothing about whether @@ -428,7 +428,7 @@ describe('renderCertificateGuidance', () => { }); expect(output).to.not.contain('Nothing needs to be obtained'); - expect(output).to.contain('THE FIX'); + expect(output).to.contain('To fix it'); }); it('should name the bypass and say it is not a playbook line', () => { From 5cec821b8541080b7eb3e479a7482d52ac185f34 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 24 Aug 2026 15:45:48 +0700 Subject: [PATCH 58/63] fix(dashmate): prescribe one repair command per certificate The doctor derived the forced repair from the installed verdict at two of its five ssl obtain remedies. The other three printed an unforced command, so a certificate issued for another address produced a forced repair and an unforced one in the same report, and an operator had no way to tell which one their node needed. The unforced command hands the same rejected certificate back. All three remaining remedies are reachable together with a forced one: a verdict carries reasons and warnings at once, so a stale-address certificate that is also expiring soon or provider-mismatched hits both paths - the ordinary shape of the problem on the nodes this gate targets. The existing guard asserted on the first problem only, which is why four later instances passed it. The new test walks every obtain remedy in the report across each served/on-disk combination that reaches one. Test would have caught this in CI: 4 failing before the fix, 4 passing after. Co-Authored-By: Claude Opus 5 --- .../analyseGatewayCertificateFactory.js | 6 +-- .../analyseGatewayCertificateFactory.spec.js | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index a026bd6bc98..8c2d8c7c604 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -139,7 +139,7 @@ Obtain a new certificate. No restart needed: problems.push(new Problem( message, chalk`Nothing is broken yet. If it needs attention, obtain a new certificate: -{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt${installedForce}}`, SEVERITY.LOW, )); }); @@ -241,7 +241,7 @@ If this node's gateway is answering and the address is simply wrong: + 'replacement', chalk`Neither the certificate in use nor the saved one is known to work, so restarting will not help. Get a current one: -{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt${installedForce}}`, SEVERITY.HIGH, )); } else if (isServedExpired) { @@ -250,7 +250,7 @@ restarting will not help. Get a current one: + 'Clients cannot connect to it', chalk`Renewal has not succeeded. Check the logs, then obtain a new certificate: {bold.cyanBright dashmate logs ${cfg} dashmate_helper} -{bold.cyanBright dashmate ssl obtain ${cfg}}`, +{bold.cyanBright dashmate ssl obtain ${cfg}${installedForce}}`, SEVERITY.HIGH, )); } else if (onDiskDiffers) { diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index 21f5d295a03..de3583f7cb0 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -633,4 +633,53 @@ describe('analyseGatewayCertificateFactory', () => { expect(problems[0].getDescription()).to.include('expired'); }); }); + + describe('when the installed certificate cannot be reinstated', () => { + // One report must not prescribe two different commands for one certificate. + // Each remedy below is reached by a different combination of what the wire + // serves and what is on disk, and an operator reading a forced command + // beside an unforced one has no way to tell which one their node needs. + const SERVED_STATES = { + 'a sound certificate': served(), + 'an expired certificate matching the disk copy': served({ + certificate: { fingerprint256: 'AA:BB', validTo: validTo(-1) }, + }), + 'an expired certificate the disk copy differs from': served({ + certificate: { fingerprint256: 'CC:DD', validTo: validTo(-1) }, + matchesOnDisk: false, + onDisk: { fingerprint256: 'AA:BB', validTo: validTo(-5) }, + }), + 'a live certificate the disk copy differs from': served({ + matchesOnDisk: false, + onDisk: { fingerprint256: 'EE:FF', validTo: validTo(-5) }, + }), + }; + + Object.entries(SERVED_STATES).forEach(([state, servedCertificate]) => { + it(`should force every repair it prescribes while serving ${state}`, () => { + // Carries a reason that reinstalling cannot fix alongside a warning: + // a stale-address certificate that is also close to expiry is the + // ordinary shape of the problem, not a contrived one. + samples.setServiceInfo('gateway', 'installedCertificate', { + status: 'INVALID', + reasons: [{ + code: 'IP_MISMATCH', + message: 'The certificate is not valid for this address.', + }], + warnings: [{ + code: 'EXPIRING_SOON', + message: 'The installed certificate expires in less than 7 days.', + }], + fingerprint256: 'AA:BB', + }); + + const prescribed = analyse(servedCertificate) + .map((problem) => problem.getSolution()) + .filter((solution) => solution.includes('ssl obtain')); + + expect(prescribed).not.to.be.empty(); + prescribed.forEach((solution) => expect(solution).to.include('--force')); + }); + }); + }); }); From 45d24421751b78c75258daf06dfdbdac9612a2cc Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 24 Aug 2026 16:00:39 +0700 Subject: [PATCH 59/63] fix(dashmate): do not pass a node whose address was never checked Whether the certificate names this node's address is the question that decides whether anything can connect to it. With no externalIp configured the checker recorded the identity check as skipped and added no reason, so an enforced mainnet or testnet masternode could reach CHECKS_PASSED with that question never asked - and the diagnostics line carried status, reasons and warnings but not skipped, so nothing said so. A masternode now fails with an actionable reason; a node that serves no public identity keeps the skip. The interrupted switch also opened with the flat claim that the TLS certificate is not valid. There the certificate is the one lego installed and only the saved provider still disagrees, so that sent an operator hunting a certificate problem that does not exist. Test would have caught this in CI: 2 failing before the fix, 2 passing after. The third test preserves the skip for a non-masternode and passes either way by design. Co-Authored-By: Claude Opus 5 --- .../dashmate/src/ssl/certificateReporting.js | 3 +++ .../src/ssl/checkGatewayCertificateFactory.js | 16 +++++++++++++- .../src/ssl/renderCertificateGuidance.js | 21 ++++++++++++++----- .../checkGatewayCertificateFactory.spec.js | 19 ++++++++++++++++- .../ssl/renderCertificateGuidance.spec.js | 17 +++++++++++++++ 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/dashmate/src/ssl/certificateReporting.js b/packages/dashmate/src/ssl/certificateReporting.js index 6f75165db7b..761c73a765a 100644 --- a/packages/dashmate/src/ssl/certificateReporting.js +++ b/packages/dashmate/src/ssl/certificateReporting.js @@ -25,6 +25,9 @@ export function writeDiagnostics(verdict, config, extra = {}) { status: verdict.status, reasons: verdict.reasons.map(({ code }) => code), warnings: verdict.warnings.map(({ code }) => code), + // What could not be established is as decisive as what failed: a check + // that never ran is invisible to an unattended operator otherwise. + skipped: verdict.skipped ?? [], provider: verdict.provider, config: config.getName(), expiresAt: verdict.installed ? verdict.installed.validTo.toISOString() : null, diff --git a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js index e2568dbbf39..02a13fcd58e 100644 --- a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js +++ b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js @@ -47,6 +47,7 @@ export const CERTIFICATE_REASONS = { EXPIRING_SOON: 'EXPIRING_SOON', SELF_SIGNED: 'SELF_SIGNED', IP_MISMATCH: 'IP_MISMATCH', + NO_EXTERNAL_IP: 'NO_EXTERNAL_IP', SWITCH_INCOMPLETE: 'SWITCH_INCOMPLETE', PROVIDER_MISMATCH: 'PROVIDER_MISMATCH', SSL_UNMANAGED: 'SSL_UNMANAGED', @@ -341,7 +342,20 @@ export default function checkGatewayCertificateFactory(homeDir) { } if (!externalIp) { - skipped.push('IDENTITY'); + // Whether the certificate names this node is the one question that + // decides if clients can connect, so a masternode that cannot be asked + // it has not passed - reporting otherwise would hand an operator a + // healthy verdict dashmate never established. A node that serves no + // public identity is a different case and keeps the skip. + if (config.get('core.masternode.enable') === true) { + reasons.push({ + code: CERTIFICATE_REASONS.NO_EXTERNAL_IP, + message: "This node's public address is not set, so dashmate cannot tell whether" + + ' the certificate is issued for this node', + }); + } else { + skipped.push('IDENTITY'); + } } else if (!installed.ipAddresses.includes(externalIp)) { // Only the subject alternative name counts. Node's own // tls.checkServerIdentity does not consult the common name for an IP diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index de9494312ce..18831df9cbf 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -13,23 +13,34 @@ import { CERTIFICATE_REASONS, requiresReplacement } from './checkGatewayCertific * opening then says nothing about images at all, rather than reporting a * failure that never happened. * + * The interrupted switch is the one state where the certificate itself is + * sound - lego installed it and only the saved provider still disagrees - so + * calling it invalid would send an operator hunting a certificate problem that + * is not there. + * * @param {{ok: boolean, failed: number, total: number}|null} pull + * @param {boolean} isSwitchIncomplete * @return {string} */ -function renderOpening(pull) { +function renderOpening(pull, isSwitchIncomplete) { + const subject = isSwitchIncomplete + ? "this node's certificate setup is unfinished" + : "this node's TLS certificate is not valid"; + const sentence = subject.charAt(0).toUpperCase() + subject.slice(1); + if (pull === null || pull === undefined) { - return " This node's TLS certificate is not valid."; + return ` ${sentence}.`; } if (!pull.ok) { - return " Images could not be pulled, and this node's TLS certificate is not valid."; + return ` Images could not be pulled, and ${subject}.`; } const pulled = pull.failed > 0 ? `Images pulled, ${pull.failed} of ${pull.total} failed - see the table above.` : 'Images pulled.'; - return ` ${pulled} This node's TLS certificate is not valid.`; + return ` ${pulled} ${sentence}.`; } /** @@ -167,7 +178,7 @@ export default function renderCertificateGuidance({ && verdict.reasons[0].code === CERTIFICATE_REASONS.SWITCH_INCOMPLETE; const blocks = [ - `${renderOpening(pull)} + `${renderOpening(pull, isSwitchIncomplete)} Node: ${config.get('network')} (config "${config.getName()}", ${config.get('externalIp') ?? 'no external IP set'}) Certificate: ${renderObservation(verdict)} diff --git a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js index b320afbe5d0..d0e6de3f2a7 100644 --- a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js @@ -398,7 +398,10 @@ describe('checkGatewayCertificateFactory', () => { expect(codes(verdict.reasons)).to.deep.equal([CERTIFICATE_REASONS.IP_MISMATCH]); }); - it('should record the identity check as skipped when no address is configured', () => { + // Whether the certificate names this node decides whether anything can + // connect to it. Recording that as merely skipped let an enforced + // masternode pass with the decisive question never asked. + it('should fail a masternode whose address is not configured', () => { const { leaf, intermediate } = issueChain({ ip: '9.9.9.9' }); config.set('externalIp', null); @@ -406,8 +409,22 @@ describe('checkGatewayCertificateFactory', () => { const verdict = checkGatewayCertificate(config); + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.include(CERTIFICATE_REASONS.NO_EXTERNAL_IP); + }); + + it('should skip the address check for a node that serves no public identity', () => { + const { leaf, intermediate } = issueChain({ ip: '9.9.9.9' }); + + config.set('core.masternode.enable', false); + config.set('externalIp', null); + install(leaf.pem + intermediate.pem, leaf.keyPem); + + const verdict = checkGatewayCertificate(config); + expect(verdict.skipped).to.deep.equal(['IDENTITY']); expect(codes(verdict.reasons)).to.not.include(CERTIFICATE_REASONS.IP_MISMATCH); + expect(codes(verdict.reasons)).to.not.include(CERTIFICATE_REASONS.NO_EXTERNAL_IP); }); // Node's own tls.checkServerIdentity does not consult the common name for diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index 4ab1900f2f2..f39f3f0dca7 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -413,6 +413,23 @@ describe('renderCertificateGuidance', () => { expect(output).to.not.contain('To fix it'); }); + // lego installed this certificate and only the saved provider still + // disagrees, so the certificate itself is sound. Opening with the flat claim + // that it is not valid sends an operator hunting a problem that is not there. + it('should not call the certificate invalid when only the switch is unfinished', () => { + const output = render({ + verdict: verdict({ + reasons: [{ + code: CERTIFICATE_REASONS.SWITCH_INCOMPLETE, + message: 'A switch was interrupted before it finished', + }], + }), + }); + + expect(output).to.not.contain("This node's TLS certificate is not valid"); + expect(output).to.contain("This node's certificate setup is unfinished"); + }); + // The installed pair being the one lego produced says nothing about whether // it is still valid. When something else is wrong with it too, saving the // setting is not the repair, and offering it as one sends the operator away From 9596d9d1859bd2b9f6e5abeb4e93aefa1822db9d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 24 Aug 2026 18:07:47 +0700 Subject: [PATCH 60/63] fix(dashmate): set the address before prescribing a certificate The NO_EXTERNAL_IP verdict added in 45d2442175 sent operators to `dashmate ssl obtain`, which throws "External IP is not set" before it does anything. The gate failed the node and then handed it a command that cannot run - through all three paths that carry the advice: the guidance printed after update, the doctor's per-reason remedy, and the interactive gate, which would have prompted to obtain and failed part way through. Nothing can be issued for an address dashmate does not have, so the address is the repair and the certificate follows once one exists. The interactive gate no longer offers an obtain it cannot complete. Test would have caught this in CI: 2 failing before the fix, 2 passing after. Co-Authored-By: Claude Opus 5 --- .../analyseGatewayCertificateFactory.js | 27 +++++++++++----- .../update/gatewayCertificateTaskFactory.js | 8 +++++ .../src/ssl/renderCertificateGuidance.js | 32 ++++++++++++++++++- .../analyseGatewayCertificateFactory.spec.js | 24 ++++++++++++++ .../ssl/renderCertificateGuidance.spec.js | 17 ++++++++++ 5 files changed, 99 insertions(+), 9 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 8c2d8c7c604..86e542c043f 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -2,7 +2,11 @@ import chalk from 'chalk'; import { SEVERITY } from '../Prescription.js'; import Problem from '../Problem.js'; import renderConfigFlag from '../../util/renderConfigFlag.js'; -import { GATED_NETWORKS, requiresReplacement } from '../../ssl/checkGatewayCertificateFactory.js'; +import { + CERTIFICATE_REASONS, + GATED_NETWORKS, + requiresReplacement, +} from '../../ssl/checkGatewayCertificateFactory.js'; /** * The manual obtain command writes certificate files but does not signal the gateway, so an @@ -124,15 +128,22 @@ export default function analyseGatewayCertificateFactory() { const installedForce = requiresReplacement(installed) ? ' --force' : ''; if (installed) { - installed.reasons.forEach(({ message }) => { - problems.push(new Problem( - message, - chalk`${UPDATE_CONSEQUENCE} + installed.reasons.forEach(({ code, message }) => { + // Nothing can be issued for an address dashmate does not have, and the + // obtain command refuses to start without one, so the address has to + // be set before a certificate is worth asking for. + const remedy = code === CERTIFICATE_REASONS.NO_EXTERNAL_IP + ? chalk`${UPDATE_CONSEQUENCE} + +Set this node's public address, then obtain a certificate: +{bold.cyanBright dashmate config set ${cfg} externalIp } +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}` + : chalk`${UPDATE_CONSEQUENCE} Obtain a new certificate. No restart needed: -{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt${installedForce}}`, - SEVERITY.HIGH, - )); +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt${installedForce}}`; + + problems.push(new Problem(message, remedy, SEVERITY.HIGH)); }); installed.warnings.forEach(({ message }) => { diff --git a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js index b9d0513fb80..b1b72563880 100644 --- a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js @@ -366,6 +366,14 @@ export default function gatewayCertificateTaskFactory( throw new CertificateUnresolvedError(verdict); } + // Nothing can be obtained for an address dashmate does not have - the + // obtain command refuses to start - so offering to run one here would + // replace a clear diagnosis with a raw failure part way through. The + // guidance names the setting that has to come first. + if (hasReason(verdict, CERTIFICATE_REASONS.NO_EXTERNAL_IP)) { + throw new CertificateUnresolvedError(verdict); + } + // Only when the interrupted switch is the whole problem. The pair being // byte-identical to the one lego produced says nothing about whether it // is still valid, so this state can carry an expired or misaddressed diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index 18831df9cbf..4a7960432c6 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -2,6 +2,13 @@ import { SSL_PROVIDERS } from '../constants.js'; import renderConfigFlag from '../util/renderConfigFlag.js'; import { CERTIFICATE_REASONS, requiresReplacement } from './checkGatewayCertificateFactory.js'; +/** + * @param {Object} verdict + * @param {string} code + * @return {boolean} + */ +const hasReason = (verdict, code) => verdict.reasons.some((reason) => reason.code === code); + /** * How the run went for the images, said only as far as it was observed. * @@ -116,6 +123,24 @@ function renderLetsEncryptDiagnosis(cfg) { `; } +/** + * Without an address there is nothing to put in a certificate, and the obtain + * command refuses to start - so prescribing it here would hand an operator a + * command that cannot work. The address is the repair; the certificate follows + * once one exists. + * + * @param {string} cfg + * @return {string} + */ +function renderNoExternalIpGuidance(cfg) { + return ` To fix it, tell dashmate this node's public address, then get a + certificate for it: + + dashmate config set ${cfg} externalIp + dashmate ssl obtain ${cfg} --provider letsencrypt +`; +} + /** * @param {string} cfg * @param {boolean} isAlreadyLetsEncrypt @@ -211,7 +236,12 @@ ${obtainAttemptFailed blocks.push(renderLetsEncryptDiagnosis(cfg)); } - blocks.push(renderFix(cfg, provider === SSL_PROVIDERS.LETSENCRYPT, verdict)); + if (hasReason(verdict, CERTIFICATE_REASONS.NO_EXTERNAL_IP)) { + blocks.push(renderNoExternalIpGuidance(cfg)); + } else { + blocks.push(renderFix(cfg, provider === SSL_PROVIDERS.LETSENCRYPT, verdict)); + } + blocks.push(renderPortEightyPermanence()); blocks.push(` Cannot open port 80? There is no other way to get an IP-address diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index de3583f7cb0..e890099ff23 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -106,6 +106,30 @@ describe('analyseGatewayCertificateFactory', () => { expect(problem.getSolution()).to.include('--force'); }); + // Nothing can be issued for an address dashmate does not have, and the + // obtain command refuses to start without one, so advising it here sends the + // operator to a command that fails before it does anything. + it('should prescribe the address rather than an obtain that cannot run', () => { + samples.setServiceInfo('gateway', 'installedCertificate', { + status: 'INVALID', + reasons: [{ + code: 'NO_EXTERNAL_IP', + message: "This node's public address is not set", + }], + warnings: [], + fingerprint256: 'AA:BB', + }); + + const [problem] = analyseGatewayCertificate(samples); + + const solution = problem.getSolution(); + + // Obtain still belongs here - it is what the operator runs once an address + // exists - but only after the setting that makes it able to run at all. + expect(solution).to.include('externalIp'); + expect(solution.indexOf('externalIp')).to.be.lessThan(solution.indexOf('ssl obtain')); + }); + it('should report no problem for a healthy certificate', () => { expect(analyse(served())).to.be.empty(); }); diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index f39f3f0dca7..9bbe117fad4 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -413,6 +413,23 @@ describe('renderCertificateGuidance', () => { expect(output).to.not.contain('To fix it'); }); + // The obtain command refuses to start without an address, so prescribing it + // for this verdict hands the operator a command that cannot work and leaves + // the node failing the gate with no way forward. + it('should prescribe the address, not an obtain that cannot run', () => { + const output = render({ + verdict: verdict({ + reasons: [{ + code: CERTIFICATE_REASONS.NO_EXTERNAL_IP, + message: "This node's public address is not set", + }], + }), + }); + + expect(output).to.contain('dashmate config set --config base externalIp'); + expect(output).to.not.contain('To fix it, switch to'); + }); + // lego installed this certificate and only the saved provider still // disagrees, so the certificate itself is sound. Opening with the flat claim // that it is not valid sends an operator hunting a problem that is not there. From b3ed62c0197bf5652044acddaefdf37dc2c5c574 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 24 Aug 2026 18:58:23 +0700 Subject: [PATCH 61/63] fix(dashmate): ask for the address before reading the certificate The missing-address check ran after five early returns for a missing, unreadable or wrongly ordered bundle, an unusable key and a key mismatch. A masternode with no externalIp and any of those returned a verdict that did not carry NO_EXTERNAL_IP, so the address-first remediation was not selected and the repair was an obtain - which refuses to start with no address to issue for. The question is now asked before a byte is read from disk, so every verdict carries it. Separately, a pull that fetched nothing renders no table and carries no message of its own; it is raised at the end. An unexpected certificate error is thrown before that, and only one error can be thrown, so an operator was told the certificate failed and never that no image arrived. One Docker daemon being down produces both at once. Test would have caught this in CI: 3 failing before the fix, 3 passing after. Co-Authored-By: Claude Opus 5 --- packages/dashmate/src/commands/update.js | 9 +++++ .../src/ssl/checkGatewayCertificateFactory.js | 36 ++++++++++--------- .../test/unit/commands/update.spec.js | 18 ++++++++++ .../checkGatewayCertificateFactory.spec.js | 22 ++++++++++++ 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/packages/dashmate/src/commands/update.js b/packages/dashmate/src/commands/update.js index 9669899e894..464a8aaeef1 100644 --- a/packages/dashmate/src/commands/update.js +++ b/packages/dashmate/src/commands/update.js @@ -224,6 +224,15 @@ export default class UpdateCommand extends ConfigBaseCommand { // must not be reduced to a certificate message. exitOnError would otherwise // have swallowed it. if (unexpected) { + // A pull that fetched nothing renders no table and carries no message of + // its own - it is raised further down instead. Only one error can be + // thrown, so without saying it here the operator is told the certificate + // failed and never learns their images never arrived. One Docker daemon + // being down produces both at once. + if (this.pullError) { + process.stderr.write(`Images could not be pulled: ${this.pullError.message}\n\n`); + } + throw unexpected; } diff --git a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js index 02a13fcd58e..52520049fed 100644 --- a/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js +++ b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js @@ -156,6 +156,25 @@ export default function checkGatewayCertificateFactory(homeDir) { const warnings = []; const skipped = []; + // Asked before a byte is read from disk. Whether the certificate names this + // node is the one question that decides if clients can connect, so a + // masternode that cannot be asked it has not passed. Every verdict has to + // carry this, including the ones that return early on a missing or broken + // bundle: without it their repair is an obtain, and obtain refuses to start + // with no address to issue for. A node that serves no public identity is a + // different case and keeps the skip. + if (!externalIp) { + if (config.get('core.masternode.enable') === true) { + reasons.push({ + code: CERTIFICATE_REASONS.NO_EXTERNAL_IP, + message: "This node's public address is not set, so dashmate cannot tell whether" + + ' the certificate is issued for this node', + }); + } else { + skipped.push('IDENTITY'); + } + } + /** * @param {Object|null} installed * @param {number|null} expiresInDays @@ -341,22 +360,7 @@ export default function checkGatewayCertificateFactory(homeDir) { }); } - if (!externalIp) { - // Whether the certificate names this node is the one question that - // decides if clients can connect, so a masternode that cannot be asked - // it has not passed - reporting otherwise would hand an operator a - // healthy verdict dashmate never established. A node that serves no - // public identity is a different case and keeps the skip. - if (config.get('core.masternode.enable') === true) { - reasons.push({ - code: CERTIFICATE_REASONS.NO_EXTERNAL_IP, - message: "This node's public address is not set, so dashmate cannot tell whether" - + ' the certificate is issued for this node', - }); - } else { - skipped.push('IDENTITY'); - } - } else if (!installed.ipAddresses.includes(externalIp)) { + if (externalIp && !installed.ipAddresses.includes(externalIp)) { // Only the subject alternative name counts. Node's own // tls.checkServerIdentity does not consult the common name for an IP // identifier and neither do browsers, so a certificate carrying the diff --git a/packages/dashmate/test/unit/commands/update.spec.js b/packages/dashmate/test/unit/commands/update.spec.js index 53cbea85283..8dc3d3fcde9 100644 --- a/packages/dashmate/test/unit/commands/update.spec.js +++ b/packages/dashmate/test/unit/commands/update.spec.js @@ -174,6 +174,24 @@ describe('Update command', () => { expect(stderr).to.contain('is not valid'); }); + // One Docker daemon being down fails the pull and the gateway reload at + // once. Only one error can be thrown, and a total pull failure renders no + // table of its own, so throwing the certificate error alone tells the + // operator their certificate broke and never that no image arrived. + it('should report the failed pull even when the certificate throws', async () => { + const rejection = new Error('service list is broken'); + const unexpected = new Error('gateway reload failed'); + + await expect(runUpdate({ + updateNode: () => Promise.reject(rejection), + gatewayCertificateTask: () => async () => { + throw unexpected; + }, + })).to.be.rejectedWith(unexpected); + + expect(stderr).to.contain('service list is broken'); + }); + // The renderer can only tell the truth about a failed attempt if the // command actually tells it one happened, so the wiring is pinned here // rather than left to the renderer's own tests. diff --git a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js index d0e6de3f2a7..d2d23b69cc9 100644 --- a/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js @@ -413,6 +413,28 @@ describe('checkGatewayCertificateFactory', () => { expect(codes(verdict.reasons)).to.include(CERTIFICATE_REASONS.NO_EXTERNAL_IP); }); + // The repair for a broken bundle is an obtain, and obtain refuses to start + // with no address to issue for. A verdict that reports only the bundle + // therefore prescribes a command that cannot run. + it('should report the missing address even when the bundle is unusable', () => { + config.set('externalIp', null); + install('not a certificate', 'not a key'); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.include(CERTIFICATE_REASONS.NO_EXTERNAL_IP); + }); + + it('should report the missing address when the bundle is absent', () => { + config.set('externalIp', null); + + const verdict = checkGatewayCertificate(config); + + expect(verdict.status).to.equal(CERTIFICATE_STATUS.INVALID); + expect(codes(verdict.reasons)).to.include(CERTIFICATE_REASONS.NO_EXTERNAL_IP); + }); + it('should skip the address check for a node that serves no public identity', () => { const { leaf, intermediate } = issueChain({ ip: '9.9.9.9' }); From 1736a374a76ebd77efbe30c807879b3c4890ef7d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 24 Aug 2026 20:14:32 +0700 Subject: [PATCH 62/63] fix(dashmate): prescribe repairs that can actually run Six defects found by a paired independent review, each verified against a concrete failure path before it was taken. The JSON diagnostics line merged two different things under one name: the bypass flag was passed as `skipped` and, spread last, overwrote the verdict's list of checks that could not run. A machine reading the line was told nothing was skipped on a node whose decisive identity check never ran. Two doctor remedies omitted the provider. `ssl obtain` falls back to the configured one, so on a ZeroSSL node it retried the free-tier limit that caused the outage, and on a node serving its own files it was refused outright - the missing flag perpetuated the failure this gate exists to catch. A stopped node was told the certificate does not prevent it starting, for any verdict including a missing bundle or a mismatched key. The gateway is handed the pair as-is and will not start with those files. The printed repair for an interrupted switch saved the provider but never signalled the gateway, so the pair already on disk was never picked up and the next check passed on it. The interactive repair signals for exactly this reason. An untrusted chain was answered only with a restart, which re-reads the same bundle and cannot make an authority trusted. A failed reload surfaced the bare signalling error, reading as though the certificate work failed when what is installed is good and only the running gateway has not been told. Test would have caught these in CI: 7 failing before the fixes, 7 passing after. Co-Authored-By: Claude Opus 5 --- packages/dashmate/src/commands/update.js | 4 +- .../analyseGatewayCertificateFactory.js | 15 ++++--- .../update/gatewayCertificateTaskFactory.js | 9 +++- .../src/ssl/renderCertificateGuidance.js | 31 ++++++++++++- .../test/unit/commands/update.spec.js | 21 +++++++++ .../analyseGatewayCertificateFactory.spec.js | 18 +++++++- .../gatewayCertificateTaskFactory.spec.js | 19 ++++++++ .../test/unit/renderedCommands.spec.js | 5 +++ .../ssl/renderCertificateGuidance.spec.js | 45 +++++++++++++++++++ 9 files changed, 157 insertions(+), 10 deletions(-) diff --git a/packages/dashmate/src/commands/update.js b/packages/dashmate/src/commands/update.js index 464a8aaeef1..c3b99746639 100644 --- a/packages/dashmate/src/commands/update.js +++ b/packages/dashmate/src/commands/update.js @@ -202,7 +202,9 @@ export default class UpdateCommand extends ConfigBaseCommand { // machine might want about the certificate goes to stderr as one line. if (format === OUTPUT_FORMATS.JSON && context.certificate) { writeDiagnostics(context.certificate, config, { - skipped: context.certificateSkipped === true, + // Not `skipped`: that name belongs to the verdict's list of checks that + // could not run, and an extra field of the same name overwrites it. + enforcementSkipped: context.certificateSkipped === true, pull: this.pullResult ?? null, }); } diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 86e542c043f..cec5895eb61 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -203,7 +203,7 @@ another dashmate config, a reverse proxy, or a second node. Find what is listening there first. If this node's gateway is answering and the address is simply wrong: -{bold.cyanBright dashmate ssl obtain ${cfg} --force}`, +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt --force}`, SEVERITY.HIGH, )); @@ -261,7 +261,7 @@ restarting will not help. Get a current one: + 'Clients cannot connect to it', chalk`Renewal has not succeeded. Check the logs, then obtain a new certificate: {bold.cyanBright dashmate logs ${cfg} dashmate_helper} -{bold.cyanBright dashmate ssl obtain ${cfg}${installedForce}}`, +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt${installedForce}}`, SEVERITY.HIGH, )); } else if (onDiskDiffers) { @@ -294,9 +294,14 @@ replacement, so do not restart to load it. Get a current one instead: problems.push(new Problem( 'The certificate this node is serving is not trusted by ordinary clients:' + ` ${describe(TRUST_FAILURES, served.chainError)}`, - chalk`Standard clients will reject this node. If the chain is incomplete, make sure -the bundle contains the issuing certificates as well as the server one. -${restartHint(cfg)}`, + chalk`Standard clients will reject this node. + +If the bundle is missing the certificates that vouch for the server one, add them. +${restartHint(cfg)} + +If the bundle is already complete, the authority that issued it is not one clients +trust, and no restart changes that. Get a publicly trusted certificate: +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, SEVERITY.HIGH, )); } diff --git a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js index b1b72563880..7b5ec7bab15 100644 --- a/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js @@ -190,7 +190,14 @@ export default function gatewayCertificateTaskFactory( await dockerCompose.execCommand(config, 'gateway', 'kill -SIGHUP 1'); } catch (e) { if (!(e instanceof ServiceIsNotRunningError)) { - throw e; + // The files are already in place by the time this runs, so the raw + // signalling error on its own reads as though the certificate work + // failed. It did not: what is installed is good and the running gateway + // simply has not been told, which is a different thing to recover from. + throw new Error(`The certificate is installed, but the gateway could not be signalled` + + ` to load it: ${e.message}\n` + + 'The node keeps serving the certificate it had until it is loaded:\n' + + ` dashmate restart ${renderConfigFlag(config.getName())} --platform`); } } } diff --git a/packages/dashmate/src/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js index 4a7960432c6..e3824aed258 100644 --- a/packages/dashmate/src/ssl/renderCertificateGuidance.js +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -9,6 +9,22 @@ import { CERTIFICATE_REASONS, requiresReplacement } from './checkGatewayCertific */ const hasReason = (verdict, code) => verdict.reasons.some((reason) => reason.code === code); +/** + * Faults in the files themselves. The gateway is handed the pair as-is, so any + * of these stops it loading - `saveCertificateTask` refuses to leave such a + * pair behind for exactly this reason. Everything else (expiry, a wrong + * address, a self-signed authority) is a certificate clients reject, on a + * gateway that starts and serves it perfectly well. + */ +const GATEWAY_CANNOT_LOAD = [ + CERTIFICATE_REASONS.BUNDLE_MISSING, + CERTIFICATE_REASONS.BUNDLE_UNREADABLE, + CERTIFICATE_REASONS.BUNDLE_ORDER, + CERTIFICATE_REASONS.KEY_MISSING, + CERTIFICATE_REASONS.KEY_UNUSABLE, + CERTIFICATE_REASONS.KEY_MISMATCH, +]; + /** * How the run went for the images, said only as far as it was observed. * @@ -97,9 +113,11 @@ function renderPortEightyPermanence() { */ function renderSwitchIncompleteGuidance(config, cfg) { return ` A Let's Encrypt certificate is installed, but the configuration still says - ${config.get('platform.gateway.ssl.provider')}. Nothing needs to be obtained - finish the switch: + ${config.get('platform.gateway.ssl.provider')}. Nothing needs to be obtained - save the setting, + then load the certificate that is already there: dashmate config set ${cfg} platform.gateway.ssl.provider letsencrypt + dashmate restart ${cfg} --platform `; } @@ -219,7 +237,16 @@ ${obtainAttemptFailed // and telling an operator their running node is stopped is worse than // saying nothing. if (isNodeRunning === false) { - blocks.push(` Your node is stopped. The certificate does not prevent it starting: + // Only when the files themselves are sound. Telling an operator to start a + // node whose gateway cannot load the pair sends them to a command that + // fails, at the moment they are already dealing with a broken certificate. + const cannotLoad = verdict.reasons.some(({ code }) => GATEWAY_CANNOT_LOAD.includes(code)); + + blocks.push(cannotLoad + ? ` Your node is stopped, and the gateway cannot start until the certificate + files are repaired. +` + : ` Your node is stopped. The certificate does not prevent it starting: dashmate start ${cfg} `); diff --git a/packages/dashmate/test/unit/commands/update.spec.js b/packages/dashmate/test/unit/commands/update.spec.js index 8dc3d3fcde9..0f1b4f3272d 100644 --- a/packages/dashmate/test/unit/commands/update.spec.js +++ b/packages/dashmate/test/unit/commands/update.spec.js @@ -192,6 +192,27 @@ describe('Update command', () => { expect(stderr).to.contain('service list is broken'); }); + // The bypass flag and the verdict's list of checks that could not run are + // two different things. Merging them under one name meant a machine reading + // the line was told nothing was skipped on a node where the decisive + // identity check never ran. + it('should not let the bypass flag overwrite the skipped checks', async () => { + const verdict = { ...invalidVerdict(), status: 'CHECKS_PASSED', reasons: [], skipped: ['IDENTITY'] }; + + await runUpdate({ + flags: { format: 'json' }, + checkGatewayCertificate: () => verdict, + gatewayCertificateTask: () => async (ctx) => { + ctx.certificate = verdict; + ctx.certificateSkipped = true; + }, + }); + + const line = stderr.split('\n').find((l) => l.trim().startsWith('{')); + + expect(JSON.parse(line).skipped).to.deep.equal(['IDENTITY']); + }); + // The renderer can only tell the truth about a failed attempt if the // command actually tells it one happened, so the wiring is pinned here // rather than left to the renderer's own tests. diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index e890099ff23..116a2443157 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -177,6 +177,18 @@ describe('analyseGatewayCertificateFactory', () => { expect(problems[0].getSeverity()).to.equal(SEVERITY.HIGH); }); + // A restart re-reads the same bundle, so where the chain is already complete + // and simply signed by an authority clients do not trust, it changes nothing + // and the operator is left with no way forward. + it('should offer a trusted certificate, not only a restart, for an untrusted chain', () => { + const [problem] = analyse(served({ + chainVerified: false, + chainError: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + })); + + expect(problem.getSolution()).to.include('dashmate ssl obtain --config base --provider letsencrypt'); + }); + it('should report an untrusted certificate separately from expiry', () => { const problems = analyse(served({ chainVerified: false, @@ -452,7 +464,11 @@ describe('analyseGatewayCertificateFactory', () => { const [problem] = analyse(hijacked()); expect(problem.getSolution()).to.contain('If this node\'s gateway is answering'); - expect(problem.getSolution()).to.contain('dashmate ssl obtain --config base --force'); + // The provider is named because obtain otherwise falls back to the + // configured one - which on a ZeroSSL node retries the free-tier wall + // that caused the outage, and on a file node is refused outright. + expect(problem.getSolution()) + .to.contain('dashmate ssl obtain --config base --provider letsencrypt --force'); }); }); diff --git a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js index 97b966e324f..730e37aa97d 100644 --- a/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js +++ b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js @@ -828,6 +828,25 @@ describe('gatewayCertificateTaskFactory', () => { expect(errors[0].message).to.contain('docker daemon is unreachable'); }); + + // By the time the signal is sent the new pair is already on disk, so the + // bare signalling error reads as though the certificate work failed. It did + // not, and the recovery is different: load what is already installed. + it('should say the certificate is installed but not loaded', async function it() { + dockerCompose.execCommand.rejects(new Error('docker daemon is unreachable')); + + let checked = 0; + const { errors } = await run.call(this, { + checkGatewayCertificate: () => { + checked += 1; + return checked === 1 ? invalid() : verdict(); + }, + answers: [true], + }); + + expect(errors[0].message).to.contain('installed, but the gateway could not be signalled'); + expect(errors[0].message).to.contain('dashmate restart --config base --platform'); + }); }); describe('the bypass suppresses enforcement, never the check', () => { diff --git a/packages/dashmate/test/unit/renderedCommands.spec.js b/packages/dashmate/test/unit/renderedCommands.spec.js index 5832c58b38a..6c8e1fa255e 100644 --- a/packages/dashmate/test/unit/renderedCommands.spec.js +++ b/packages/dashmate/test/unit/renderedCommands.spec.js @@ -68,6 +68,11 @@ const RESTART_ADVICE_ALLOWED = { + 'served, and only the gateway has not picked it up - re-reading the files is the entire ' + 'remedy; and the incomplete-chain case, where the operator edits the bundle by hand and ' + 'nothing else signals the gateway afterwards', + 'src/ssl/renderCertificateGuidance.js': + 'the interrupted switch, and only that state: the pair lego installed is already on disk and ' + + 'is the sole reason on the verdict, so every other check passed on it - saving the provider ' + + 'setting leaves a running gateway serving the older certificate, and the interactive repair ' + + 'signals it for exactly this reason', }; const PRE_EXISTING_BARE_COMMANDS = [ diff --git a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js index 9bbe117fad4..94fad0ed960 100644 --- a/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -430,6 +430,51 @@ describe('renderCertificateGuidance', () => { expect(output).to.not.contain('To fix it, switch to'); }); + // The gateway is handed the pair as-is, so a fault in the files themselves + // stops it loading them. Telling an operator to start such a node sends them + // to a command that fails while they are already dealing with a certificate. + it('should not promise a stopped node will start when the files are unusable', () => { + const output = render({ + isNodeRunning: false, + verdict: verdict({ + reasons: [{ + code: CERTIFICATE_REASONS.KEY_MISMATCH, + message: 'The certificate and private key do not match', + }], + }), + }); + + expect(output).to.not.contain('does not prevent it starting'); + expect(output).to.contain('cannot start until the certificate'); + }); + + it('should still offer to start a stopped node the files cannot stop', () => { + const output = render({ + isNodeRunning: false, + verdict: verdict({ + reasons: [{ code: CERTIFICATE_REASONS.EXPIRED, message: 'expired' }], + }), + }); + + expect(output).to.contain('does not prevent it starting'); + }); + + // Saving the provider leaves a running gateway on the certificate it already + // had; the interactive repair signals it for exactly this reason, so the + // printed one has to say so too or the node never picks the pair up. + it('should load the certificate after finishing an interrupted switch', () => { + const output = render({ + verdict: verdict({ + reasons: [{ + code: CERTIFICATE_REASONS.SWITCH_INCOMPLETE, + message: 'A switch was interrupted before it finished', + }], + }), + }); + + expect(output).to.contain('dashmate restart --config base --platform'); + }); + // lego installed this certificate and only the saved provider still // disagrees, so the certificate itself is sound. Opening with the flat claim // that it is not valid sends an operator hunting a problem that is not there. From 9e829ae755f69340dffac53b650319e136adc820 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 24 Aug 2026 20:26:02 +0700 Subject: [PATCH 63/63] fix(dashmate): do not blame the authority when the dates are what failed The untrusted-chain remedy in 1736a374a7 was applied to every verification failure that was not an expiry. A certificate can fail verification with a perfectly sound chain because its dates do not hold - a node whose clock is behind serves a publicly trusted certificate and gets CERT_NOT_YET_VALID - and there the remedy asserted the issuing authority is not trusted and sent the operator to obtain a replacement, when the clock is what is wrong. The two-branch advice now applies only to the failures that are about the chain of trust itself. A validity failure says the chain is not the problem and to check the clock first. Test would have caught this in CI: 1 failing before the fix, 1 passing after. Co-Authored-By: Claude Opus 5 --- .../analyseGatewayCertificateFactory.js | 25 +++++++++++++++++-- .../analyseGatewayCertificateFactory.spec.js | 13 ++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index cec5895eb61..6ca57e0d1bb 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -74,6 +74,21 @@ const TRUST_FAILURES = { */ const describe = (table, code) => table[code] ?? code; +/** + * The verification failures that are about the chain of trust itself - a + * missing issuer, or an authority nothing vouches for. A certificate can also + * fail verification while its chain is perfectly sound, because the dates do + * not hold; saying the authority is untrusted there is simply false, and sends + * an operator to replace a certificate when the clock is what is wrong. + */ +const TRUST_PATH_FAILURES = [ + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'UNABLE_TO_GET_ISSUER_CERT', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', +]; + const restartHint = (cfg) => chalk`Then restart Platform so the gateway picks it up: {bold.cyanBright dashmate restart ${cfg} --platform}`; /** @@ -294,14 +309,20 @@ replacement, so do not restart to load it. Get a current one instead: problems.push(new Problem( 'The certificate this node is serving is not trusted by ordinary clients:' + ` ${describe(TRUST_FAILURES, served.chainError)}`, - chalk`Standard clients will reject this node. + TRUST_PATH_FAILURES.includes(served.chainError) + ? chalk`Standard clients will reject this node. If the bundle is missing the certificates that vouch for the server one, add them. ${restartHint(cfg)} If the bundle is already complete, the authority that issued it is not one clients trust, and no restart changes that. Get a publicly trusted certificate: -{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}`, +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt}` + : chalk`Standard clients will reject this node. The chain itself is not the +problem, so adding certificates to the bundle will not help. Check this node's +clock first. If the clock is right, the certificate's own dates are wrong and it +has to be replaced: +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt --force}`, 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 116a2443157..271927696ff 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js @@ -189,6 +189,19 @@ describe('analyseGatewayCertificateFactory', () => { expect(problem.getSolution()).to.include('dashmate ssl obtain --config base --provider letsencrypt'); }); + // A certificate can fail verification with a sound chain, because its dates + // do not hold. Telling that operator the authority is untrusted is false, and + // sends them to replace a certificate when the clock is what is wrong. + it('should not blame the authority when the dates are what failed', () => { + const [problem] = analyse(served({ + chainVerified: false, + chainError: 'CERT_NOT_YET_VALID', + })); + + expect(problem.getSolution()).to.not.include('the authority that issued it is not one'); + expect(problem.getSolution()).to.include("clock"); + }); + it('should report an untrusted certificate separately from expiry', () => { const problems = analyse(served({ chainVerified: false,