Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
a00c5ac
chore: update gRPC queries cache [skip ci]
github-actions[bot] Jun 4, 2026
37ea011
Merge remote-tracking branch 'origin/v4.1-dev'
shumkov Jul 27, 2026
cc03079
Merge remote-tracking branch 'origin/v4.2-dev' into ssl
shumkov Aug 20, 2026
29276af
feat(dashmate): detect whether a session can answer a prompt
shumkov Aug 21, 2026
4a525bd
feat(dashmate): judge the certificate installed for the gateway
shumkov Aug 21, 2026
9b2b710
feat(dashmate): stop asking for an email, retry port 80, never prompt…
shumkov Aug 21, 2026
e2ee5a4
feat(dashmate): check the gateway certificate on update
shumkov Aug 21, 2026
51d0d18
fix(dashmate): report certificate problems doctor could not see
shumkov Aug 21, 2026
e92c4fd
test(dashmate): prove contactless issuance against a real ACME server
shumkov Aug 21, 2026
73bbc40
fix(dashmate): honour the JSON contract and stop the preflight claimi…
shumkov Aug 21, 2026
d6ec6af
refactor(dashmate): one rule for reading a boolean environment variable
shumkov Aug 21, 2026
01fcd60
fix(dashmate): stop warning about ZeroSSL after switching away from it
shumkov Aug 21, 2026
2d06357
style(dashmate): drop lint noise this change introduced
shumkov Aug 21, 2026
1a07767
fix(dashmate): five defects found by cross-model review
shumkov Aug 21, 2026
77d81d6
fix(dashmate): honest operator messaging and a private doctor archive
shumkov Aug 21, 2026
ce0095c
test(dashmate): pin the interactivity guards distinctly from the prom…
shumkov Aug 21, 2026
b4861d3
fix(dashmate): make the preflight decline rather than migrate unlocked
shumkov Aug 21, 2026
09f9709
fix(dashmate): reserve the migration error for a version that is actu…
shumkov Aug 21, 2026
6dfc75a
fix(dashmate): mask the operator's name even when the environment omi…
shumkov Aug 21, 2026
3c6bad7
fix(dashmate): let the preflight judge a config it would have to migrate
shumkov Aug 21, 2026
4ab84e4
fix(dashmate): tell a port conflict apart from a firewall, and say po…
shumkov Aug 21, 2026
ce2b710
fix(dashmate): stop offering a switch to the provider the node alread…
shumkov Aug 21, 2026
7ec0724
fix(dashmate): stop doctor advising a restart that would take a node …
shumkov Aug 21, 2026
51508ec
fix(dashmate): stop redaction blanking dashmate out of its own messages
shumkov Aug 21, 2026
3dbadce
fix(dashmate): judge and migrate the configuration from one snapshot
shumkov Aug 21, 2026
9a080b4
fix(dashmate): state the port 80 argument once, and test what is rend…
shumkov Aug 21, 2026
657529d
fix(dashmate): separate what the authority said from what it was neve…
shumkov Aug 21, 2026
2c37c63
fix(dashmate): compare the two certificates on the expired branch too
shumkov Aug 21, 2026
86eb80c
fix(dashmate): rewrite the home path only where it ends
shumkov Aug 21, 2026
ac759a0
fix(dashmate): stop retrying an issuance that already succeeded
shumkov Aug 21, 2026
50fb902
fix(dashmate): advise a restart only when the disk copy can actually …
shumkov Aug 21, 2026
c920176
fix(dashmate): require proof the disk pair is sound before advising a…
shumkov Aug 21, 2026
8d1edec
test(dashmate): pin that the verdict and the wire sample describe one…
shumkov Aug 21, 2026
1371288
fix(dashmate): say what failed the check, and stop tests inheriting a…
shumkov Aug 21, 2026
0623347
fix(dashmate): drop the removed Commit override for prerelease configs
shumkov Aug 21, 2026
7d83d1d
fix(dashmate): stop prescribing a fix that cannot work for a hijacked…
shumkov Aug 21, 2026
2b33981
fix(dashmate): require the key's certificate to be the bundle's first…
shumkov Aug 21, 2026
3aceeeb
fix(dashmate): match the node address against the SAN only
shumkov Aug 21, 2026
7e01230
fix(dashmate): reject a certificate whose validity has not started
shumkov Aug 21, 2026
5d5b444
fix(dashmate): stop telling operators to restart after obtaining a ce…
shumkov Aug 21, 2026
f4cb3dc
style(dashmate): flatten the identity branch and unshadow its detail
shumkov Aug 21, 2026
8d29701
fix(dashmate): stop claiming a failed attempt changed nothing
shumkov Aug 21, 2026
5140e70
style(dashmate): name the migration specs' local version binding in c…
shumkov Aug 21, 2026
adf6fb0
fix(dashmate): offer the ZeroSSL switch when the certificate is nearl…
shumkov Aug 21, 2026
e9f517c
fix(dashmate): reject a bundle whose certificate blocks are truncated
shumkov Aug 21, 2026
f85256c
fix(dashmate): stop the switch offer asserting things the verdict doe…
shumkov Aug 21, 2026
2a9df46
docs(dashmate): drop dev-time narration from the certificate test com…
shumkov Aug 21, 2026
7e80c1f
fix(dashmate): treat a PEM delimiter as a line, not as a substring
shumkov Aug 21, 2026
e3a270b
fix(dashmate): say only what the verdict established in the switch offer
shumkov Aug 21, 2026
e941198
docs(dashmate): state what the certificate comments protect, not how …
shumkov Aug 21, 2026
7805de2
fix(dashmate): accept delimiter lines padded with trailing whitespace
shumkov Aug 21, 2026
17cb102
fix(dashmate): stop advising a restart that reloads the stale certifi…
shumkov Aug 21, 2026
078e070
refactor(dashmate): tell an operator what to do, and check the certif…
shumkov Aug 22, 2026
7272f5c
refactor(dashmate): cut the certificate messages down to what to do
shumkov Aug 22, 2026
a564f0e
refactor(dashmate): stop reporting a missing contact address as a pro…
shumkov Aug 22, 2026
52efd33
feat(dashmate): warn that a certificate will become required to start…
shumkov Aug 22, 2026
3aa7cdd
fix(dashmate): print the repair command that actually repairs
shumkov Aug 23, 2026
50b1dd0
fix(dashmate): judge a certificate the same way wherever it is judged
shumkov Aug 23, 2026
4346d05
refactor(dashmate): tell an operator what is wrong, not which check n…
shumkov Aug 24, 2026
5cec821
fix(dashmate): prescribe one repair command per certificate
shumkov Aug 24, 2026
45d2442
fix(dashmate): do not pass a node whose address was never checked
shumkov Aug 24, 2026
9596d9d
fix(dashmate): set the address before prescribing a certificate
shumkov Aug 24, 2026
b3ed62c
fix(dashmate): ask for the address before reading the certificate
shumkov Aug 24, 2026
1736a37
fix(dashmate): prescribe repairs that can actually run
shumkov Aug 24, 2026
9e829ae
fix(dashmate): do not blame the authority when the dates are what failed
shumkov Aug 24, 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
13 changes: 6 additions & 7 deletions packages/dashmate/configs/getConfigFileMigrationsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
});

Expand Down
42 changes: 30 additions & 12 deletions packages/dashmate/src/commands/ssl/obtain.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -46,20 +47,22 @@ 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,
configFileRepository,
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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
Expand Down
255 changes: 229 additions & 26 deletions packages/dashmate/src/commands/update.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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<void>}
*/
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;
}
Comment thread
shumkov marked this conversation as resolved.

// 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;
}
}
Loading
Loading