Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b7a53e3
fix(dashmate)!: stop read-only commands rewriting config and clobberi…
shumkov Jul 28, 2026
ef34389
fix(dashmate): do not let lock bookkeeping mask a config write outcome
shumkov Jul 28, 2026
2358015
test(dashmate): stop the lock-contention test burning a core and leak…
shumkov Jul 28, 2026
e23dbdf
fix(dashmate): render service templates only for configs that changed
shumkov Jul 28, 2026
7abb4c3
fix(dashmate)!: read, change and save config as one locked step
shumkov Jul 30, 2026
95611b9
fix(dashmate): change config through one locked step in every config …
shumkov Jul 30, 2026
dd795e9
fix(dashmate): hold the config lock for commands that reconfigure a node
shumkov Jul 30, 2026
7514eff
test(dashmate): cover the config lock paths that only happen after a …
shumkov Jul 30, 2026
aae3308
fix(dashmate): make one locked step the only way config reaches disk
shumkov Jul 30, 2026
c907b93
fix(dashmate): narrow to the config paths this change can carry
shumkov Jul 30, 2026
949428b
fix(dashmate): keep reads lock-free and every config mutation under t…
shumkov Jul 31, 2026
3244932
fix(dashmate): renew certificates against current config under the lock
shumkov Jul 31, 2026
c1bacb7
fix(dashmate): close the gaps a full review found in the config lock
shumkov Aug 7, 2026
4865cd8
feat(dashmate): obtain certificates from a configurable ACME directory
shumkov Aug 8, 2026
ce68da9
Merge remote-tracking branch 'origin/v4.2-dev' into fix/dashmate/4242…
shumkov Aug 8, 2026
77feca8
fix(dashmate): render service files under a lock this process still h…
shumkov Aug 10, 2026
c3c5cfe
fix(dashmate): decide a migration is due without running one
shumkov Aug 10, 2026
f8470f8
fix(dashmate): finish a render that a killed process left half-done
shumkov Aug 11, 2026
1c40b02
fix(dashmate): keep a removed config's files out of a re-creatable name
shumkov Aug 11, 2026
ca5fcac
fix(dashmate): make interrupted config changes recoverable by their o…
shumkov Aug 11, 2026
03ea38a
fix(dashmate): keep an upgrade loadable and a lost lease's work recov…
shumkov Aug 11, 2026
470bc4c
fix(dashmate): stop config remove deleting the config file itself
shumkov Aug 11, 2026
4b055da
fix(dashmate): decide by filesystem, not by name, and keep each rescue
shumkov Aug 11, 2026
54f1883
fix(dashmate)!: stop recovering crashes by deleting things
shumkov Aug 11, 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
4 changes: 3 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,9 @@ jobs:
package: ${{ matrix.js-package }}
# The platform test suite's default command drives a live network, so it
# runs from the E2E jobs. Its unit tests need nothing and run here.
test-command: ${{ (matrix.js-package == 'dashmate' || contains(matrix.js-package, 'platform-test-suite')) && 'test:unit' || 'test' }}
# Dashmate also runs its integration tests here: they need Docker, which
# this runner has, but not a live network.
test-command: ${{ matrix.js-package == 'dashmate' && 'test:ci' || (contains(matrix.js-package, 'platform-test-suite') && 'test:unit' || 'test') }}
direct-packages: ${{ needs.changes.outputs.js-packages-direct }}

js-deps-versions:
Expand Down
23 changes: 22 additions & 1 deletion .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Binary file not shown.
2 changes: 2 additions & 0 deletions packages/dashmate/configs/defaults/getBaseConfigFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import {
NETWORK_MAINNET,
PACKAGE_ROOT_DIR,
LETSENCRYPT_ACME_DIRECTORY_URL,
} from '../../src/constants.js';

const { version } = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT_DIR, 'package.json'), 'utf8'));
Expand All @@ -15,7 +16,7 @@
*/
export default function getBaseConfigFactory() {
const prereleaseTag = semver.prerelease(version) === null ? '' : `-${semver.prerelease(version)[0]}`;
const dockerImageVersion = `${semver.major(version)}${prereleaseTag}`;

Check warning on line 19 in packages/dashmate/configs/defaults/getBaseConfigFactory.js

View workflow job for this annotation

GitHub Actions / JS packages (dashmate) / Linting

'dockerImageVersion' is assigned a value but never used

/**
* @typedef {function} getBaseConfig
Expand Down Expand Up @@ -255,6 +256,7 @@
},
letsencrypt: {
email: null,
acmeDirectoryUrl: LETSENCRYPT_ACME_DIRECTORY_URL,
},
},
},
Expand Down
18 changes: 18 additions & 0 deletions packages/dashmate/configs/getConfigFileMigrationsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -1701,6 +1701,24 @@ 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]) => {
const providerConfigs = options.platform?.gateway?.ssl?.providerConfigs;

if (providerConfigs?.letsencrypt
&& providerConfigs.letsencrypt.acmeDirectoryUrl === undefined) {
providerConfigs.letsencrypt.acmeDirectoryUrl = base.get(
'platform.gateway.ssl.providerConfigs.letsencrypt.acmeDirectoryUrl',
);
}
});

return configFile;
},
};
}

Expand Down
19 changes: 19 additions & 0 deletions packages/dashmate/docs/config/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,27 @@ These settings control SSL/TLS for secure connections:
| `platform.gateway.ssl.providerConfigs.zerossl.apiKey` | ZeroSSL API key | `null` | `"your-api-key"` |
| `platform.gateway.ssl.providerConfigs.zerossl.id` | ZeroSSL certificate ID | `null` | `"certificate_id"` |

### Let's Encrypt Provider Configuration

| Option | Description | Default | Example |
|--------|-------------|---------|---------|
| `platform.gateway.ssl.providerConfigs.letsencrypt.email` | Contact address for expiry notices | `null` | `"admin@example.com"` |
| `platform.gateway.ssl.providerConfigs.letsencrypt.acmeDirectoryUrl` | ACME directory certificates are requested from | `https://acme-v02.api.letsencrypt.org/directory` | `https://acme-staging-v02.api.letsencrypt.org/directory` |

A node is identified by its external IP rather than a domain name, and Let's Encrypt
issues IP address certificates only under its short-lived profile, so these
certificates are valid for about six days and are renewed automatically by the
Dashmate helper.

Point `acmeDirectoryUrl` at the staging directory to rehearse issuance. Production
allows only a few failed validations per hour, and the usual cause of failure —
inbound port 80 being unreachable — takes several attempts to sort out. Certificates
from staging are not publicly trusted, so set it back afterwards and obtain again
with `dashmate ssl obtain --force`.

Available SSL providers:
- `zerossl`: Commercial certificate provider with automated issuance
- `letsencrypt`: Free certificates issued over ACME; requires inbound port 80 during issuance and renewal
- `selfSigned`: Self-signed certificates (not trusted by browsers)
- `file`: Use existing certificate files (requires certificate and key files to be manually provided)

Expand Down
68 changes: 68 additions & 0 deletions packages/dashmate/docs/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,71 @@ dashmate config get <option>
# Enable debug logging
dashmate config set core.log.debug.enabled true
```

## Running Dashmate commands concurrently

Dashmate keeps all configuration in a single `config.json` inside its home directory
(`~/.dashmate` by default).

Commands that change a configuration option — `dashmate config set` and friends — read,
change and save that file as one locked step. Two of them running at once cannot lose each
other's work: if one sets a Core RPC port while another pins a Drive image, both settings
survive. When no long-running operation owns the lock, a command waits only for the locked
read and write.

Read-only commands such as `dashmate config get`, `dashmate status` and `dashmate core cli`
normally do not write configuration. The first command after an upgrade may migrate and save
`config.json`; that migration needs the same lock and can time out behind a long-running
configuration change.

### While a node is being reconfigured

`dashmate setup`, `dashmate reset`, `dashmate group reset`, `dashmate ssl obtain`,
`dashmate core reindex` and `dashmate group core reindex` change or render configuration
while doing long work, so they take the lock for their whole run. Another command that
needs the lock waits briefly and then reports that something else is modifying it — nothing
is lost, and running it again once the first command finishes works normally.

The Dashmate helper uses the same whole-operation lock while renewing an SSL certificate.
For ZeroSSL this includes HTTP validation and may take minutes. A configuration-changing
command started during background renewal can therefore reach its 15-second timeout and
report that another Dashmate command is modifying configuration. Retry it after renewal
finishes. Keeping the lock for issuance is intentional: releasing it earlier would require
replaying selected renewal fields later, which could undo an operator's provider switch or
SSL disable.

Ordinary reads remain available: `dashmate status`, `dashmate config get` and
`dashmate core cli` do not take the lock unless loading the configuration discovers a
migration that must be saved.

Graceful termination releases the lock. After `SIGKILL` or a power loss, the next writer
takes over after about a minute.

### Recovering interrupted filesystem work

`config.json` is the authoritative configuration. For normal configuration changes and
certificate renewal, Dashmate saves it before writing the derived service files. If a
command reports a service-file rendering error, or is killed after saving, those files can
still describe the previous configuration. Repair the affected config explicitly:

```bash
dashmate config render --config=<name>
```

Removing a config also saves `config.json` before deleting its service directory. If the
directory deletion fails, Dashmate leaves the orphan in place and will refuse to create a
new config with that name. Inspect `~/.dashmate/<name>` for keys or other state that must be
kept, then move or delete the directory manually before retrying `dashmate config create`.
An absent name passed to `dashmate config remove` is rejected; it is not an orphan-cleanup
command.

If a command loses its lock before saving, Dashmate preserves the pending JSON in a private
`~/.dashmate/.config.json.rescue-<id>` file and reports that path. Compare it with
`config.json` and retain any needed values. Delete the rescue file manually only after its
contents have been acknowledged; Dashmate does not remove rescue files automatically.

The lock coordinates versions of Dashmate that implement this protocol. An older Dashmate
process that does not use the lock can still write concurrently, so finish rolling out the
new version before relying on this guarantee. The protocol is intended for a Dashmate home
directory on a local filesystem; network filesystems may not provide the required lock and
atomic-replacement semantics.
8 changes: 6 additions & 2 deletions packages/dashmate/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
"postpack": "rm -f oclif.manifest.json",
"prepack": "oclif manifest && oclif readme",
"version": "oclif readme && git add README.md",
"test": "yarn run test:unit && yarn run test:e2e",
"test": "yarn run test:unit && yarn run test:integration && yarn run test:e2e",
"test:unit": "mocha --recursive test/unit",
"test:integration": "mocha --recursive test/integration",
"test:ci": "yarn run test:unit && yarn run test:integration",
"test:e2e": "mocha --recursive test/e2e",
"helper": "node scripts/helper.js"
},
Expand Down Expand Up @@ -87,14 +89,16 @@
"node-graceful": "^3.0.1",
"pretty-bytes": "^5.3.0",
"pretty-ms": "^7.0.0",
"proper-lockfile": "^4.1.2",
"public-ip": "^6.0.1",
"qs": "^6.14.2",
"rxjs": "^6.6.7",
"semver": "^7.5.3",
"systeminformation": "^5.31.1",
"table": "^6.8.1",
"tar": "7.5.10",
"wrap-ansi": "^7.0.0"
"wrap-ansi": "^7.0.0",
"write-file-atomic": "^5.0.1"
},
"devDependencies": {
"@babel/core": "^7.26.10",
Expand Down
56 changes: 35 additions & 21 deletions packages/dashmate/scripts/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import dotenv from 'dotenv';
import { asValue } from 'awilix';
import graceful from 'node-graceful';
import createDIContainer from '../src/createDIContainer.js';
import scheduleRenewCertificate from '../src/helper/scheduleRenewCertificate.js';
import watchCertificateConfig from '../src/helper/watchCertificateConfig.js';

// The ephemeral containers SSL providers bind to port 80 during issuance.
// Either can be left orphaned if a previous helper run crashed mid-renewal.
Expand Down Expand Up @@ -90,16 +92,10 @@ async function removeOrphanedSslContainers(docker) {
*/
const writeConfigTemplates = container.resolve('writeConfigTemplates');

const configFile = await configFileRepository.read();

// Persist config if it was migrated
if (configFile.isChanged()) {
await configFileRepository.write(configFile);

configFile.getAllConfigs()
.filter((config) => config.isChanged())
.forEach(writeConfigTemplates);
}
const { configFile } = configFileRepository.readAndMigrate(
{},
(migratedConfigs) => migratedConfigs.forEach(writeConfigTemplates),
);

const config = configFile.getConfig(configName);

Expand All @@ -119,17 +115,35 @@ async function removeOrphanedSslContainers(docker) {
await removeOrphanedSslContainers(docker);
}

if (isEnabled && provider === 'zerossl') {
const scheduleRenewZeroSslCertificate = container.resolve('scheduleRenewZeroSslCertificate');
await scheduleRenewZeroSslCertificate(config);
} else if (isEnabled && provider === 'letsencrypt') {
const scheduleRenewLetsEncryptCertificate = container.resolve('scheduleRenewLetsEncryptCertificate');
await scheduleRenewLetsEncryptCertificate(config);
} else {
// prevent infinite restarts
setInterval(() => {
}, 60 * 1000);
}
const scheduleRenewZeroSslCertificate = container.resolve('scheduleRenewZeroSslCertificate');
const scheduleRenewLetsEncryptCertificate = container.resolve('scheduleRenewLetsEncryptCertificate');
const watchInactiveConfig = (inactiveConfig, onActivated) => watchCertificateConfig(
inactiveConfig,
null,
configFileRepository,
async (currentConfig) => {
if (!currentConfig) {
return false;
}

return onActivated(currentConfig);
},
(e) => {
// eslint-disable-next-line no-console
console.error(`Failed to check configuration for certificate renewal: ${e.message}`);
},
);
await scheduleRenewCertificate(
config,
scheduleRenewZeroSslCertificate,
scheduleRenewLetsEncryptCertificate,
watchInactiveConfig,
);

// Keep the helper alive when renewal is disabled, the config is removed, or
// a provider change stops the only scheduled job.
setInterval(() => {
}, 60 * 1000);

if (config.get('dashmate.helper.api.enable')) {
const createHttpApiServer = container.resolve('createHttpApiServer');
Expand Down
42 changes: 41 additions & 1 deletion packages/dashmate/src/commands/config/create.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import fs from 'fs';
import { Args } from '@oclif/core';
import BaseCommand from '../../oclif/command/BaseCommand.js';
import resolveConfigDirectory, {
assertConfigNameAvailable,
getPortableConfigName,
} from '../../config/resolve-config-directory.js';
import ConfigAlreadyPresentError from '../../config/errors/ConfigAlreadyPresentError.js';

export default class ConfigCreateCommand extends BaseCommand {
static description = 'Create new config';
Expand All @@ -22,6 +28,9 @@ export default class ConfigCreateCommand extends BaseCommand {
* @param {Object} args
* @param {Object} flags
* @param {ConfigFile} configFile
* @param {ConfigFileJsonRepository} configFileRepository
* @param {writeConfigTemplates} writeConfigTemplates
* @param {HomeDir} homeDir
* @return {Promise<void>}
*/
async runWithDependencies(
Expand All @@ -31,8 +40,39 @@ export default class ConfigCreateCommand extends BaseCommand {
},
flags,
configFile,
configFileRepository,
writeConfigTemplates,
homeDir,
) {
configFile.createConfig(configName, fromConfigName);
assertConfigNameAvailable(configName);

const serviceConfigsPath = resolveConfigDirectory(homeDir, configName);

// Read, change and save in one locked step, so a config created here cannot
// revert a change another command saved in the meantime.
configFileRepository.update((updatedConfigFile) => {
const portableConfigName = getPortableConfigName(configName);
const hasPortableNameCollision = updatedConfigFile.getAllConfigs()
.some((existingConfig) => getPortableConfigName(existingConfig.getName())
=== portableConfigName);

if (hasPortableNameCollision) {
throw new ConfigAlreadyPresentError(configName);
}

// A directory without a matching config belongs to an interrupted create
// or remove and may contain private files that a new node must not adopt.
if (!updatedConfigFile.isConfigExists(configName) && fs.existsSync(serviceConfigsPath)) {
throw new Error(`Service files for '${configName}' already exist without a config.`
+ ` Inspect '${serviceConfigsPath}' and move or delete it manually, then retry.`);
}

updatedConfigFile.createConfig(configName, fromConfigName);
}, {
// Keep the lock through rendering so another writer cannot save and
// render newer state in between this save and its service files.
onSaved: (freshConfigFile) => writeConfigTemplates(freshConfigFile.getConfig(configName)),
});

// eslint-disable-next-line no-console
console.log(`${configName} created`);
Expand Down
7 changes: 6 additions & 1 deletion packages/dashmate/src/commands/config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,17 @@ Shows default config name or sets another config as default
},
flags,
configFile,
configFileRepository,
) {
if (configName === null) {
// eslint-disable-next-line no-console
console.log(configFile.getDefaultConfigName());
} else {
configFile.setDefaultConfigName(configName);
// Read, change and save in one locked step, so pointing the default at a
// config cannot revert a change another command saved in the meantime.
configFileRepository.update((freshConfigFile) => {
freshConfigFile.setDefaultConfigName(configName);
});

// eslint-disable-next-line no-console
console.log(`${configName} config set as default`);
Expand Down
Loading
Loading