Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 14 additions & 10 deletions packages/dashmate/configs/defaults/getMainnetConfigFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,30 +32,34 @@ export default function getMainnetConfigFactory(homeDir, getBaseConfig) {
drive: {
tenderdash: {
p2p: {
// Registered evonodes with open platform p2p, verified working
// against the live evonode registry on 2026-08-30. Seeds rotate
// with the registry, so this list rots; the long-term fix is to
// generate it from the registry instead of hardcoding it.
seeds: [
{
id: '069639dfceec5f7c86257e6e9c46407c16ad1eab',
host: '34.211.174.194',
id: 'ee9ab93559e6e931d7dbcf269e1ea8446e7068e5',
host: '149.28.241.190',
port: 26656,
},
{
id: 'd46e2445642b2f94158ac3c2a6d90b88b83705b8',
host: '3.76.148.150',
id: '30918550e1f57eaff1b97f85adc8f4967065a16b',
host: '216.238.75.46',
port: 26656,
},
{
id: 'b08a650ecfac178939f21c0c12801eccaf18a5ea',
host: '3.0.60.103',
id: '6d9fe2b4f18b999521cf706e8c7b8559d4477e4c',
host: '89.125.209.110',
port: 26656,
},
{
id: '4cb4a8488eb1dbabda7fb79e47ac3c14eec73c4f',
host: '152.42.151.147',
id: 'dc812dc0e2e35a8a59491c5d20cba0390d045171',
host: '84.247.180.201',
port: 26656,
},
{
id: 'fdc2239c1e0e62f3a192823d6e068d012620a2d1',
host: 'seed-1.pshenmic.dev',
id: '3ed7bb4f1ed2f19cacd33f44a68b95d3f24cf85d',
host: '134.255.182.186',
port: 26656,
},
],
Expand Down
38 changes: 38 additions & 0 deletions packages/dashmate/configs/getConfigFileMigrationsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -1764,6 +1764,44 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs)

return configFile;
},
'4.2.0-dev.7': (configFile) => {
// All five mainnet tenderdash seeds shipped until now are dead
// (verified at the p2p layer on 2026-08-30): four fail the
// secret-connection handshake and one drops right after completing it,
// and none of their node IDs is in the current evonode registry. They
// still accept TCP on 26656, so a mainnet node carrying them stalls
// instead of failing loudly. Move configs still holding exactly the
// stock list onto the new defaults; a custom seed list is left alone.
const deadSeeds = [
'069639dfceec5f7c86257e6e9c46407c16ad1eab@34.211.174.194:26656',
'd46e2445642b2f94158ac3c2a6d90b88b83705b8@3.76.148.150:26656',
'b08a650ecfac178939f21c0c12801eccaf18a5ea@3.0.60.103:26656',
'4cb4a8488eb1dbabda7fb79e47ac3c14eec73c4f@152.42.151.147:26656',
'fdc2239c1e0e62f3a192823d6e068d012620a2d1@seed-1.pshenmic.dev:26656',
];

Object.entries(configFile.configs)
.forEach(([, options]) => {
if (options.network !== NETWORK_MAINNET) {
return;
}

const seeds = options.platform?.drive?.tenderdash?.p2p?.seeds;

if (!Array.isArray(seeds) || seeds.length !== deadSeeds.length) {
return;
}

const isStockList = seeds
.every((seed) => deadSeeds.includes(`${seed.id}@${seed.host}:${seed.port}`));
Comment on lines +1795 to +1796

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Check true set equality before replacing stored seeds

The length check followed by every(deadSeeds.includes(...)) permits duplicate entries and therefore does not establish set equality. For example, a five-entry custom list containing legacy seeds A, A, B, C, and D passes even though it omits E, so the migration replaces a list that is not equal to the stock defaults. The seed schema does not prohibit duplicates, and this replacement behavior was reproduced against the migration. Compare the unique stored addresses with every legacy default so custom lists remain untouched as promised.

Suggested change
const isStockList = seeds
.every((seed) => deadSeeds.includes(`${seed.id}@${seed.host}:${seed.port}`));
const storedSeeds = new Set(
seeds.map(({ id, host, port }) => `${id}@${host}:${port}`),
);
const isStockList = storedSeeds.size === deadSeeds.length
&& deadSeeds.every((seed) => storedSeeds.has(seed));

source: ['claude']


if (isStockList) {
options.platform.drive.tenderdash.p2p.seeds = mainnet.getStored('platform.drive.tenderdash.p2p.seeds');
}
});

return configFile;
},
};
}

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 @@ -33,6 +33,7 @@ import cancelCertificate from './ssl/zerossl/cancelCertificate.js';

import renderTemplateFactory from './templates/renderTemplateFactory.js';
import renderServiceTemplatesFactory from './templates/renderServiceTemplatesFactory.js';
import ensureTenderdashNodeKeyFactory from './tenderdash/ensureTenderdashNodeKeyFactory.js';
import writeServiceConfigsFactory from './templates/writeServiceConfigsFactory.js';

import DockerCompose from './docker/DockerCompose.js';
Expand Down Expand Up @@ -217,6 +218,7 @@ export default async function createDIContainer(options = {}) {
* Templates
*/
container.register({
ensureTenderdashNodeKey: asFunction(ensureTenderdashNodeKeyFactory).singleton(),
renderTemplate: asFunction(renderTemplateFactory).singleton(),
renderServiceTemplates: asFunction(renderServiceTemplatesFactory).singleton(),
writeServiceConfigs: asFunction(writeServiceConfigsFactory).singleton(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import * as glob from 'glob';
import { TEMPLATES_DIR } from '../constants.js';

/**
* @param {renderTemplate} renderTemplate
* @param {ensureTenderdashNodeKey} ensureTenderdashNodeKey
* @return {renderServiceTemplates}
*/
export default function renderServiceTemplatesFactory(renderTemplate) {
export default function renderServiceTemplatesFactory(renderTemplate, ensureTenderdashNodeKey) {
/**
* Render templates for services
*
Expand All @@ -14,6 +16,11 @@ export default function renderServiceTemplatesFactory(renderTemplate) {
* @return {Object<string,string>}
*/
function renderServiceTemplates(config) {
// node_key.json interpolates platform.drive.tenderdash.node.{id,key}
// literally, so a null key must be filled in before rendering or
// tenderdash panics at startup on the string "null".
ensureTenderdashNodeKey(config);

const templatePaths = glob.sync(`${TEMPLATES_DIR}/**/*.dot`, {
ignore: {
// Ignore manual rendered templates
Expand Down
105 changes: 105 additions & 0 deletions packages/dashmate/src/tenderdash/ensureTenderdashNodeKeyFactory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import generateTenderdashNodeKey from './generateTenderdashNodeKey.js';
import deriveTenderdashNodeId from './deriveTenderdashNodeId.js';

/**
* @param {ConfigFileJsonRepository} configFileRepository
* @return {ensureTenderdashNodeKey}
*/
export default function ensureTenderdashNodeKeyFactory(configFileRepository) {
/**
* Persist node identity values into the stored copy of the config, so a
* restart reuses the same identity instead of generating a new one. The
* config file is re-read under its lock, and a value that appeared there in
* the meantime wins over the one generated here.
*
* For a command holding the lock across its run this is an intermediate
* write: it persists the identity ahead of the command's own final save,
* without that command's other pending in-memory edits. Those still land
* with the final save; only if the command dies first does the identity
* outlive them - which is the point, since the rendered files already
* reference it.
*
* @param {Config} config
* @param {string} id
* @param {string} key
* @returns {void}
*/
function persistNodeIdentity(config, id, key) {
configFileRepository.update((configFile) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Do not save the migrated config during template rendering

During BaseCommand initialization, readAndMigrate() renders every migrated config before saving the migrated ConfigFile, specifically so a rendering failure leaves the old format on disk and the next command retries. This call to update() re-enters the repository while that callback is running, reads and migrates the old on-disk file again, and saves the target format version before the current template or the remaining configs finish rendering. If any write subsequently fails, the outer save is skipped, but the next command sees no migration due and does not repair the stale files or generate keys for the remaining configs. This was reproduced by throwing after rendering the first eligible config: config.json was already stamped 4.2.0, while another platform-enabled config still had a null Tenderdash key. Mutate the in-flight migrated config and let the outer migration save it only after all template writes succeed, rather than starting a nested repository update.

source: ['claude']

// A config not stored yet (a preset being set up) is persisted by the
// command that created it once it saves the config file it holds.
if (!configFile.isConfigExists(config.getName())) {
return;
}

const storedConfig = configFile.getConfig(config.getName());
const storedKey = storedConfig.get('platform.drive.tenderdash.node.key');

if (storedKey === null || storedKey === key) {
storedConfig.set('platform.drive.tenderdash.node.id', id);
storedConfig.set('platform.drive.tenderdash.node.key', key);
} else {
// Another process stored a different identity first; render with
// theirs, deriving the id when it is not stored either.
config.set(
'platform.drive.tenderdash.node.id',
storedConfig.get('platform.drive.tenderdash.node.id') ?? deriveTenderdashNodeId(storedKey),
);
config.set('platform.drive.tenderdash.node.key', storedKey);
}
});
}

/**
* Fill in a missing tenderdash node identity before service configs are
* rendered.
*
* The interactive setup wizard is the only flow that collects a node key, so
* a config assembled any other way (dashmate config create, non-interactive
* setup, enabling platform on an existing node) reaches template rendering
* with platform.drive.tenderdash.node.{id,key} still null, and node_key.json
* is written with the literal string "null" - tenderdash panics at startup.
* An existing key is never touched.
*
* @typedef {ensureTenderdashNodeKey}
* @param {Config} config
* @returns {void}
*/
function ensureTenderdashNodeKey(config) {
if (config.get('platform.enable') !== true) {
return;
}

// The base config is a template: a key generated for it would be cloned
// into every config created from it, and those must not share an identity.
if (config.getName() === 'base') {
return;
}

const existingKey = config.get('platform.drive.tenderdash.node.key');

if (existingKey !== null) {
// The id is derivable, so a config carrying a key without one is
// completed rather than rejected.
if (config.get('platform.drive.tenderdash.node.id') === null) {
const id = deriveTenderdashNodeId(existingKey);

config.set('platform.drive.tenderdash.node.id', id);

persistNodeIdentity(config, id, existingKey);
}

return;
}

const key = generateTenderdashNodeKey();
const id = deriveTenderdashNodeId(key);

config.set('platform.drive.tenderdash.node.id', id);
config.set('platform.drive.tenderdash.node.key', key);

persistNodeIdentity(config, id, key);
Comment on lines +95 to +101

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Persist generated node identities with private file permissions

This path automatically creates a private Ed25519 P2P identity for nodes that did not pass through the interactive wizard, but both persistence sinks use process-umask permissions. ConfigFileJsonRepository.#save() passes only an encoding when creating config.json, and writeServiceConfigsFactory does the same for the rendered node_key.json; HomeDir.createWithPathOrDefault() also creates the home directory without a private mode. With a typical umask of 022, a fresh run was verified to produce a mode-0755 home directory and mode-0644 config.json and node_key.json. Another local account can therefore read the key and impersonate the node's P2P identity or cause duplicate-identity connection disruption. Create both files with mode 0600 and explicitly tighten existing files with chmodSync; the Dashmate home and per-config directories should likewise prevent traversal by other users.

source: ['claude']

}

return ensureTenderdashNodeKey;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ describe('dynamic compose template', () => {
beforeEach(() => {
getBaseConfig = getBaseConfigFactory(HomeDir.createTemp());
const renderTemplate = renderTemplateFactory();
renderServiceTemplates = renderServiceTemplatesFactory(renderTemplate);
const ensureTenderdashNodeKey = () => {};
renderServiceTemplates = renderServiceTemplatesFactory(
renderTemplate,
ensureTenderdashNodeKey,
);
});

it('should not publish metrics port when rs-dapi metrics are disabled', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ describe('envoy template', () => {
config.set('platform.gateway.admin.enabled', false);

const renderTemplate = renderTemplateFactory();
const renderServiceTemplates = renderServiceTemplatesFactory(renderTemplate);
const ensureTenderdashNodeKey = () => {};
const renderServiceTemplates = renderServiceTemplatesFactory(
renderTemplate,
ensureTenderdashNodeKey,
);
const renderedConfigs = renderServiceTemplates(config);

const envoyConfig = renderedConfigs['platform/gateway/envoy.yaml'];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import ensureTenderdashNodeKeyFactory from '../../../src/tenderdash/ensureTenderdashNodeKeyFactory.js';
import renderServiceTemplatesFactory from '../../../src/templates/renderServiceTemplatesFactory.js';
import deriveTenderdashNodeId from '../../../src/tenderdash/deriveTenderdashNodeId.js';
import generateTenderdashNodeKey from '../../../src/tenderdash/generateTenderdashNodeKey.js';
import validateTenderdashNodeKey from '../../../src/listr/prompts/validators/validateTenderdashNodeKey.js';
import Config from '../../../src/config/Config.js';
import createDIContainer from '../../../src/createDIContainer.js';

describe('ensureTenderdashNodeKeyFactory', () => {
let container;
let config;
let storedConfig;
let configFileRepository;
let ensureTenderdashNodeKey;

const NODE_ID_PATH = 'platform.drive.tenderdash.node.id';
const NODE_KEY_PATH = 'platform.drive.tenderdash.node.key';

beforeEach(async function beforeEach() {
container = await createDIContainer();

const defaultConfigs = container.resolve('defaultConfigs');

config = new Config('testnet', defaultConfigs.get('testnet').getStoredOptions());
config.set('platform.enable', true);

// The stored copy the repository would read back from disk
storedConfig = new Config('testnet', config.getStoredOptions());

const configFile = {
isConfigExists: this.sinon.stub().returns(true),
getConfig: this.sinon.stub().returns(storedConfig),
};

configFileRepository = {
update: this.sinon.stub().callsFake((mutate) => mutate(configFile)),
};

ensureTenderdashNodeKey = ensureTenderdashNodeKeyFactory(configFileRepository);
});

it('should generate and persist a valid node key when the stored key is null', () => {
expect(config.get(NODE_KEY_PATH)).to.equal(null);

ensureTenderdashNodeKey(config);

const key = config.get(NODE_KEY_PATH);

expect(key).to.be.a('string');
expect(validateTenderdashNodeKey(key)).to.equal(true);
expect(config.get(NODE_ID_PATH)).to.equal(deriveTenderdashNodeId(key));

// Persisted into the stored copy so a restart reuses the same identity
expect(configFileRepository.update).to.have.been.calledOnce();
expect(storedConfig.get(NODE_KEY_PATH)).to.equal(key);
expect(storedConfig.get(NODE_ID_PATH)).to.equal(config.get(NODE_ID_PATH));
});

it('should never regenerate an existing node key', () => {
const existingKey = generateTenderdashNodeKey();
const existingId = deriveTenderdashNodeId(existingKey);

config.set(NODE_ID_PATH, existingId);
config.set(NODE_KEY_PATH, existingKey);

ensureTenderdashNodeKey(config);

expect(config.get(NODE_KEY_PATH)).to.equal(existingKey);
expect(config.get(NODE_ID_PATH)).to.equal(existingId);
expect(configFileRepository.update).to.have.not.been.called();
});

it('should derive and persist a missing node id from an existing key', () => {
const existingKey = generateTenderdashNodeKey();

config.set(NODE_KEY_PATH, existingKey);
storedConfig.set(NODE_KEY_PATH, existingKey);

ensureTenderdashNodeKey(config);

expect(config.get(NODE_KEY_PATH)).to.equal(existingKey);
expect(config.get(NODE_ID_PATH)).to.equal(deriveTenderdashNodeId(existingKey));
expect(storedConfig.get(NODE_ID_PATH)).to.equal(deriveTenderdashNodeId(existingKey));
});

it('should adopt an identity another process stored first', () => {
const winningKey = generateTenderdashNodeKey();
const winningId = deriveTenderdashNodeId(winningKey);

storedConfig.set(NODE_ID_PATH, winningId);
storedConfig.set(NODE_KEY_PATH, winningKey);

ensureTenderdashNodeKey(config);

expect(config.get(NODE_KEY_PATH)).to.equal(winningKey);
expect(config.get(NODE_ID_PATH)).to.equal(winningId);
});

it('should not touch a config with platform disabled', () => {
config.set('platform.enable', false);

ensureTenderdashNodeKey(config);

expect(config.get(NODE_KEY_PATH)).to.equal(null);
expect(configFileRepository.update).to.have.not.been.called();
});

it('should not generate a key for the base template config', () => {
const baseConfig = new Config('base', config.getStoredOptions());

ensureTenderdashNodeKey(baseConfig);

expect(baseConfig.get(NODE_KEY_PATH)).to.equal(null);
expect(configFileRepository.update).to.have.not.been.called();
});

it('should render node_key.json with a generated key instead of "null"', () => {
// Regression: a fullnode configured outside the interactive setup wizard
// reached template rendering with a null node key, and node_key.json was
// written with the literal string "null" - tenderdash panicked at startup.
const renderTemplate = container.resolve('renderTemplate');
const renderServiceTemplates = renderServiceTemplatesFactory(
renderTemplate,
ensureTenderdashNodeKey,
);

const serviceConfigs = renderServiceTemplates(config);

const nodeKeyFile = JSON.parse(serviceConfigs['platform/drive/tenderdash/node_key.json']);

expect(nodeKeyFile.priv_key.value).to.equal(config.get(NODE_KEY_PATH));
expect(nodeKeyFile.priv_key.value).to.not.equal('null');
expect(nodeKeyFile.id).to.equal(config.get(NODE_ID_PATH));
expect(validateTenderdashNodeKey(nodeKeyFile.priv_key.value)).to.equal(true);
});
});
Loading