Skip to content
Open
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
14 changes: 14 additions & 0 deletions packages/dashmate/configs/getConfigFileMigrationsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,13 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs)
if (fs.existsSync(oldFilePath)) {
fs.mkdirSync(path.dirname(newFilePath), { recursive: true });
fs.copyFileSync(oldFilePath, newFilePath);

// A copy keeps the permissions of the source, and the private key
// must not be readable by other users on the host
if (filename === 'private.key') {
fs.chmodSync(newFilePath, 0o600);
}

fs.rmSync(oldFilePath, { recursive: true });
}
}
Expand Down Expand Up @@ -712,6 +719,13 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs)
if (fs.existsSync(oldFilePath)) {
fs.mkdirSync(path.dirname(newFilePath), { recursive: true });
fs.copyFileSync(oldFilePath, newFilePath);

// A copy keeps the permissions of the source, and the private key
// must not be readable by other users on the host
if (filename === 'private.key') {
fs.chmodSync(newFilePath, 0o600);
}

fs.rmSync(oldFilePath, { recursive: true });
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/dashmate/src/commands/config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ Shows default config name or sets another config as default
name: 'config',
required: false,
description: 'config name',
default: null, // only allow input to be from a discrete set
},
),
};
Expand All @@ -31,7 +30,8 @@ Shows default config name or sets another config as default
flags,
configFile,
) {
if (configName === null) {
// The argument is omitted when only the current default config name is requested
if (configName === undefined) {
// eslint-disable-next-line no-console
console.log(configFile.getDefaultConfigName());
} else {
Expand Down
4 changes: 2 additions & 2 deletions packages/dashmate/src/commands/group/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ Shows default group name or sets another group as default
name: 'group',
required: false,
description: 'group name',
default: null, // only allow input to be from a discrete set
},
),
};
Expand All @@ -31,7 +30,8 @@ Shows default group name or sets another group as default
flags,
configFile,
) {
if (groupName === null) {
// The argument is omitted when only the current default group name is requested
if (groupName === undefined) {
// eslint-disable-next-line no-console
console.log(configFile.getDefaultGroupName());
} else {
Expand Down
15 changes: 15 additions & 0 deletions packages/dashmate/src/commands/group/restart.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ export default class GroupRestartCommand extends GroupBaseCommand {
title: `Restart ${groupName} nodes`,
task: async () => (
new Listr([
{
// Every node's images must be fetched before the first node is
// stopped, otherwise a failed pull leaves the group stopped
title: 'Pull missing images',
task: () => (
new Listr(configGroup.map((config) => ({
task: (ctx, task) => dockerCompose.pullMissingImages(config, {
onProgress: (message) => {
// eslint-disable-next-line no-param-reassign
task.output = message;
},
}),
})))
Comment on lines +47 to +56

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: Build local images before stopping group nodes

This pre-stop phase only calls pullMissingImages, which intentionally excludes services marked isBuiltLocally. When local builds are enabled, startGroupNodesTask runs buildServicesTask as its first task, but that happens only after every node has been stopped. A missing local image or any build failure therefore still leaves the entire group down, despite the commit's stated guarantee that required images are prepared before anything stops. Run the shared local build before the stop phase and arrange for the subsequent group start to skip the duplicate build.

source: ['codex']

),
},
{
title: 'Stop nodes',
task: () => (
Expand Down
19 changes: 17 additions & 2 deletions packages/dashmate/src/commands/update.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,38 @@ export default class UpdateCommand extends ConfigBaseCommand {
const colors = {
updated: chalk.yellow,
'up to date': chalk.green,
'built locally': chalk.gray,
error: chalk.red,
};

// Draw table or show json
printArrayOfObjects(updateInfo
.reduce(
(acc, {
name, title, updated, image,
name, title, updated, image, error,
}) => ([
...acc,
format === OUTPUT_FORMATS.PLAIN
? { Service: title, Image: image, Updated: colors[updated](updated) }
: {
name, title, updated, image,
name, title, updated, image, error,
},
]),
[],
), format);

const failedServices = updateInfo.filter(({ updated }) => updated === 'error');

if (failedServices.length > 0) {
const reasons = failedServices
.map(({ title, image, error }) => ` ${title} (${image}): ${error}`)
.join('\n');

// Report to stderr to keep machine-readable output on stdout intact
// eslint-disable-next-line no-console
console.error(`\nFailed to update ${failedServices.length} of ${updateInfo.length} images:\n\n${reasons}\n`);
Comment on lines +64 to +70

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: Sanitize registry error text before terminal output

The error field can come directly from a Docker registry pull-stream errorDetail.message, but the stderr summary interpolates it unchanged. A registry response can therefore inject newlines, carriage returns, ANSI escapes, bidirectional controls, or an excessively long message into the operator's terminal. This path is unconditional, including when --format=json is selected. Strip unsafe control characters and bound the rendered length before writing remote error text to terminal streams, using protections equivalent to the new remote-diagnostic sanitization in status/providers.js.

source: ['codex']


process.exitCode = 1;
}
}
}
90 changes: 89 additions & 1 deletion packages/dashmate/src/docker/DockerCompose.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,26 @@ export default class DockerCompose {
*/
#getServiceList;

/**
* @type {dockerPull}
*/
#dockerPull;

/**
* @param {Docker} docker
* @param {StartedContainers} startedContainers
* @param {HomeDir} homeDir
* @param {generateEnvs} generateEnvs
* @param {getServiceList} getServiceList
* @param {dockerPull} dockerPull
*/
constructor(docker, startedContainers, homeDir, generateEnvs, getServiceList) {
constructor(docker, startedContainers, homeDir, generateEnvs, getServiceList, dockerPull) {
this.#docker = docker;
this.#startedContainers = startedContainers;
this.#homeDir = homeDir;
this.#generateEnvs = generateEnvs;
this.#getServiceList = getServiceList;
this.#dockerPull = dockerPull;
}

/**
Expand Down Expand Up @@ -498,6 +505,87 @@ export default class DockerCompose {
}
}

/**
* Pull images required by the config that are not present on the host
*
* Docker Compose pulls a missing image only when it creates the container,
* which during a restart happens after the node has already been stopped.
* A failed pull would then leave the node down, so images are fetched
* upfront and the caller can abort while the node is still running.
*
* @param {Config} config
* @param {Object} [options]
* @param {string[]} [options.profiles] - Filter by profiles
* @param {function} [options.onProgress] - Called with pull progress messages
* @return {Promise<string[]>} images that have been pulled
*/
async pullMissingImages(config, { profiles = [], onProgress = undefined } = {}) {
await this.throwErrorIfNotInstalled();

let serviceList = this.#getServiceList(config);

if (profiles.length > 0) {
// Compose creates a service when one of its profiles is enabled, and
// always creates a service that declares no profiles at all
serviceList = serviceList.filter((service) => service.profiles.length === 0
|| service.profiles.some((profile) => profiles.includes(profile)));
}

const images = serviceList
// Images built from sources on this host are not available in a registry
.filter((service) => !service.isBuiltLocally)
.map((service) => service.image);

const pulledImages = [];

for (const image of new Set(images)) {
if (await this.#isImagePresent(image)) {
continue;
}

try {
await this.#dockerPull(image, (message) => {
if (onProgress && message?.status) {
const progress = message.progress ? ` ${message.progress}` : '';

onProgress(`${image}: ${message.status}${progress}`);
}
});
} catch (e) {
throw new Error(`Failed to pull image ${image}: ${e.message}`);
}

// Docker can report a successful pull without producing the image,
// and the whole point of pulling here is to know the image is on the host
if (!await this.#isImagePresent(image)) {
throw new Error(`Failed to pull image ${image}: it is still not present on the host`);
}

pulledImages.push(image);
}

return pulledImages;
}

/**
* @private
* @param {string} image
* @return {Promise<boolean>}
*/
async #isImagePresent(image) {
try {
await this.#docker.getImage(image).inspect();

return true;
} catch (e) {
if (e.statusCode === 404) {
return false;
}

throw new Error(`Failed to check image ${image}: ${e.message}`);
}
}

/**
* Logs
*
Expand Down
17 changes: 15 additions & 2 deletions packages/dashmate/src/docker/dockerPullFactory.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import findPullStreamError from './findPullStreamError.js';

/**
* @param {Docker} docker
* @return {dockerPull}
Expand All @@ -6,9 +8,10 @@ export default function dockerPullFactory(docker) {
/**
* @typedef {dockerPull}
* @param {string} image
* @param {function} [onProgress] - called with every pull stream message
* @return {Promise<*>}
*/
function dockerPull(image) {
function dockerPull(image, onProgress = undefined) {
return new Promise((resolve, reject) => {
docker.pull(image, (err, stream) => {
if (err) {
Expand All @@ -24,8 +27,18 @@ export default function dockerPullFactory(docker) {
return;
}

// followProgress collects stream messages without inspecting them,
// so a failed pull has to be recognized here
const streamError = findPullStreamError(output);

if (streamError) {
reject(new Error(streamError));

return;
}

resolve(output);
});
}, onProgress);
});
});
}
Expand Down
19 changes: 19 additions & 0 deletions packages/dashmate/src/docker/findPullStreamError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Find a failure reported inside a Docker pull progress stream
*
* Docker answers a pull request with 200 and then reports registry and disk
* failures as a message in the progress stream, so a completed stream doesn't
* mean the image was pulled.
*
* @param {Object[]} output - messages collected from the pull stream
* @return {string|undefined} failure reason
*/
export default function findPullStreamError(output) {
const failure = output.find((message) => message?.error);

if (!failure) {
return undefined;
}

return failure.errorDetail?.message ?? failure.error;
}
9 changes: 8 additions & 1 deletion packages/dashmate/src/docker/getServiceListFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,20 @@ export default function getServiceListFactory(generateEnvs, getConfigProfiles) {
// map to array of services and populate with data
.map((composeFileServiceEntry) => {
const [serviceName,
{ image: serviceImage, labels, profiles: serviceProfiles }] = composeFileServiceEntry;
{
image: serviceImage, labels, profiles: serviceProfiles, build: serviceBuild,
}] = composeFileServiceEntry;

const title = labels?.['org.dashmate.service.title'];

if (!title) {
throw new Error(`Label for dashmate service ${serviceName} is not defined`);
}

// A service with a build section is built from sources on this host,
// so its image exists only locally and can't be pulled from a registry
const isBuiltLocally = Boolean(serviceBuild);

// Use hardcoded version for dashmate helper
// Or parse image env variable name and extract version from the env
const serviceImageEnv = serviceImage.match(/([A-Z_]+)/);
Expand All @@ -61,6 +67,7 @@ export default function getServiceListFactory(generateEnvs, getConfigProfiles) {
name: serviceName,
title,
image,
isBuiltLocally,
profiles: serviceProfiles ?? [],
});
});
Expand Down
16 changes: 16 additions & 0 deletions packages/dashmate/src/doctor/analyse/analyseConfigFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,22 @@ and revoke the previous certificate in the ZeroSSL dashboard`,
}
}

// Gateway TLS private key permissions
const sslPrivateKeyMode = samples.getServiceInfo('gateway', 'sslPrivateKeyMode');

// eslint-disable-next-line no-bitwise
if (typeof sslPrivateKeyMode === 'number' && (sslPrivateKeyMode & 0o077) !== 0) {
const problem = new Problem(
`Gateway TLS private key is accessible to other users on this host (mode ${sslPrivateKeyMode.toString(8)}, expected 600)`,
chalk`Please make the private key accessible only to its owner:
{bold.cyanBright chmod 600 ~/.dashmate/${config.getName()}/platform/gateway/ssl/private.key}
Use your dashmate home directory if it is not the default one`,
SEVERITY.HIGH,
);

problems.push(problem);
}

if (samples?.getDashmateConfig()?.get('network') !== NETWORK_LOCAL) {
// Core P2P port
const coreP2pPort = samples.getServiceInfo('core', 'p2pPort');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,26 @@ export default function collectSamplesTaskFactory(
enabled: () => config.get('platform.enable'),
title: 'Gateway SSL certificates',
task: async () => {
// The private key permissions are collected for every provider,
// since a key readable by other users is a problem regardless
// of how it was obtained
const privateKeyFilePath = homeDir.joinPath(
config.getName(),
'platform',
'gateway',
'ssl',
'private.key',
);

if (fs.existsSync(privateKeyFilePath)) {
ctx.samples.setServiceInfo(
'gateway',
'sslPrivateKeyMode',
// eslint-disable-next-line no-bitwise
fs.statSync(privateKeyFilePath).mode & 0o777,
);
}

if (!config.get('platform.gateway.ssl.enabled')) {
ctx.samples.setServiceInfo('gateway', 'ssl', {
error: 'disabled',
Expand Down
Loading
Loading