Skip to content
Merged
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
3 changes: 3 additions & 0 deletions packages/dashmate/configs/defaults/getBaseConfigFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ export default function getBaseConfigFactory() {
apiKey: null,
id: null,
},
letsencrypt: {
email: null,
},
},
},
},
Expand Down
14 changes: 14 additions & 0 deletions packages/dashmate/configs/getConfigFileMigrationsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,20 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs)

return configFile;
},
'3.0.0-rc.3': (configFile) => {
Object.entries(configFile.configs)
.forEach(([, options]) => {
// Add letsencrypt provider config if it doesn't exist
if (options.platform?.gateway?.ssl?.providerConfigs
&& !options.platform.gateway.ssl.providerConfigs.letsencrypt) {
options.platform.gateway.ssl.providerConfigs.letsencrypt = {
email: null,
};
}
});

return configFile;
},
};
}

Expand Down
3 changes: 3 additions & 0 deletions packages/dashmate/scripts/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ import createDIContainer from '../src/createDIContainer.js';
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(() => {
Expand Down
37 changes: 32 additions & 5 deletions packages/dashmate/src/commands/ssl/obtain.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { Flags } from '@oclif/core';
import ConfigBaseCommand from '../../oclif/command/ConfigBaseCommand.js';
import MuteOneLineError from '../../oclif/errors/MuteOneLineError.js';
import Certificate from '../../ssl/zerossl/Certificate.js';
import LegoCertificate from '../../ssl/letsencrypt/LegoCertificate.js';
import { SSL_PROVIDERS } from '../../constants.js';

export default class ObtainCommand extends ConfigBaseCommand {
static description = `Obtain SSL certificate

Create a new SSL certificate or download an already existing one using ZeroSSL as provider
Create a new SSL certificate or download an already existing one using ZeroSSL or Let's Encrypt as provider
Certificate will be renewed if it is about to expire (see 'expiration-days' flag)
`;

Expand All @@ -19,7 +21,10 @@ Certificate will be renewed if it is about to expire (see 'expiration-days' flag
'expiration-days': Flags.integer({
description: 'renew even if expiration period is less than'
+ ' specified number of days',
default: Certificate.EXPIRATION_LIMIT_DAYS,
}),
provider: Flags.string({
description: 'SSL provider to use (defaults to configured provider)',
options: [SSL_PROVIDERS.ZEROSSL, SSL_PROVIDERS.LETSENCRYPT],
}),
};

Expand All @@ -28,24 +33,46 @@ Certificate will be renewed if it is about to expire (see 'expiration-days' flag
* @param {Object} flags
* @param {Config} config
* @param {obtainZeroSSLCertificateTask} obtainZeroSSLCertificateTask
* @param {obtainLetsEncryptCertificateTask} obtainLetsEncryptCertificateTask
* @return {Promise<void>}
*/
async runWithDependencies(
args,
{
verbose: isVerbose,
'no-retry': noRetry,
'expiration-days': expirationDays,
'expiration-days': expirationDaysFlag,
force,
provider: providerFlag,
},
config,
obtainZeroSSLCertificateTask,
obtainLetsEncryptCertificateTask,
) {
const provider = providerFlag || config.get('platform.gateway.ssl.provider');

let task;
let taskTitle;
let expirationDays;

if (provider === SSL_PROVIDERS.LETSENCRYPT) {
task = obtainLetsEncryptCertificateTask;
taskTitle = "Obtain Let's Encrypt certificate";
expirationDays = expirationDaysFlag ?? LegoCertificate.EXPIRATION_LIMIT_DAYS;
} else if (provider === SSL_PROVIDERS.ZEROSSL) {
task = obtainZeroSSLCertificateTask;
taskTitle = 'Obtain ZeroSSL certificate';
expirationDays = expirationDaysFlag ?? Certificate.EXPIRATION_LIMIT_DAYS;
} else {
throw new Error(`SSL provider '${provider}' does not support certificate obtainment via this command. `
+ `Supported providers: ${SSL_PROVIDERS.ZEROSSL}, ${SSL_PROVIDERS.LETSENCRYPT}`);
}

const tasks = new Listr(
[
{
title: 'Obtain ZeroSSL certificate',
task: () => obtainZeroSSLCertificateTask(config),
title: taskTitle,
task: () => task(config),
},
],
{
Expand Down
12 changes: 11 additions & 1 deletion packages/dashmate/src/config/configJsonSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@ export default {
},
provider: {
type: 'string',
enum: ['zerossl', 'self-signed', 'file'],
enum: ['zerossl', 'letsencrypt', 'self-signed', 'file'],
},
providerConfigs: {
type: 'object',
Expand All @@ -700,6 +700,16 @@ export default {
required: ['apiKey', 'id'],
additionalProperties: false,
},
letsencrypt: {
type: ['object'],
properties: {
email: {
type: ['string', 'null'],
},
},
required: ['email'],
additionalProperties: false,
},
},
},
},
Expand Down
1 change: 1 addition & 0 deletions packages/dashmate/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const OUTPUT_FORMATS = {

export const SSL_PROVIDERS = {
ZEROSSL: 'zerossl',
LETSENCRYPT: 'letsencrypt',
FILE: 'file',
SELF_SIGNED: 'self-signed',
};
8 changes: 8 additions & 0 deletions packages/dashmate/src/createDIContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import generateHDPrivateKeys from './util/generateHDPrivateKeys.js';
import getOperatingSystemInfoFactory from './util/getOperatingSystemInfoFactory.js';

import obtainZeroSSLCertificateTaskFactory from './listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js';
import obtainLetsEncryptCertificateTaskFactory from './listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js';
import VerificationServer from './listr/tasks/ssl/VerificationServer.js';
import saveCertificateTaskFactory from './listr/tasks/ssl/saveCertificateTask.js';

Expand All @@ -102,6 +103,7 @@ import generateKeyPair from './ssl/generateKeyPair.js';
import createSelfSignedCertificate from './ssl/selfSigned/createSelfSignedCertificate.js';

import scheduleRenewZeroSslCertificateFactory from './helper/scheduleRenewZeroSslCertificateFactory.js';
import scheduleRenewLetsEncryptCertificateFactory from './helper/scheduleRenewLetsEncryptCertificateFactory.js';
import registerMasternodeGuideTaskFactory from './listr/tasks/setup/regular/registerMasternodeGuideTaskFactory.js';
import configureNodeTaskFactory from './listr/tasks/setup/regular/configureNodeTaskFactory.js';
import configureSSLCertificateTaskFactory from './listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js';
Expand All @@ -127,6 +129,7 @@ import verifySystemRequirementsTaskFactory
import collectSamplesTaskFactory from './listr/tasks/doctor/collectSamplesTaskFactory.js';
import verifySystemRequirementsFactory from './doctor/verifySystemRequirementsFactory.js';
import validateZeroSslCertificateFactory from './ssl/zerossl/validateZeroSslCertificateFactory.js';
import validateLetsEncryptCertificateFactory from './ssl/letsencrypt/validateLetsEncryptCertificateFactory.js';

/**
* @param {Object} [options]
Expand Down Expand Up @@ -305,6 +308,8 @@ export default async function createDIContainer(options = {}) {
obtainZeroSSLCertificateTask: asFunction(obtainZeroSSLCertificateTaskFactory).singleton(),
cleanupZeroSSLCertificatesTask: asFunction(cleanupZeroSSLCertificatesTaskFactory).singleton(),
obtainSelfSignedCertificateTask: asFunction(obtainSelfSignedCertificateTaskFactory).singleton(),
obtainLetsEncryptCertificateTask: asFunction(obtainLetsEncryptCertificateTaskFactory)
.singleton(),
saveCertificateTask: asFunction(saveCertificateTaskFactory),
reindexNodeTask: asFunction(reindexNodeTaskFactory).singleton(),
getCoreScope: asFunction(getCoreScopeFactory).singleton(),
Expand All @@ -330,6 +335,7 @@ export default async function createDIContainer(options = {}) {
*/
container.register({
validateZeroSslCertificate: asFunction(validateZeroSslCertificateFactory).singleton(),
validateLetsEncryptCertificate: asFunction(validateLetsEncryptCertificateFactory).singleton(),
getCertificate: asValue(getCertificate),
});

Expand All @@ -353,6 +359,8 @@ export default async function createDIContainer(options = {}) {
*/
container.register({
scheduleRenewZeroSslCertificate: asFunction(scheduleRenewZeroSslCertificateFactory).singleton(),
scheduleRenewLetsEncryptCertificate: asFunction(scheduleRenewLetsEncryptCertificateFactory)
.singleton(),
createHttpApiServer: asFunction(createHttpApiServerFactory).singleton(),
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { CronJob } from 'cron';
import path from 'path';

import LegoCertificate from '../ssl/letsencrypt/LegoCertificate.js';

/**
* @param {obtainLetsEncryptCertificateTask} obtainLetsEncryptCertificateTask
* @param {DockerCompose} dockerCompose
* @param {ConfigFileJsonRepository} configFileRepository
* @param {ConfigFile} configFile
* @param {writeConfigTemplates} writeConfigTemplates
* @param {HomeDir} homeDir
* @return {scheduleRenewLetsEncryptCertificate}
*/
export default function scheduleRenewLetsEncryptCertificateFactory(
obtainLetsEncryptCertificateTask,
dockerCompose,
configFileRepository,
configFile,
writeConfigTemplates,
homeDir,
) {
/**
* @typedef scheduleRenewLetsEncryptCertificate
* @param {Config} config
* @return {Promise<void>}
*/
async function scheduleRenewLetsEncryptCertificate(config) {
const externalIp = config.get('externalIp');
const legoDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'lego');
const certPath = path.join(legoDir, 'certificates', `${externalIp}.crt`);

let certificate;
try {
certificate = LegoCertificate.fromFile(certPath);
} catch (e) {
// eslint-disable-next-line no-console
console.error(`Failed to read Let's Encrypt certificate from ${certPath}: ${e.message}`);
// Schedule a check in 1 hour to see if certificate appears
const retryAt = new Date(Date.now() + 60 * 60 * 1000);

const retryJob = new CronJob(retryAt, async () => {
retryJob.stop();
process.nextTick(() => scheduleRenewLetsEncryptCertificate(config));
});

retryJob.start();
return;
}

let renewAt;
if (certificate.isExpiredInDays(LegoCertificate.EXPIRATION_LIMIT_DAYS)) {
// Obtain new certificate right away
renewAt = new Date(Date.now() + 3000);

// eslint-disable-next-line no-console
console.log(`Let's Encrypt certificate will expire in less than ${LegoCertificate.EXPIRATION_LIMIT_DAYS} days at ${certificate.expires}. Schedule to obtain it NOW.`);
} else {
// Schedule a new check close to expiration period
renewAt = new Date(certificate.expires);
renewAt.setDate(renewAt.getDate() - LegoCertificate.EXPIRATION_LIMIT_DAYS);

// eslint-disable-next-line no-console
console.log(`Let's Encrypt certificate will expire at ${certificate.expires}. Schedule to obtain at ${renewAt}.`);
}

let renewalSucceeded = false;

const job = new CronJob(renewAt, async () => {
try {
const tasks = obtainLetsEncryptCertificateTask(config);

await tasks.run({
expirationDays: LegoCertificate.EXPIRATION_LIMIT_DAYS,
noRetry: true,
});

// Write config files
configFileRepository.write(configFile);
writeConfigTemplates(config);

// Restart Gateway to catch up new SSL certificates
await dockerCompose.execCommand(config, 'gateway', 'kill -SIGHUP 1');

// eslint-disable-next-line no-console
console.log("Let's Encrypt certificate renewed successfully");

renewalSucceeded = true;
} catch (e) {
// eslint-disable-next-line no-console
console.error(`Failed to renew Let's Encrypt certificate: ${e.message}`);

renewalSucceeded = false;
}

job.stop();
}, async () => {
// Schedule new cron task after completion
if (renewalSucceeded) {
// Success: reschedule immediately to read new cert expiry
process.nextTick(() => scheduleRenewLetsEncryptCertificate(config));
} else {
// Failure: wait 1 hour before retrying to avoid tight loop
// eslint-disable-next-line no-console
console.log("Scheduling Let's Encrypt renewal retry in 1 hour");

setTimeout(() => {
scheduleRenewLetsEncryptCertificate(config);
}, 60 * 60 * 1000);
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

job.start();
}

return scheduleRenewLetsEncryptCertificate;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import listCertificates from '../../../../ssl/zerossl/listCertificates.js';
* @param {saveCertificateTask} saveCertificateTask
* @param {obtainZeroSSLCertificateTask} obtainZeroSSLCertificateTask
* @param {obtainSelfSignedCertificateTask} obtainSelfSignedCertificateTask
* @param {obtainLetsEncryptCertificateTask} obtainLetsEncryptCertificateTask
* @returns {configureSSLCertificateTask}
*/
export default function configureSSLCertificateTaskFactory(
saveCertificateTask,
obtainZeroSSLCertificateTask,
obtainSelfSignedCertificateTask,
obtainLetsEncryptCertificateTask,
) {
/**
* @typedef configureSSLCertificateTask
Expand Down Expand Up @@ -113,6 +115,23 @@ export default function configureSSLCertificateTaskFactory(
title: 'Generate self-signed certificate',
task: async (ctx) => obtainSelfSignedCertificateTask(ctx.config),
},
[SSL_PROVIDERS.LETSENCRYPT]: {
title: 'Obtain Let\'s Encrypt certificate',
task: async (ctx, task) => {
const email = await task.prompt({
type: 'input',
message: 'Enter email address for Let\'s Encrypt notifications',
validate: (input) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(input) || 'Please enter a valid email address';
},
});

ctx.config.set('platform.gateway.ssl.providerConfigs.letsencrypt.email', email);

return obtainLetsEncryptCertificateTask(ctx.config);
},
},
};

return new Listr([
Expand All @@ -121,6 +140,7 @@ export default function configureSSLCertificateTaskFactory(
task: async (ctx, task) => {
const choices = [
{ name: SSL_PROVIDERS.ZEROSSL, message: 'ZeroSSL' },
{ name: SSL_PROVIDERS.LETSENCRYPT, message: "Let's Encrypt" },
{ name: SSL_PROVIDERS.FILE, message: 'File on disk' },
];

Expand All @@ -132,14 +152,15 @@ export default function configureSSLCertificateTaskFactory(
by loading an SSL certificate signed against the IP address specified in the
registration transaction. The certificate should be recognized by common web
browsers, and must therefore be issued by a well-known Certificate Authority
(CA). Dashmate offers three options to configure this certificate:
(CA). Dashmate offers several options to configure this certificate:

ZeroSSL - Provide a ZeroSSL API key and let dashmate configure the certificate
https://zerossl.com/documentation/api/ ("Access key" section)
File on disk - Provide your own certificate to dashmate\n`;
ZeroSSL - Provide a ZeroSSL API key and let dashmate configure the certificate
https://zerossl.com/documentation/api/ ("Access key" section)
Let's Encrypt - Free certificates using Let's Encrypt (requires email)
File on disk - Provide your own certificate to dashmate\n`;

if (isSelfSignedEnabled) {
header += ' Self-signed - Generate your own self-signed certificate\n';
header += ' Self-signed - Generate your own self-signed certificate\n';

choices.push({ name: SSL_PROVIDERS.SELF_SIGNED, message: 'Self-signed' });
}
Expand Down
Loading
Loading