Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
929800a
fix(dashmate): restore the report date as a date, not a string
shumkov Aug 24, 2026
58ecedb
feat(dashmate): name why a certificate renewal failed
shumkov Aug 24, 2026
010619a
feat(dashmate): persist the outcome of every scheduled renewal
shumkov Aug 24, 2026
28275be
feat(dashmate): tell an operator why renewal is failing, before the n…
shumkov Aug 24, 2026
27d6923
feat(dashmate): let update name the cause too, from the same vocabulary
shumkov Aug 24, 2026
78499ef
fix(dashmate): never prescribe a certificate request the recorded cau…
shumkov Aug 24, 2026
791c87f
fix(dashmate): correct four defects in the renewal record, and pin wh…
shumkov Aug 24, 2026
e2b542d
fix(dashmate): keep bookkeeping from failing an install, and narrow w…
shumkov Aug 24, 2026
c0b5b6d
refactor(dashmate): give the renewal record a model and a repository
shumkov Aug 25, 2026
cb76557
docs(dashmate): point operators at the port 80 guide, and repair thre…
shumkov Aug 25, 2026
790108f
fix(dashmate): three defects a second review found in the renewal record
shumkov Aug 25, 2026
38d7418
perf(dashmate): bound a recorded excerpt before scanning it
shumkov Aug 25, 2026
ec45fb2
refactor(dashmate): name new modules in kebab-case, per the package g…
shumkov Aug 25, 2026
77fd674
fix(dashmate): seven more defects, four of them in guidance I had jus…
shumkov Aug 25, 2026
e070096
fix(dashmate): two blocking defects and a shared guidance state, agre…
shumkov Aug 25, 2026
309e1af
revert(dashmate): drop unrelated lint autofixes from this branch
shumkov Aug 25, 2026
3b0f1de
revert(dashmate): drop the remaining lint autofix collateral
shumkov Aug 25, 2026
4367fa2
fix(dashmate): repair four defects the fence and the shared state int…
shumkov Aug 25, 2026
0c2e85b
revert(dashmate): drop the last of the lint autofix collateral, in test
shumkov Aug 25, 2026
b1459ae
fix(dashmate): make one decision the only thing that prescribes a cer…
shumkov Aug 25, 2026
fd88826
style(dashmate): drop a stray blank line
shumkov Aug 25, 2026
c5fc461
fix(dashmate): give the shared decision the input it was missing, and…
shumkov Aug 25, 2026
fc284dd
fix(dashmate): keep the certificate authority's reason for a failed r…
shumkov Aug 25, 2026
d9f8450
fix(dashmate): stop responder-supplied text choosing an operator's ne…
shumkov Aug 27, 2026
60b53d7
fix(dashmate): stop offering a certificate that could not be saved
shumkov Aug 27, 2026
99a8865
fix(dashmate): make the storage veto reachable, and honour it everywhere
shumkov Aug 27, 2026
522a52d
fix(dashmate): one place decides whether to ask for a certificate
shumkov Aug 27, 2026
911acbf
feat(dashmate): stop naming the config an operator is already on
shumkov Aug 27, 2026
2c514d5
Revert "feat(dashmate): stop naming the config an operator is already…
shumkov Aug 27, 2026
95a978d
fix(dashmate): close the review findings on the renewal record
shumkov Aug 27, 2026
4e6475d
fix(dashmate): confirm the log attachment before waiting on lego
shumkov Aug 27, 2026
8ce253b
fix(dashmate): stop four ways a request outlived the decision withhol…
shumkov Aug 28, 2026
7fed9a9
fix(dashmate): hold the fence through the write, and let the switch r…
shumkov Aug 28, 2026
83dcec9
fix(dashmate): reclaim a fence only from a holder that is gone
shumkov Aug 28, 2026
f55052d
revert(dashmate): reclaim the fence by age again, and state what that…
shumkov Aug 28, 2026
71e42ca
fix(dashmate): never hand back a generation that is not a number
shumkov Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/dashmate/src/commands/update.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export default class UpdateCommand extends ConfigBaseCommand {
checkGatewayCertificate,
gatewayCertificateTask,
dockerCompose,
renewalRecordRepository,
) {
const {
format,
Expand All @@ -85,6 +86,7 @@ export default class UpdateCommand extends ConfigBaseCommand {
config,
verdict,
dockerCompose,
renewalRecordRepository,
pull: this.pullResult ?? null,
obtainAttemptFailed,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import ConfigFileNotFoundError from '../errors/ConfigFileNotFoundError.js';
import InvalidConfigFileFormatError from '../errors/InvalidConfigFileFormatError.js';
import configFileJsonSchema from './configFileJsonSchema.js';
import ConfigFile from './ConfigFile.js';
import ConfigurationLockLostError from '../../ssl/errors/ConfigurationLockLostError.js';

/**
* How long a lock may go un-refreshed before another process may break it.
Expand Down Expand Up @@ -229,7 +230,7 @@ export default class ConfigFileJsonRepository {
this.#save(configFile);

if (!this.isExclusive()) {
throw new Error('Lost the configuration lock after saving the config file;'
throw new ConfigurationLockLostError('Lost the configuration lock after saving the config file;'
+ ' follow-up filesystem changes were not run. Re-run the command.');
}

Expand Down Expand Up @@ -274,7 +275,7 @@ export default class ConfigFileJsonRepository {

return this.#locked(() => {
if (!this.isExclusive()) {
throw new Error('Lost the configuration lock before the config file was migrated.');
throw new ConfigurationLockLostError('Lost the configuration lock before the config file was migrated.');
}

// Another process may have migrated or changed the file while this
Expand Down Expand Up @@ -535,7 +536,7 @@ export default class ConfigFileJsonRepository {
}

if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for configuration lock '${this.lockFilePath}'.`
throw new ConfigurationLockLostError(`Timed out waiting for configuration lock '${this.lockFilePath}'.`
+ ' It may be held by a Dashmate command, the dashmate helper during certificate'
+ ' renewal, or a running reindex. An abandoned lock after SIGKILL or power loss'
+ ' clears itself after about a minute; do not remove it manually while another'
Expand Down
2 changes: 2 additions & 0 deletions packages/dashmate/src/createDIContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import getServiceListFactory from './docker/getServiceListFactory.js';
import ensureFileMountExistsFactory from './docker/ensureFileMountExistsFactory.js';
import getConnectionHostFactory from './docker/getConnectionHostFactory.js';
import ConfigFileJsonRepository from './config/configFile/ConfigFileJsonRepository.js';
import RenewalRecordRepository from './ssl/renewalRecord/RenewalRecordRepository.js';
import createConfigFileFactory from './config/configFile/createConfigFileFactory.js';
import migrateConfigFileFactory from './config/configFile/migrateConfigFileFactory.js';
import DefaultConfigs from './config/DefaultConfigs.js';
Expand Down Expand Up @@ -166,6 +167,7 @@ export default async function createDIContainer(options = {}) {
legoCaCertificatePath: asValue(null),
legoContainerOptions: asValue({}),
configFileRepository: asClass(ConfigFileJsonRepository).singleton(),
renewalRecordRepository: asClass(RenewalRecordRepository).singleton(),
getBaseConfig: asFunction(getBaseConfigFactory).singleton(),
getLocalConfig: asFunction(getLocalConfigFactory).singleton(),
getTestnetConfig: asFunction(getTestnetConfigFactory).singleton(),
Expand Down
34 changes: 34 additions & 0 deletions packages/dashmate/src/docsLinks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Every documentation link dashmate prints, in one place.
*
* Gathered because they were repeated inline: the SSL-certificates anchor
* appeared three times in one file alone, and the certificate troubleshooting
* article was a constant in another. A link that lives in several places is one
* that gets updated in some of them - and dashmate has already shipped links
* that answered 404, which costs the command the credibility it needs at the
* moment an operator is following its instructions.
*
* Two forms appear here, deliberately. `docs.dash.org/<slug>` is a redirect
* configured in the documentation site's dashboard; those exist for some pages
* and not others. Where no redirect was created, the published path is used
* instead - it resolves today, and a link that resolves beats a shorter one
* that does not.
*/
export const DOCS_LINKS = {
/** Choosing and configuring a certificate provider. */
SSL_CERTIFICATES: 'https://docs.dash.org/en/stable/docs/user/masternodes/setup-evonode.html#ssl-certificates',

/** Why renewal fails, and what to do about each cause. */
CERTIFICATE_TROUBLESHOOTING: 'https://docs.dash.org/en/stable/docs/user/masternodes/troubleshooting-certificates.html',

/** Registering an evonode's collateral from Dash Core. */
EVONODE_COLLATERAL: 'https://docs.dash.org/evonode-setup-core-collateral',

/** Registering a masternode's collateral from Dash Core. */
MASTERNODE_COLLATERAL: 'https://docs.dash.org/mn-setup-core-collateral',

/** Dash Masternode Tool. */
DMT_SETUP: 'https://docs.dash.org/dmt-setup',
};

export default DOCS_LINKS;
72 changes: 68 additions & 4 deletions packages/dashmate/src/doctor/analyse/analyseConfigFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { ERRORS as ZEROSSL_ERRORS } from '../../ssl/zerossl/validateZeroSslCerti
import { SEVERITY } from '../Prescription.js';
import Problem from '../Problem.js';
import renderConfigFlag from '../../util/renderConfigFlag.js';
import { DOCS_LINKS } from '../../docsLinks.js';
import { RENEWAL_RECORD_STATES } from '../../ssl/renewalRecord/RenewalRecordRepository.js';
import RenewalRecord from '../../ssl/renewalRecord/RenewalRecord.js';
import deriveRenewalGuidance, { SAFE_ACTION } from '../../ssl/renewalGuidance.js';
import renderObtainCommand from '../../ssl/renderObtainCommand.js';

/**
* Whether a ZeroSSL certificate can be renewed depends on the operator's plan, which dashmate
Expand All @@ -16,6 +21,60 @@ of charge:
{bold.cyanBright dashmate config set platform.gateway.ssl.providerConfigs.letsencrypt.email EMAIL}
{bold.cyanBright dashmate ssl obtain}`;

/**
* What to say instead of a certificate request, when the renewal record forbids
* one - or nothing, when it does not.
*
* The legacy checks below cannot be left to decide this for themselves. They
* predate the record entirely, and each one ends in its own command.
*
* @param {Samples} samples
* @param {Config} config
* @return {string|null}
*/
function withheldRequest(samples, config) {
const sample = samples.getServiceInfo('gateway', 'certificateRenewal');
const record = sample?.state === RENEWAL_RECORD_STATES.PRESENT
? RenewalRecord.fromObject(sample)
: null;

// A record left by a provider this node no longer uses says nothing about the
// one it does. The configuration watcher hands over without clearing it, so
// without this a stale spent or uncertain record would suppress a request
// that is now perfectly valid - the renewal-aware analyser already ignores it
// for exactly that reason, and these two must not disagree.
// The same two inputs the renewal-aware analyser uses. Without the installed
// certificate's date, a failure that a newer certificate has already overtaken
// still counts here - so this analyser would replace a valid repair with stale
// no-obtain guidance while the other one correctly ignored the same record.
const applicable = record?.isFailed()
&& record.appliesTo({
provider: config.get('platform.gateway.ssl.provider'),
certificateValidFrom: samples.getServiceInfo('gateway', 'installedCertificate')?.validFrom
?? null,
})
? record
: null;

const guidance = deriveRenewalGuidance({
record: applicable,
isRecordUnreadable: sample?.state === RENEWAL_RECORD_STATES.UNREADABLE
|| (sample?.state === RENEWAL_RECORD_STATES.PRESENT && record === null),
isCertificateUsable: false,
});

// A provider switch forbids these remedies just as firmly as an outright
// refusal: they ask this provider for another certificate, while the
// renewal-aware analyser in the same report says this provider will never
// issue one again.
if (guidance.safeAction !== SAFE_ACTION.DO_NOT_OBTAIN
&& guidance.safeAction !== SAFE_ACTION.SWITCH_PROVIDER) {
return null;
}

return renderObtainCommand({ configName: config.getName(), guidance });
Comment thread
shumkov marked this conversation as resolved.
}

export default function analyseConfigFactory() {
/**
* @typedef analyseConfig
Expand Down Expand Up @@ -52,7 +111,7 @@ export default function analyseConfigFactory() {
if (config.get('network') !== NETWORK_LOCAL) {
const problem = new Problem(
'SSL certificates are disabled. Clients won\'t be able to connect securely',
chalk`Please enable and set up SSL certificates {bold.cyanBright https://docs.dash.org/en/stable/masternodes/dashmate.html#ssl-certificate}`,
chalk`Please enable and set up SSL certificates {bold.cyanBright ${DOCS_LINKS.SSL_CERTIFICATES}}`,
SEVERITY.HIGH,
);

Expand All @@ -63,7 +122,7 @@ export default function analyseConfigFactory() {
if (config.get('network') === NETWORK_MAINNET) {
const problem = new Problem(
'Self-signed SSL certificate is used on mainnet. Clients won\'t be able to connect securely',
chalk`Please use valid SSL certificates {bold.cyanBright https://docs.dash.org/en/stable/masternodes/dashmate.html#ssl-certificate}`,
chalk`Please use valid SSL certificates {bold.cyanBright ${DOCS_LINKS.SSL_CERTIFICATES}}`,
SEVERITY.HIGH,
);

Expand All @@ -88,7 +147,7 @@ Private key file path: {bold.cyanBright ${ssl?.data?.privateFilePath}}`,
Certificate chain file path: {bold.cyanBright ${ssl?.data?.chainFilePath}}
Private key file path: {bold.cyanBright ${ssl?.data?.privateFilePath}}

Or use ZeroSSL https://docs.dash.org/en/stable/masternodes/dashmate.html#ssl-certificate`,
Or use ZeroSSL ${DOCS_LINKS.SSL_CERTIFICATES}`,
},
};

Expand Down Expand Up @@ -211,9 +270,14 @@ a working certificate.`,
}[ssl.error] ?? {};

if (description) {
// These checks predate the renewal record and each ends in its
// own request. They run before the renewal-aware analyser in the
// same report, so a node whose recorded cause forbids asking
// again would read "do not obtain" from one and a runnable
// command from the other - and follow the command.
const problem = new Problem(
description,
solution,
withheldRequest(samples, config) ?? solution,
severity,
);

Expand Down
Loading
Loading