diff --git a/packages/dashmate/configs/getConfigFileMigrationsFactory.js b/packages/dashmate/configs/getConfigFileMigrationsFactory.js index c768f85e67f..077ebae1225 100644 --- a/packages/dashmate/configs/getConfigFileMigrationsFactory.js +++ b/packages/dashmate/configs/getConfigFileMigrationsFactory.js @@ -1702,11 +1702,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]) => { + // 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; if (providerConfigs?.letsencrypt @@ -1756,10 +1757,8 @@ 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. + // 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; }); diff --git a/packages/dashmate/src/commands/ssl/obtain.js b/packages/dashmate/src/commands/ssl/obtain.js index d23a1b29b3b..c681690ab0d 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; @@ -109,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 { @@ -135,12 +147,18 @@ 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, - }); + await tasks.run(context); } catch (e) { throw new MuteOneLineError(e); } diff --git a/packages/dashmate/src/commands/update.js b/packages/dashmate/src/commands/update.js index dce6b2c6790..c3b99746639 100644 --- a/packages/dashmate/src/commands/update.js +++ b/packages/dashmate/src/commands/update.js @@ -1,10 +1,31 @@ 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 { 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'; + +/** + * 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]; 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; + static description = 'Update node software'; static flags = { @@ -14,47 +35,229 @@ 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, + }), }; /** * @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, + } = flags; - const colors = { - updated: chalk.yellow, - 'up to date': chalk.green, - error: chalk.red, - }; + const skipCertificateCheck = flags['skip-certificate-check'] === true + || isEnvironmentFlagSet(process.env.DASHMATE_SKIP_CERTIFICATE_CHECK); + + const interactive = isInteractiveSession({ flags }); + + const isGated = config.get('platform.enable') === true + && GATED_NETWORKS.includes(config.get('network')); + + 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 + // 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) { + // 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 }; + this.pullError = result.error; + + return; + } - // Draw table or show json - printArrayOfObjects(updateInfo - .reduce( - (acc, { + 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, + // 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, + 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)); + + // 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) { + writeDiagnostics(context.certificate, config, { + // 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, + }); + } + + (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 the certificate ${describeStatus(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) { + // 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; + } + + // 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(), + Boolean(context.certificateObtainError), + ); + } + + // 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); + } + + process.exitCode = 0; } } diff --git a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js index b3add6cef0d..6d1507f9b90 100644 --- a/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js +++ b/packages/dashmate/src/config/configFile/ConfigFileJsonRepository.js @@ -109,19 +109,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')); 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/doctor/analyse/analyseConfigFactory.js b/packages/dashmate/src/doctor/analyse/analyseConfigFactory.js index 67e897bd736..d716af62442 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 @@ -154,10 +155,6 @@ ${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]}`, - }, [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]}`, @@ -178,11 +175,18 @@ ${LETSENCRYPT_ALTERNATIVE}`, 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. 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.`, @@ -200,6 +204,7 @@ Please restart Platform: {bold.cyanBright dashmate restart --platform}`, const { description, solution, + severity = SEVERITY.HIGH, } = { ...fileProblems, ...providerProblems, @@ -209,7 +214,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/doctor/analyse/analyseGatewayCertificateFactory.js b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js index 23d77a4e270..6ca57e0d1bb 100644 --- a/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseGatewayCertificateFactory.js @@ -1,17 +1,108 @@ import chalk from 'chalk'; import { SEVERITY } from '../Prescription.js'; import Problem from '../Problem.js'; +import renderConfigFlag from '../../util/renderConfigFlag.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 * 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. + * + * 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} */ -const RESTART_HINT = chalk`Then restart Platform so the gateway picks it up: {bold.cyanBright dashmate restart --platform}`; +/** + * 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; + +/** + * 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}`; + +/** + * 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 = 'The certificate saved for the gateway is not usable.' + + ' Updates still work.'; 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 +115,68 @@ export default function analyseGatewayCertificateFactory() { return []; } - const served = samples.getServiceInfo('gateway', 'servedCertificate'); - - if (!served) { + // `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 = []; + // 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'); + + // 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(({ 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}}`; + + problems.push(new Problem(message, remedy, 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 ${cfg} --provider letsencrypt${installedForce}}`, + SEVERITY.LOW, + )); + }); + } + + const served = samples.getServiceInfo('gateway', 'servedCertificate'); + + if (!served) { + return 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 @@ -40,8 +185,9 @@ export default function analyseGatewayCertificateFactory() { 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}`, + "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, )); @@ -59,12 +205,20 @@ export default function analyseGatewayCertificateFactory() { // 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 --force} -${RESTART_HINT}`, + `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 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 answering and the address is simply wrong: +{bold.cyanBright dashmate ssl obtain ${cfg} --provider letsencrypt --force}`, SEVERITY.HIGH, )); @@ -75,34 +229,77 @@ ${RESTART_HINT}`, const isServedExpired = servedExpiresAt <= now; const onDiskDiffers = served.matchesOnDisk === false; - if (isServedExpired && onDiskDiffers) { + // 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; + // + // 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?.status === 'CHECKS_PASSED' + && Boolean(installed.fingerprint256) + && installed.fingerprint256 === served.onDisk?.fingerprint256; + + 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. -{bold.cyanBright dashmate restart --platform}`, + `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) { + } else if (isServedExpired && onDiskDiffers) { problems.push(new Problem( - `The gateway is serving a certificate that expired on ${served.certificate.validTo}. ` - + 'Clients cannot connect to this node', - chalk`Renewal has not succeeded. Check the renewal logs: -{bold.cyanBright dashmate logs dashmate_helper} -Then obtain a new certificate: {bold.cyanBright dashmate ssl obtain} -${RESTART_HINT}`, + `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 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${installedForce}}`, 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. + } else if (isServedExpired) { 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 --platform}`, + `This node is using a certificate that expired on ${served.certificate.validTo}. ` + + '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} --provider letsencrypt${installedForce}}`, SEVERITY.HIGH, )); + } else if (onDiskDiffers) { + 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( + '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 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( + '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 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${installedForce}}`, + SEVERITY.HIGH, + )); + } } // Reported separately from expiry because the connection surfaces only its first @@ -110,32 +307,35 @@ ${RESTART_HINT}`, // expiry, and the second fault would otherwise stay hidden until the first was fixed. if (!served.chainVerified && !isServedExpired) { problems.push(new Problem( - `The certificate served by the gateway is not trusted by standard clients (${served.chainError})`, - chalk`Clients verifying against public certificate authorities will reject this node. -If the certificate chain is incomplete, make sure the bundle contains the issuing -certificates as well as the server certificate. -${RESTART_HINT}`, - SEVERITY.HIGH, - )); - } + 'The certificate this node is serving is not trusted by ordinary clients:' + + ` ${describe(TRUST_FAILURES, served.chainError)}`, + TRUST_PATH_FAILURES.includes(served.chainError) + ? chalk`Standard clients will reject this node. - // 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 the bundle is missing the certificates that vouch for the server one, add them. +${restartHint(cfg)} - 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, +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}` + : 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, )); } + // 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. + // 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/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 632a57371a0..3edda30b13d 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'; @@ -10,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'; @@ -39,8 +40,58 @@ async function fetchTextOrError(url) { * @param {HomeDir} homeDir * @param {validateZeroSslCertificate} validateZeroSslCertificate * @param {validateLetsEncryptCertificate} validateLetsEncryptCertificate + * @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 {{username: string|null, homePath: string|null}} + */ +function getOperatorIdentity() { + let username = null; + let homePath = null; + + try { + ({ username, homedir: homePath } = os.userInfo()); + } catch { + // A process running under a uid with no passwd entry has no name to read. + } + + 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 identity = getOperatorIdentity(); + + obfuscateObjectRecursive(data, (_field, value) => maskOperatorIdentity(value, identity)); +} + +/** + * @param {string|undefined} text + * @return {string|undefined} + */ +function hideOperatorNameIn(text) { + return maskOperatorIdentity(text, getOperatorIdentity()); +} + export default function collectSamplesTaskFactory( dockerCompose, createRpcClient, @@ -51,6 +102,7 @@ export default function collectSamplesTaskFactory( homeDir, validateZeroSslCertificate, validateLetsEncryptCertificate, + checkGatewayCertificate, ) { /** * @typedef {function} collectSamplesTask @@ -112,10 +164,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, @@ -133,10 +182,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, @@ -162,10 +208,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', { @@ -192,6 +235,47 @@ 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); + + const installed = { + 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, + // 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, + // 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. + obfuscateOperatorName(installed); + + ctx.samples.setServiceInfo('gateway', 'installedCertificate', installed); + }, + }, { // 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 @@ -423,28 +507,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/setup/regular/configureSSLCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js index 90858703a44..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', @@ -126,20 +71,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 +82,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 +105,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/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/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index 8da44dd8724..e853c2b47ff 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -5,9 +5,148 @@ 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'; +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; + +/** + * 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 = 'Keep inbound port 80 reachable from the internet permanently,' + + ' for certificate reissue.'; + +/** + * 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) { + const cfg = renderConfigFlag(config.getName()); + + 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. +Then: + dashmate ssl obtain ${cfg} --provider letsencrypt + +${PORT_80_PERMANENCE} + +Still stuck? Send a report to Dash support: + dashmate doctor report ${cfg}`; +} + +/** + * 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 + * @param {boolean} [neverRan] - whether the helper is known not to have run + * @return {string} + */ +function renderHelperDidNotStartGuidance(config, cause, neverRan = true) { + // 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} + +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`; +} + +/** + * 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} + +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. + +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`; +} + +/** + * 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'; /** @@ -34,6 +173,42 @@ 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. + * + * 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, onCreated) { + let container; + + try { + container = await docker.createContainer(options); + } 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; + } + /** * @typedef {obtainLetsEncryptCertificateTask} * @param {Config} config @@ -53,10 +228,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 +268,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 +297,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}`); } @@ -142,6 +321,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(); @@ -157,15 +337,31 @@ 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'; // 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 +381,166 @@ 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 () => { + // 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 { - 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 new LegoDidNotStartError(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}`); - } + // 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, - }, - }); + // 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, + 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}...`; + + // 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); + } - startedContainers.addContainer(containerName); + 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(); + // 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; + + // 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); + } - // Wait for container to finish - const result = await container.wait(); + if (!fs.existsSync(ctx.legoKeyPath)) { + throw new LegoArtifactsMissingError(ctx.legoKeyPath); + } + }; - 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()}`; + await runLego(); + + break; } catch (e) { - // Container may have been auto-removed - } + // 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, e.neverRan)); + } - 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.'); - } + if (e instanceof LegoResultNotObservedError) { + throw new Error(renderResultNotObservedGuidance(config, e.cause)); + } - // Verify certificate and key were created - if (!fs.existsSync(ctx.legoCertPath)) { - throw new Error('Certificate file was not created by lego'); - } + // 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)); + } - if (!fs.existsSync(ctx.legoKeyPath)) { - throw new Error('Private key file was not created by lego'); + // 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. + const retry = canRetry && await promptOrThrow(task, { + type: 'toggle', + header: ` Let's Encrypt did not issue a certificate for ${ctx.externalIp}: + + ${e.message} + + 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', + initial: false, + }, { interactive: ctx.interactive }); + + if (!retry) { + throw new Error(`${e.message}\n\n${renderGiveUpGuidance(config, attempt)}`); + } + } } ctx.configurationUpdateRequired = true; @@ -278,12 +552,21 @@ 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'); 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; + + // 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/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/listr/tasks/update/gatewayCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js new file mode 100644 index 00000000000..7b5ec7bab15 --- /dev/null +++ b/packages/dashmate/src/listr/tasks/update/gatewayCertificateTaskFactory.js @@ -0,0 +1,525 @@ +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, + describeStatus, + requiresReplacement, +} 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; + +/** + * 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 passed these checks. Declining changes nothing.\n'; + } + + if (verdict.status !== CERTIFICATE_STATUS.WARN) { + return ' Declining leaves the certificate failing the checks above.\n'; + } + + // 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('')}`; +} + +/** + * 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 + * @param {Object} [options] + * @param {Object} [options.verdict] - decides what declining leaves behind, and + * carries the warnings the operator is being asked to weigh + * @return {string} + */ +function renderSwitchOffer(config, externalIp, { verdict } = {}) { + 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: [] })}`; +} + +/** + * 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 for ${config.get('externalIp')}, valid until ${expiresAt} (${days} days). + + Keep inbound port 80 reachable from the internet permanently, for certificate + reissue. Nothing will warn you if it lapses. + + dashmate doctor ${renderConfigFlag(config.getName())} +`; +} + +/** + * 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 + * @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. + * + * 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} + */ + async function reloadGateway(config) { + try { + await dockerCompose.execCommand(config, 'gateway', 'kill -SIGHUP 1'); + } catch (e) { + if (!(e instanceof ServiceIsNotRunningError)) { + // 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`); + } + } + } + + /** + * 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, verdict) { + // 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 || requiresReplacement(verdict) })); + } + + 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, the certificate ${describeStatus(verdict.status)}`); + + return; + } + + // 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 + // operator that renewal has stopped being possible until it has. + const warn = () => { + ctx.certificateWarnings = [ + ...(ctx.certificateWarnings ?? []), + `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`, + ]; + }; + + if (!interactive) { + warn(); + collectWarnings(ctx, verdict); + + return; + } + + const accepted = await promptOrThrow(task, { + type: 'toggle', + header: renderSwitchOffer(config, config.get('externalIp'), { verdict }), + message: "Switch to Let's Encrypt and obtain a certificate now?", + enabled: 'Yes', + disabled: 'Not now', + initial: daysLeft < ZEROSSL_URGENT_DAYS, + }, { interactive }); + + if (!accepted) { + warn(); + collectWarnings(ctx, verdict); + + return; + } + + 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 + // 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) { + // 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.', + ); + + 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 + // success message. + ctx.certificateSuccess = renderSuccess(config, after); + } + + return; + } + + ctx.certificate = after; + + throw new CertificateUnresolvedError(after); + } + + // 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); + } + + // 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 + // 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 + 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); + + // 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; + } + + // 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 }); + + // 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; + } + + 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, verdict); + + 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..63edebfe4c6 100644 --- a/packages/dashmate/src/oclif/command/BaseCommand.js +++ b/packages/dashmate/src/oclif/command/BaseCommand.js @@ -21,6 +21,12 @@ 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. + */ + holdsConfigLock = this.constructor.mutatesConfig === true; + /** * @param {Object} options * @return {Promise} @@ -51,7 +57,7 @@ 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) { + if (this.holdsConfigLock) { configFileRepository.acquire(); } @@ -65,9 +71,7 @@ export default class BaseCommand extends Command { ) ?? false; ({ configFile } = configFileRepository.readAndMigrate( - { - skipValidation, - }, + { skipValidation }, (migratedConfigs) => { const writeConfigTemplates = this.container.resolve('writeConfigTemplates'); @@ -131,7 +135,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 +161,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/certificateReporting.js b/packages/dashmate/src/ssl/certificateReporting.js new file mode 100644 index 00000000000..761c73a765a --- /dev/null +++ b/packages/dashmate/src/ssl/certificateReporting.js @@ -0,0 +1,75 @@ +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), + // 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, + ...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 new file mode 100644 index 00000000000..52520049fed --- /dev/null +++ b/packages/dashmate/src/ssl/checkGatewayCertificateFactory.js @@ -0,0 +1,416 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +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'; + +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', +}; + +/** + * 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 'is valid'; + } + + return status === CERTIFICATE_STATUS.WARN ? 'is valid, with warnings' : 'is not valid'; +} + +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', + KEY_UNUSABLE: 'KEY_UNUSABLE', + KEY_MISMATCH: 'KEY_MISMATCH', + EXPIRED: 'EXPIRED', + 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', + 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. + */ +/** + * 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} + */ +/** + * 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); +} + +const EXPIRING_SOON_DAYS = 1; + +/** + * 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 {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 {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 = []; + + // 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 + * @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 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. + 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}: ${detail}`, + }); + + return verdict(); + } + + if (error === LEAF_SELECTION_ERRORS.BUNDLE_UNREADABLE) { + reasons.push({ + code: CERTIFICATE_REASONS.BUNDLE_UNREADABLE, + 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(); + } + + if (error === LEAF_SELECTION_ERRORS.KEY_MISMATCH) { + 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, + }); + } + + // 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, + 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 && !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. + const named = installed.ipAddresses.length > 0 + ? `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 is not issued for this node's address" + + ` ${externalIp} - ${named}`, + }); + } + + 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/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/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/src/ssl/errors/LegoDidNotStartError.js b/packages/dashmate/src/ssl/errors/LegoDidNotStartError.js new file mode 100644 index 00000000000..aa89a6e7ae9 --- /dev/null +++ b/packages/dashmate/src/ssl/errors/LegoDidNotStartError.js @@ -0,0 +1,27 @@ +import AbstractError from '../../errors/AbstractError.js'; + +/** + * The certificate helper could not be started. + * + * Worth distinguishing from a request the authority refused, because the two + * 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, 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/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/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/letsencrypt/validateLetsEncryptCertificateFactory.js b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js index 86970dec505..536c33198bc 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', @@ -40,15 +39,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) { @@ -103,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/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/ssl/renderCertificateGuidance.js b/packages/dashmate/src/ssl/renderCertificateGuidance.js new file mode 100644 index 00000000000..e3824aed258 --- /dev/null +++ b/packages/dashmate/src/ssl/renderCertificateGuidance.js @@ -0,0 +1,283 @@ +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); + +/** + * 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. + * + * 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. + * + * 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. + * + * 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, 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 ` ${sentence}.`; + } + + if (!pull.ok) { + 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} ${sentence}.`; +} + +/** + * @param {Object} verdict + * @return {string} + */ +function renderObservation(verdict) { + const [first] = verdict.reasons; + + return first ? first.message : 'the installed certificate is not usable'; +} + +/** + * Why a free ZeroSSL account stops working, and why it is not the operator's + * mistake. + * + * @return {string} + */ +function renderZeroSslExplanation() { + return ` This node uses ZeroSSL. A free ZeroSSL account allows three certificates in + total, so renewals stop working after about 270 days. +`; +} + +/** + * 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 ` Keep inbound port 80 reachable from the internet permanently, for + certificate reissue. Nothing will warn you if it lapses. +`; +} + +/** + * 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 installed, but the configuration still says + ${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 +`; +} + +/** + * 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 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 logs ${cfg} dashmate_helper +`; +} + +/** + * 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 + * @param {Object} verdict - decides whether the certificate can be reinstated + * @return {string} + */ +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 + ? " To fix it, get a new certificate from Let's Encrypt." + : " To fix it, switch to Let's Encrypt. Certificates are free."; + + return `${heading} + + This needs inbound port 80 reachable from the internet. Check it first: + + dashmate doctor ${cfg} + + Then: + + dashmate ssl obtain ${cfg} --provider letsencrypt${requiresReplacement(verdict) ? ' --force' : ''} +`; +} + +/** + * 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|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} + */ +export default function renderCertificateGuidance({ + config, + verdict, + isNodeRunning, + pull, + obtainAttemptFailed = false, +}) { + const cfg = renderConfigFlag(config.getName()); + const provider = config.get('platform.gateway.ssl.provider'); + // 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, isSwitchIncomplete)} + + Node: ${config.get('network')} (config "${config.getName()}", ${config.get('externalIp') ?? 'no external IP set'}) + Certificate: ${renderObservation(verdict)} +${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. +` + : ''}`, + ]; + + // 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) { + // 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} +`); + } + + 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)); + } + + 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 + 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 +`); + } + + return `\n${blocks.join('\n')}\n`; +} diff --git a/packages/dashmate/src/ssl/selectLeafCertificate.js b/packages/dashmate/src/ssl/selectLeafCertificate.js new file mode 100644 index 00000000000..259cd7d5f48 --- /dev/null +++ b/packages/dashmate/src/ssl/selectLeafCertificate.js @@ -0,0 +1,146 @@ +import crypto from 'node:crypto'; + +export const LEAF_SELECTION_ERRORS = { + KEY_UNUSABLE: 'KEY_UNUSABLE', + BUNDLE_UNREADABLE: 'BUNDLE_UNREADABLE', + KEY_MISMATCH: 'KEY_MISMATCH', + BUNDLE_ORDER: 'BUNDLE_ORDER', +}; + +/** + * 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-----[ \t]*\r?$[\s\S]*?^-----END CERTIFICATE-----[ \t]*\r?$/gm; + +/** + * 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. + */ +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. + */ +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. 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. + * + * 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 + * @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 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 well formed,` + + ' so at least one block is truncated or its delimiters are damaged', + }; + } + + if (blocks.length === 0) { + return { error: LEAF_SELECTION_ERRORS.BUNDLE_UNREADABLE, detail: 'it holds no 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 (position === -1) { + return { + error: LEAF_SELECTION_ERRORS.KEY_MISMATCH, + detail: 'no certificate in the bundle belongs to the private key', + }; + } + + 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/src/test/certificateFixtures.js b/packages/dashmate/src/test/certificateFixtures.js new file mode 100644 index 00000000000..58d92ac3d9d --- /dev/null +++ b/packages/dashmate/src/test/certificateFixtures.js @@ -0,0 +1,200 @@ +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 {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, + * subject: Object}} + */ +export function issueCertificate({ + subject = { commonName: '1.2.3.4' }, + issuer, + ip, + days = 30, + startsInDays, + 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 = 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)); + + 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/src/test/createCertificateForTest.js b/packages/dashmate/src/test/createCertificateForTest.js index ec78288605a..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(); @@ -22,8 +23,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 }]; @@ -33,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/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/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/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 new file mode 100644 index 00000000000..521de6181b3 --- /dev/null +++ b/packages/dashmate/src/util/isInteractiveSession.js @@ -0,0 +1,67 @@ +import isEnvironmentFlagSet from './isEnvironmentFlagSet.js'; + +/** + * 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/maskOperatorIdentity.js b/packages/dashmate/src/util/maskOperatorIdentity.js new file mode 100644 index 00000000000..402a4d14ac9 --- /dev/null +++ b/packages/dashmate/src/util/maskOperatorIdentity.js @@ -0,0 +1,78 @@ +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']; + +/** + * @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 + * not rare - short account names are common. + * + * @param {string} name + * @return {RegExp} + */ +function wholeWord(name) { + return new RegExp(`(?} + */ +export default function promptOrThrow(task, options, { interactive } = {}) { + if (interactive !== true) { + throw new NonInteractivePromptError(options?.message); + } + + return task.prompt(options); +} 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/integration/ssl/letsencryptPebble.spec.js b/packages/dashmate/test/integration/ssl/letsencryptPebble.spec.js index 46c39e4e034..f1698e04c90 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. @@ -147,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')}`; @@ -374,4 +383,198 @@ 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; + + // 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'); + + 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(); + }); + + // 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']); + }); + + // 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/commands/ssl/obtain.spec.js b/packages/dashmate/test/unit/commands/ssl/obtain.spec.js index c142f820496..43944658173 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, @@ -52,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); @@ -150,4 +190,62 @@ 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() { + // 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); + + try { + await runObtain({ ...dependencies, 'no-retry': false }); + } finally { + process.stdin.isTTY = restore.stdin; + process.stdout.isTTY = restore.stdout; + } + + expect(context.interactive).to.equal(false); + }); + + 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 }; + 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; + } + }; + + try { + // 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(); + } + + expect(context.interactive).to.equal(true); + }); }); diff --git a/packages/dashmate/test/unit/commands/update.spec.js b/packages/dashmate/test/unit/commands/update.spec.js index 12536a22930..0f1b4f3272d 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,72 @@ 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, + ...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 +86,445 @@ 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 taskRan = false; - // 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 () => { + // An unobserved rejection would already have taken the process down. + await new Promise((resolve) => { setImmediate(resolve); }); + taskRan = true; + }, + })).to.be.rejectedWith(rejection); - updateNode = updateNodeFactory(mockGetServicesList, mockDocker); + expect(taskRan).to.be.true(); + }); - await command.runWithDependencies({}, { format: 'json' }, mockDocker, config, updateNode); + // 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'); - 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 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('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 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. + 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('may have changed'); + 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() { + 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(process.exitCode).to.equal(0); + }); + + // 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'); + + 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('is not valid')) { + 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('scope', () => { + ['local', 'devnet'].forEach((network) => { + it(`should not check the certificate on ${network}`, async function it() { + const innerTask = this.sinon.stub().resolves(); + const checkGatewayCertificate = this.sinon.stub().returns(passingVerdict()); + config.set('network', network); + + 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(); + }); + }); + + 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([ + '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('certificate is not valid'); + expect(stderr).to.not.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(); + }); + }); + + 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. + 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/config/configFile/migrateConfigFileFactory.spec.js b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js index 7e405f2b543..5fc22d104e0 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'; @@ -23,6 +24,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(); @@ -99,7 +123,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 @@ -115,13 +139,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; }; @@ -177,14 +201,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'; @@ -192,7 +216,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( @@ -210,25 +234,66 @@ 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 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 = 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 = { + timeout: null, + bypass: null, + }; + } + + const migrated = migrateConfigFile(configFileData, fromVersion, 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 // 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( @@ -246,7 +311,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')); @@ -254,13 +319,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( diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js index 602fc78c3ae..a6d4d9e85e7 100644 --- a/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/analyse/analyseConfigFactory.spec.js @@ -50,6 +50,60 @@ describe('analyseConfigFactory', () => { expect(problems[0].getSeverity()).to.equal(SEVERITY.HIGH); }); + // 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.be.empty(); + }); + + // 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'); + }); + + // 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. + 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/doctor/analyse/analyseGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseGatewayCertificateFactory.spec.js index f7f7a022b47..271927696ff 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} @@ -58,6 +73,63 @@ 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'); + }); + + // 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(); }); @@ -74,24 +146,62 @@ describe('analyseGatewayCertificateFactory', () => { }); it('should distinguish a certificate that was renewed but never reached the gateway', () => { + // 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, + onDisk: { fingerprint256: 'CC:DD', validTo: validTo(30) }, })); 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].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'); }); 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 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) }, + })); 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); }); + // 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'); + }); + + // 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, @@ -99,7 +209,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', () => { @@ -113,7 +262,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', () => { @@ -128,17 +281,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. @@ -164,4 +306,433 @@ 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', () => { + installedIsUsable('CC:DD'); + + const problems = analyse(served({ + matchesOnDisk: false, + onDisk: { fingerprint256: 'CC:DD', validTo: validTo(60) }, + })); + + const [problem] = problems.filter((p) => p.getDescription().includes('has been saved')); + + expect(problem.getDescription()).to.include('older certificate than the one that has been saved'); + 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('has been saved')); + + 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('has been saved')); + + expect(problem.getDescription()).to.not.include('older certificate than the one that has been saved'); + expect(problem.getSolution()).to.not.match(/dashmate restart/); + }); + }); + + // 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', () => { + installedIsUsable('CC:DD'); + + 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 has already been saved and is ready to use'); + 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 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'); + }); + + 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 has already been saved and is ready to use'); + expect(problem.getSolution()).to.not.match(/dashmate restart/); + }); + }); + + // 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/); + }); + }); + }); + + // 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/); + }); + + // 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('answering on port 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 answering'); + // 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'); + }); + }); + + // 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 + // 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 + * @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. + // 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('not usable'); + }); + + // `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', + reasons: [{ code: 'EXPIRED', message: 'expired' }], + warnings: [], + }); + + expect(problem.getSolution()).to.include('Updates still work'); + }); + + // 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', + 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'); + }); + }); + + 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')); + }); + }); + }); }); 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/listr/tasks/doctor/collectSamplesTaskFactory.spec.js b/packages/dashmate/test/unit/listr/tasks/doctor/collectSamplesTaskFactory.spec.js index 7de4901d627..0bad62c5055 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'; @@ -10,6 +11,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'; @@ -47,6 +49,9 @@ describe('collectSamplesTaskFactory', () => { let collectSamplesTask; let analyseConfig; let samples; + let dockerCompose; + let rpcClient; + let originalUser; /** * Run the sample collection the same way the doctor command does: as a subtask @@ -64,6 +69,7 @@ describe('collectSamplesTaskFactory', () => { } beforeEach(function beforeEach() { + originalUser = process.env.USER; homeDir = HomeDir.createTemp(); config = getBaseConfigFactory()(); @@ -92,13 +98,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: {} }), @@ -117,6 +123,7 @@ describe('collectSamplesTaskFactory', () => { homeDir, validateZeroSslCertificateFactory(homeDir, getCertificate), this.sinon.stub().resolves({}), + checkGatewayCertificateFactory(homeDir), ); analyseConfig = analyseConfigFactory(); @@ -126,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 () => { @@ -169,6 +184,115 @@ 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. + // 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); + }); + }); + + // 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 }); 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..730e37aa97d --- /dev/null +++ b/packages/dashmate/test/unit/listr/tasks/update/gatewayCertificateTaskFactory.spec.js @@ -0,0 +1,900 @@ +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'; +import ServiceIsNotRunningError from '../../../../../src/docker/errors/ServiceIsNotRunningError.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), + state: tasks.tasks[0].state, + }; + } + + 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()); + + // 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(); + }); + }); + + // 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 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 these checks'); + }); + }); + + // 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'); + + 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(); + }); + + // 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('Declining changes nothing'); + }); + + // 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], + }); + + // 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('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); + }); + + 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'); + }); + + // 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('reachable from the internet permanently'); + }); + + // 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 + // 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(); + }); + + // 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), + 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'); + }); + + // 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() { + 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(); + }); + + // 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'); + }); + + // 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', () => { + 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(); + + // 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, whatever precedes them. + expect(context.certificateWarnings.slice(-2)) + .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/renderedCommands.spec.js b/packages/dashmate/test/unit/renderedCommands.spec.js new file mode 100644 index 00000000000..6c8e1fa255e --- /dev/null +++ b/packages/dashmate/test/unit/renderedCommands.spec.js @@ -0,0 +1,474 @@ +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. + */ +/** + * 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', + '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 = [ + '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}`); + }); + }); + }); + }); + + // `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. + // + // 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 + * @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, 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 = []; + + 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(); + }); + + // 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(); + 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/checkGatewayCertificateFactory.spec.js b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js new file mode 100644 index 00000000000..d2d23b69cc9 --- /dev/null +++ b/packages/dashmate/test/unit/ssl/checkGatewayCertificateFactory.spec.js @@ -0,0 +1,633 @@ +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', () => { + // 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(leaf.pem + intermediate.pem + root.pem, 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. 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(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); + }); + + // 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 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); + }); + + // 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 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. + 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)); + + 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. + 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. + // 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]); + }); + + // 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); + install(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.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' }); + + 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 + // 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.include(CERTIFICATE_REASONS.IP_MISMATCH); + }); + + 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); + + const [reason] = checkGatewayCertificate(config).reasons + .filter(({ code }) => code === CERTIFICATE_REASONS.IP_MISMATCH); + + expect(reason.message).to.contain('names no address at all'); + }); + }); + + // 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 + */ + 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'); + }); +}); 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..ae9c73321e8 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,563 @@ 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, + {}, + ); + } + + /** + * 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('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, + // 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('reachable from the internet permanently'); + }); + + 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('reachable from the internet permanently'); + }); + + // 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; + + beforeEach(() => { + homeDir = HomeDir.createTemp(); + config = getBaseConfigFactory(homeDir)(); + config.set('externalIp', '1.2.3.4'); + }); + + 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]'); + }); + + // 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, 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 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. + 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.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'); + }); + + // 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(); + + // 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('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); + + // 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. + 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.match(/paused/i); + 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); + // Nor any of the guidance that belongs to a response from the authority. + 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('never contacted'); + 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 + // 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.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() { + const docker = getFailingDockerMock(this.sinon); + + const error = await buildFailingTask(this.sinon, docker)(config) + .run({ force: true }).catch((e) => e); + + 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 + // 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); + + 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('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('Do not keep retrying'); + 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); + + 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() { + 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..a3f969558b6 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,10 +39,77 @@ 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(); + }); + + // 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'); }); + // 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, + 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/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 new file mode 100644 index 00000000000..94fad0ed960 --- /dev/null +++ b/packages/dashmate/test/unit/ssl/renderCertificateGuidance.spec.js @@ -0,0 +1,519 @@ +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), + ]; + + // 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'); + }); + }); + + // 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'); + + 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', () => { + // 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(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. 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); + + // 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'); + }); + + // 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(); + + expect(output).to.contain('already uses Let\'s Encrypt'); + 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. + 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. + // 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 output = render(); + + const occurrences = (needle) => output.split(needle).length - 1; + + expect(occurrences('reachable from the internet 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 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); + }); + + // What the evidence does support: one operator, one day, three nodes. + expect(render()).to.contain('reachable from the internet permanently'); + }); + + // 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 + // 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.not.match(/future version|will not allow|4\.3/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 stopped'); + expect(output).to.contain('dashmate start --config base'); + }); + + // 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 stopped'); + expect(output).to.not.contain('dashmate restart --config base'); + expect(output).to.not.contain('dashmate start --config base'); + }); + + // 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.contain('may have changed'); + }); + + it('should not promise the node will start', () => { + const output = render({ obtainAttemptFailed: true, isNodeRunning: false }); + + expect(output).to.contain('may have changed'); + }); + + 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'); + }); + }); + + // 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 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('dashmate start --config base'); + }); + + 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'); + expect(render({ pull: { ok: false, failed: 0, total: 0 } })) + .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 + // 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('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 is not valid."); + }); + + // 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('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/); + }); + + // 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('Inbound port 80 is the most common cause'); + + // 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'); + }); + + it('should state that port 80 is permanent, never periodic', () => { + const output = render(); + + 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); + }); + + // 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('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'); + }); + + // 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. + 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 + // 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('To fix it'); + }); + + 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('--skip-certificate-check'); + }); +}); 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..18611f721a3 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,89 @@ 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() { + 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() { + 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(); + }); + }); }); 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/maskOperatorIdentity.spec.js b/packages/dashmate/test/unit/util/maskOperatorIdentity.spec.js new file mode 100644 index 00000000000..e7e36224fba --- /dev/null +++ b/packages/dashmate/test/unit/util/maskOperatorIdentity.spec.js @@ -0,0 +1,84 @@ +import maskOperatorIdentity from '../../../src/util/maskOperatorIdentity.js'; + +describe('maskOperatorIdentity', () => { + // 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'); + }); + }); + + // 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'); + }); + + it('should pass through anything that is not a string', () => { + expect(maskOperatorIdentity(42, { username: 'alice', homePath: '/home/alice' })).to.equal(42); + }); +}); 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?'); + }); +});