From daf35c265bd27d1de1b45310780e99238664ae15 Mon Sep 17 00:00:00 2001 From: ktechmidas Date: Sat, 17 Jan 2026 17:40:46 +0000 Subject: [PATCH 1/7] feat(dashmate): add Let's Encrypt SSL provider support Add Let's Encrypt as a new SSL certificate provider option using the goacme/lego Docker client. This enables IP-based certificate issuance with automatic renewal support for shortlived certificates. New files: - LegoCertificate.js - Certificate model for parsing PEM files - validateLetsEncryptCertificateFactory.js - Validation logic - obtainLetsEncryptCertificateTaskFactory.js - Main obtain task - scheduleRenewLetsEncryptCertificateFactory.js - Renewal scheduler Changes: - Add LETSENCRYPT to SSL_PROVIDERS constant - Add letsencrypt config schema and defaults - Add Let's Encrypt to setup wizard choices - Update ssl obtain command with --provider flag - Update helper.js with Let's Encrypt renewal scheduling Co-Authored-By: Claude Opus 4.5 --- .../configs/defaults/getBaseConfigFactory.js | 3 + packages/dashmate/scripts/helper.js | 3 + packages/dashmate/src/commands/ssl/obtain.js | 37 ++- .../dashmate/src/config/configJsonSchema.js | 12 +- packages/dashmate/src/constants.js | 1 + packages/dashmate/src/createDIContainer.js | 6 + ...eduleRenewLetsEncryptCertificateFactory.js | 100 ++++++++ .../configureSSLCertificateTaskFactory.js | 31 ++- ...obtainLetsEncryptCertificateTaskFactory.js | 221 ++++++++++++++++++ .../src/ssl/letsencrypt/LegoCertificate.js | 105 +++++++++ .../validateLetsEncryptCertificateFactory.js | 126 ++++++++++ 11 files changed, 634 insertions(+), 11 deletions(-) create mode 100644 packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js create mode 100644 packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js create mode 100644 packages/dashmate/src/ssl/letsencrypt/LegoCertificate.js create mode 100644 packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js diff --git a/packages/dashmate/configs/defaults/getBaseConfigFactory.js b/packages/dashmate/configs/defaults/getBaseConfigFactory.js index 5797362096c..3ea84fb7509 100644 --- a/packages/dashmate/configs/defaults/getBaseConfigFactory.js +++ b/packages/dashmate/configs/defaults/getBaseConfigFactory.js @@ -243,6 +243,9 @@ export default function getBaseConfigFactory() { apiKey: null, id: null, }, + letsencrypt: { + email: null, + }, }, }, }, diff --git a/packages/dashmate/scripts/helper.js b/packages/dashmate/scripts/helper.js index 6cd9feb9f61..12a510e0a05 100644 --- a/packages/dashmate/scripts/helper.js +++ b/packages/dashmate/scripts/helper.js @@ -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(() => { diff --git a/packages/dashmate/src/commands/ssl/obtain.js b/packages/dashmate/src/commands/ssl/obtain.js index 4dbfcd35612..754b6d1e6bc 100644 --- a/packages/dashmate/src/commands/ssl/obtain.js +++ b/packages/dashmate/src/commands/ssl/obtain.js @@ -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) `; @@ -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], }), }; @@ -28,6 +33,7 @@ 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} */ async runWithDependencies( @@ -35,17 +41,38 @@ Certificate will be renewed if it is about to expire (see 'expiration-days' flag { 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), }, ], { diff --git a/packages/dashmate/src/config/configJsonSchema.js b/packages/dashmate/src/config/configJsonSchema.js index 76feacc717b..dfe057cf981 100644 --- a/packages/dashmate/src/config/configJsonSchema.js +++ b/packages/dashmate/src/config/configJsonSchema.js @@ -680,7 +680,7 @@ export default { }, provider: { type: 'string', - enum: ['zerossl', 'self-signed', 'file'], + enum: ['zerossl', 'letsencrypt', 'self-signed', 'file'], }, providerConfigs: { type: 'object', @@ -700,6 +700,16 @@ export default { required: ['apiKey', 'id'], additionalProperties: false, }, + letsencrypt: { + type: ['object'], + properties: { + email: { + type: ['string', 'null'], + }, + }, + required: ['email'], + additionalProperties: false, + }, }, }, }, diff --git a/packages/dashmate/src/constants.js b/packages/dashmate/src/constants.js index e6338f61876..d9bb31140d6 100644 --- a/packages/dashmate/src/constants.js +++ b/packages/dashmate/src/constants.js @@ -56,6 +56,7 @@ export const OUTPUT_FORMATS = { export const SSL_PROVIDERS = { ZEROSSL: 'zerossl', + LETSENCRYPT: 'letsencrypt', FILE: 'file', SELF_SIGNED: 'self-signed', }; diff --git a/packages/dashmate/src/createDIContainer.js b/packages/dashmate/src/createDIContainer.js index 3b2e657d2b9..703dd016b50 100644 --- a/packages/dashmate/src/createDIContainer.js +++ b/packages/dashmate/src/createDIContainer.js @@ -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'; @@ -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'; @@ -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] @@ -305,6 +308,7 @@ 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(), @@ -330,6 +334,7 @@ export default async function createDIContainer(options = {}) { */ container.register({ validateZeroSslCertificate: asFunction(validateZeroSslCertificateFactory).singleton(), + validateLetsEncryptCertificate: asFunction(validateLetsEncryptCertificateFactory).singleton(), getCertificate: asValue(getCertificate), }); @@ -353,6 +358,7 @@ export default async function createDIContainer(options = {}) { */ container.register({ scheduleRenewZeroSslCertificate: asFunction(scheduleRenewZeroSslCertificateFactory).singleton(), + scheduleRenewLetsEncryptCertificate: asFunction(scheduleRenewLetsEncryptCertificateFactory).singleton(), createHttpApiServer: asFunction(createHttpApiServerFactory).singleton(), }); diff --git a/packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js b/packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js new file mode 100644 index 00000000000..a3319735a8a --- /dev/null +++ b/packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js @@ -0,0 +1,100 @@ +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} + */ + 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}.`); + } + + 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"); + } catch (e) { + // eslint-disable-next-line no-console + console.error(`Failed to renew Let's Encrypt certificate: ${e.message}`); + } + + job.stop(); + }, async () => { + // Schedule new cron task after completion + process.nextTick(() => scheduleRenewLetsEncryptCertificate(config)); + }); + + job.start(); + } + + return scheduleRenewLetsEncryptCertificate; +} diff --git a/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js index 50b5f5f050d..b0d11b91d71 100644 --- a/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js @@ -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 @@ -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([ @@ -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' }, ]; @@ -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' }); } diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js new file mode 100644 index 00000000000..824ef238029 --- /dev/null +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -0,0 +1,221 @@ +import { Listr } from 'listr2'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +import { ERRORS } from '../../../../ssl/letsencrypt/validateLetsEncryptCertificateFactory.js'; +import LegoCertificate from '../../../../ssl/letsencrypt/LegoCertificate.js'; + +const LEGO_IMAGE = 'goacme/lego:v4.16.1'; + +/** + * @param {Docker} docker + * @param {dockerPull} dockerPull + * @param {StartedContainers} startedContainers + * @param {HomeDir} homeDir + * @param {validateLetsEncryptCertificate} validateLetsEncryptCertificate + * @param {saveCertificateTask} saveCertificateTask + * @param {ConfigFileJsonRepository} configFileRepository + * @param {ConfigFile} configFile + * @return {obtainLetsEncryptCertificateTask} + */ +export default function obtainLetsEncryptCertificateTaskFactory( + docker, + dockerPull, + startedContainers, + homeDir, + validateLetsEncryptCertificate, + saveCertificateTask, + configFileRepository, + configFile, +) { + /** + * @typedef {obtainLetsEncryptCertificateTask} + * @param {Config} config + * @return {Listr} + */ + function obtainLetsEncryptCertificateTask(config) { + return new Listr([ + { + title: 'Check if certificate already exists and is valid', + skip: (ctx) => ctx.force, + task: async (ctx, task) => { + const expirationDays = ctx.expirationDays || LegoCertificate.EXPIRATION_LIMIT_DAYS; + const { error, data } = await validateLetsEncryptCertificate(config, expirationDays); + + Object.assign(ctx, data); + + // Ensure lego directory exists + fs.mkdirSync(ctx.legoDir, { recursive: true }); + fs.mkdirSync(path.join(ctx.legoDir, 'certificates'), { recursive: true }); + + switch (error) { + case undefined: + ctx.certificateValid = true; + // eslint-disable-next-line no-param-reassign + task.output = `Certificate is valid and expires at ${ctx.certificate.expires}`; + break; + case ERRORS.EMAIL_IS_NOT_SET: + throw new Error('Let\'s Encrypt email is not set. Please set it in the config file'); + case ERRORS.EXTERNAL_IP_IS_NOT_SET: + throw new Error('External IP is not set. Please set it in the config file'); + case ERRORS.CERTIFICATE_NOT_FOUND: + // eslint-disable-next-line no-param-reassign + task.output = 'Certificate not found, obtaining a new one'; + ctx.certificateValid = false; + ctx.isRenewal = false; + break; + case ERRORS.PRIVATE_KEY_NOT_FOUND: + // eslint-disable-next-line no-param-reassign + task.output = 'Private key not found, obtaining a new certificate'; + ctx.certificateValid = false; + ctx.isRenewal = false; + break; + case ERRORS.CERTIFICATE_EXPIRES_SOON: + // eslint-disable-next-line no-param-reassign + task.output = `Certificate expires soon at ${ctx.certificate.expires}, renewing`; + ctx.certificateValid = false; + ctx.isRenewal = true; + break; + case ERRORS.CERTIFICATE_IP_MISMATCH: + throw new Error(`Certificate IP ${ctx.certificate.commonName} does not match external IP ${ctx.externalIp}.\n` + + 'Please change the external IP in config or use --force to obtain a new certificate.'); + case ERRORS.CERTIFICATE_NOT_VALID: + // eslint-disable-next-line no-param-reassign + task.output = 'Certificate is not valid, obtaining a new one'; + ctx.certificateValid = false; + ctx.isRenewal = false; + break; + default: + throw new Error(`Unknown error: ${error}`); + } + }, + }, + { + title: `Pull lego Docker image (${LEGO_IMAGE})`, + skip: (ctx) => ctx.certificateValid, + task: async () => { + await dockerPull(LEGO_IMAGE); + }, + }, + { + title: 'Obtain certificate using lego', + skip: (ctx) => ctx.certificateValid, + task: async (ctx, task) => { + const { uid, gid } = os.userInfo(); + + // Determine if this is initial run or renewal + const command = ctx.isRenewal ? 'renew' : 'run'; + + // Build lego command arguments + const legoArgs = [ + '--server=https://acme-v02.api.letsencrypt.org/directory', + '--email', ctx.email, + '--accept-tos', + '--http', + '--http.port', ':80', + '--domains', ctx.externalIp, + '--disable-cn', + '--path', '/data', + command, + ]; + + // Add profile for initial run + if (!ctx.isRenewal) { + legoArgs.push('--profile', 'shortlived'); + } else { + // For renewal, renew if within 30 days of expiry (default) + legoArgs.push('--days', '30'); + } + + const containerName = 'dashmate-letsencrypt-lego'; + + // Remove any existing container with the same name + try { + const existingContainer = await docker.getContainer(containerName); + await existingContainer.remove({ force: true }); + } catch (e) { + // Container doesn't exist, that's fine + if (e.statusCode !== 404) { + throw e; + } + } + + const container = await docker.createContainer({ + name: containerName, + Image: LEGO_IMAGE, + Cmd: legoArgs, + User: `${uid}:${gid}`, + ExposedPorts: { '80/tcp': {} }, + HostConfig: { + AutoRemove: true, + Binds: [`${ctx.legoDir}:/data`], + PortBindings: { '80/tcp': [{ HostPort: '80' }] }, + }, + }); + + startedContainers.addContainer(containerName); + + // eslint-disable-next-line no-param-reassign + task.output = `Running lego ${command}...`; + + await container.start(); + + // Wait for container to finish + const result = await container.wait(); + + if (result.StatusCode !== 0) { + // Try to get logs for error message + let errorMessage = `Lego exited with code ${result.StatusCode}`; + try { + const logs = await container.logs({ + stdout: true, + stderr: true, + }); + errorMessage += `\n${logs.toString()}`; + } catch (e) { + // Container may have been auto-removed + } + + throw new Error(`Failed to obtain Let's Encrypt certificate: ${errorMessage}\n` + + `Please ensure port 80 on your public IP address ${ctx.externalIp} is open\n` + + 'for incoming HTTP connections.'); + } + + // Verify certificate was created + if (!fs.existsSync(ctx.legoCertPath)) { + throw new Error('Certificate file was not created by lego'); + } + + // eslint-disable-next-line no-param-reassign + task.output = 'Certificate obtained successfully'; + }, + }, + { + title: 'Save certificate', + skip: (ctx) => ctx.certificateValid, + task: async (ctx) => { + // Read certificate and key from lego output + ctx.certificateFile = fs.readFileSync(ctx.legoCertPath, 'utf8'); + ctx.privateKeyFile = fs.readFileSync(ctx.legoKeyPath, 'utf8'); + + // Update config + config.set('platform.gateway.ssl.enabled', true); + config.set('platform.gateway.ssl.provider', 'letsencrypt'); + + // Save config file + configFileRepository.write(configFile); + + // Save to gateway SSL directory + return saveCertificateTask(config); + }, + }, + ], { + rendererOptions: { + showErrorMessage: true, + }, + }); + } + + return obtainLetsEncryptCertificateTask; +} diff --git a/packages/dashmate/src/ssl/letsencrypt/LegoCertificate.js b/packages/dashmate/src/ssl/letsencrypt/LegoCertificate.js new file mode 100644 index 00000000000..2dab024a990 --- /dev/null +++ b/packages/dashmate/src/ssl/letsencrypt/LegoCertificate.js @@ -0,0 +1,105 @@ +import fs from 'fs'; +import forge from 'node-forge'; + +export default class LegoCertificate { + /** + * @type {Date} + */ + expires; + + /** + * @type {Date} + */ + created; + + /** + * @type {string} + */ + commonName; + + static EXPIRATION_LIMIT_DAYS = 2; + + /** + * @param {Object} data + * @param {Date} data.expires + * @param {Date} data.created + * @param {string} data.commonName + */ + constructor(data) { + this.expires = data.expires; + this.created = data.created; + this.commonName = data.commonName; + } + + /** + * Parse certificate from PEM file + * + * @param {string} certPath - Path to certificate PEM file + * @returns {LegoCertificate} + */ + static fromFile(certPath) { + const certPem = fs.readFileSync(certPath, 'utf8'); + return LegoCertificate.fromPem(certPem); + } + + /** + * Parse certificate from PEM string + * + * @param {string} certPem - PEM encoded certificate + * @returns {LegoCertificate} + */ + static fromPem(certPem) { + const cert = forge.pki.certificateFromPem(certPem); + + const commonNameAttr = cert.subject.attributes.find( + (attr) => attr.shortName === 'CN', + ); + + return new LegoCertificate({ + expires: cert.validity.notAfter, + created: cert.validity.notBefore, + commonName: commonNameAttr ? commonNameAttr.value : null, + }); + } + + /** + * Check if certificate file exists + * + * @param {string} certPath + * @returns {boolean} + */ + static exists(certPath) { + return fs.existsSync(certPath); + } + + /** + * Is certificate expired in N days? + * + * @param {number} days + * @returns {boolean} + */ + isExpiredInDays(days) { + const expiresInDays = new Date(this.expires); + expiresInDays.setDate(expiresInDays.getDate() - days); + + return expiresInDays.getTime() <= Date.now(); + } + + /** + * Is certificate expired less than in 2 days? + * + * @returns {boolean} + */ + isExpiredSoon() { + return this.isExpiredInDays(LegoCertificate.EXPIRATION_LIMIT_DAYS); + } + + /** + * Is certificate valid (not expired)? + * + * @returns {boolean} + */ + isValid() { + return new Date(this.expires).getTime() > Date.now(); + } +} diff --git a/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js new file mode 100644 index 00000000000..e29b778ae40 --- /dev/null +++ b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js @@ -0,0 +1,126 @@ +import fs from 'fs'; +import path from 'path'; + +import LegoCertificate from './LegoCertificate.js'; + +export const ERRORS = { + EMAIL_IS_NOT_SET: 'EMAIL_IS_NOT_SET', + EXTERNAL_IP_IS_NOT_SET: 'EXTERNAL_IP_IS_NOT_SET', + CERTIFICATE_NOT_FOUND: 'CERTIFICATE_NOT_FOUND', + PRIVATE_KEY_NOT_FOUND: 'PRIVATE_KEY_NOT_FOUND', + CERTIFICATE_EXPIRES_SOON: 'CERTIFICATE_EXPIRES_SOON', + CERTIFICATE_IP_MISMATCH: 'CERTIFICATE_IP_MISMATCH', + CERTIFICATE_NOT_VALID: 'CERTIFICATE_NOT_VALID', +}; + +/** + * @param {HomeDir} homeDir + * @return {validateLetsEncryptCertificate} + */ +export default function validateLetsEncryptCertificateFactory(homeDir) { + /** + * @typedef {validateLetsEncryptCertificate} + * @param {Config} config + * @param {number} expirationDays + * @return {Promise<{ [error: String], [data: Object] }>} + */ + async function validateLetsEncryptCertificate(config, expirationDays = LegoCertificate.EXPIRATION_LIMIT_DAYS) { + const data = {}; + + // SSL output directory (where we copy final certs for gateway) + data.sslConfigDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'ssl'); + data.privateKeyFilePath = path.join(data.sslConfigDir, 'private.key'); + data.bundleFilePath = path.join(data.sslConfigDir, 'bundle.crt'); + + // Lego data directory (where lego stores its state) + data.legoDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'lego'); + + data.email = config.get('platform.gateway.ssl.providerConfigs.letsencrypt.email'); + + if (!data.email) { + return { + error: ERRORS.EMAIL_IS_NOT_SET, + data, + }; + } + + data.externalIp = config.get('externalIp'); + + if (!data.externalIp) { + return { + error: ERRORS.EXTERNAL_IP_IS_NOT_SET, + data, + }; + } + + // Lego output paths + data.legoCertPath = path.join(data.legoDir, 'certificates', `${data.externalIp}.crt`); + data.legoKeyPath = path.join(data.legoDir, 'certificates', `${data.externalIp}.key`); + + // Check if lego certificate files exist + data.isLegoCertPresent = fs.existsSync(data.legoCertPath); + data.isLegoKeyPresent = fs.existsSync(data.legoKeyPath); + + // Check if gateway SSL files exist + data.isPrivateKeyFilePresent = fs.existsSync(data.privateKeyFilePath); + data.isBundleFilePresent = fs.existsSync(data.bundleFilePath); + + if (!data.isLegoCertPresent) { + return { + error: ERRORS.CERTIFICATE_NOT_FOUND, + data, + }; + } + + if (!data.isLegoKeyPresent) { + return { + error: ERRORS.PRIVATE_KEY_NOT_FOUND, + data, + }; + } + + // Parse certificate to check expiration + try { + data.certificate = LegoCertificate.fromFile(data.legoCertPath); + } catch (e) { + return { + error: ERRORS.CERTIFICATE_NOT_VALID, + data, + }; + } + + data.isExpiresSoon = data.certificate.isExpiredInDays(expirationDays); + data.expirationDays = expirationDays; + + // Check if certificate IP matches external IP + if (data.certificate.commonName && data.certificate.commonName !== data.externalIp) { + return { + error: ERRORS.CERTIFICATE_IP_MISMATCH, + data, + }; + } + + // Check if certificate is still valid + if (!data.certificate.isValid()) { + return { + error: ERRORS.CERTIFICATE_NOT_VALID, + data, + }; + } + + // Check if certificate expires soon + if (data.isExpiresSoon) { + return { + error: ERRORS.CERTIFICATE_EXPIRES_SOON, + data, + }; + } + + // Certificate is valid + return { + data, + }; + } + + return validateLetsEncryptCertificate; +} From 8b854ba3d49f4d2a62ceef8e5082802db5ba7591 Mon Sep 17 00:00:00 2001 From: ktechmidas Date: Sun, 18 Jan 2026 05:30:55 +0000 Subject: [PATCH 2/7] Version + Linting fixes --- packages/dashmate/src/createDIContainer.js | 6 ++++-- .../letsencrypt/obtainLetsEncryptCertificateTaskFactory.js | 3 ++- .../letsencrypt/validateLetsEncryptCertificateFactory.js | 5 ++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/dashmate/src/createDIContainer.js b/packages/dashmate/src/createDIContainer.js index 703dd016b50..876a2c00d4b 100644 --- a/packages/dashmate/src/createDIContainer.js +++ b/packages/dashmate/src/createDIContainer.js @@ -308,7 +308,8 @@ export default async function createDIContainer(options = {}) { obtainZeroSSLCertificateTask: asFunction(obtainZeroSSLCertificateTaskFactory).singleton(), cleanupZeroSSLCertificatesTask: asFunction(cleanupZeroSSLCertificatesTaskFactory).singleton(), obtainSelfSignedCertificateTask: asFunction(obtainSelfSignedCertificateTaskFactory).singleton(), - obtainLetsEncryptCertificateTask: asFunction(obtainLetsEncryptCertificateTaskFactory).singleton(), + obtainLetsEncryptCertificateTask: asFunction(obtainLetsEncryptCertificateTaskFactory) + .singleton(), saveCertificateTask: asFunction(saveCertificateTaskFactory), reindexNodeTask: asFunction(reindexNodeTaskFactory).singleton(), getCoreScope: asFunction(getCoreScopeFactory).singleton(), @@ -358,7 +359,8 @@ export default async function createDIContainer(options = {}) { */ container.register({ scheduleRenewZeroSslCertificate: asFunction(scheduleRenewZeroSslCertificateFactory).singleton(), - scheduleRenewLetsEncryptCertificate: asFunction(scheduleRenewLetsEncryptCertificateFactory).singleton(), + scheduleRenewLetsEncryptCertificate: asFunction(scheduleRenewLetsEncryptCertificateFactory) + .singleton(), createHttpApiServer: asFunction(createHttpApiServerFactory).singleton(), }); diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index 824ef238029..ebac51b47a9 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -6,7 +6,7 @@ import os from 'os'; import { ERRORS } from '../../../../ssl/letsencrypt/validateLetsEncryptCertificateFactory.js'; import LegoCertificate from '../../../../ssl/letsencrypt/LegoCertificate.js'; -const LEGO_IMAGE = 'goacme/lego:v4.16.1'; +const LEGO_IMAGE = 'goacme/lego:v4.31.0'; /** * @param {Docker} docker @@ -108,6 +108,7 @@ export default function obtainLetsEncryptCertificateTaskFactory( const command = ctx.isRenewal ? 'renew' : 'run'; // Build lego command arguments + // --disable-cn is needed for IP address certificates const legoArgs = [ '--server=https://acme-v02.api.letsencrypt.org/directory', '--email', ctx.email, diff --git a/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js index e29b778ae40..503098c30e3 100644 --- a/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js +++ b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js @@ -24,7 +24,10 @@ export default function validateLetsEncryptCertificateFactory(homeDir) { * @param {number} expirationDays * @return {Promise<{ [error: String], [data: Object] }>} */ - async function validateLetsEncryptCertificate(config, expirationDays = LegoCertificate.EXPIRATION_LIMIT_DAYS) { + async function validateLetsEncryptCertificate( + config, + expirationDays = LegoCertificate.EXPIRATION_LIMIT_DAYS, + ) { const data = {}; // SSL output directory (where we copy final certs for gateway) From ef47a60587d86cd2fc834ee63823ecd16ea51d2a Mon Sep 17 00:00:00 2001 From: ktechmidas Date: Tue, 20 Jan 2026 10:28:36 +0000 Subject: [PATCH 3/7] Config migration --- .../configs/getConfigFileMigrationsFactory.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/dashmate/configs/getConfigFileMigrationsFactory.js b/packages/dashmate/configs/getConfigFileMigrationsFactory.js index a19af3e7df6..065649dbfe2 100644 --- a/packages/dashmate/configs/getConfigFileMigrationsFactory.js +++ b/packages/dashmate/configs/getConfigFileMigrationsFactory.js @@ -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; + }, }; } From 76d31c99de86625ed414046a6b3f5bc9faa8fa99 Mon Sep 17 00:00:00 2001 From: ktechmidas Date: Tue, 20 Jan 2026 10:48:41 +0000 Subject: [PATCH 4/7] Fix small errors from coderabbit --- .../obtainLetsEncryptCertificateTaskFactory.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index ebac51b47a9..a1511dc9a2b 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -78,7 +78,7 @@ export default function obtainLetsEncryptCertificateTaskFactory( ctx.isRenewal = true; break; case ERRORS.CERTIFICATE_IP_MISMATCH: - throw new Error(`Certificate IP ${ctx.certificate.commonName} does not match external IP ${ctx.externalIp}.\n` + throw new Error(`Certificate does not match external IP ${ctx.externalIp}.\n` + 'Please change the external IP in config or use --force to obtain a new certificate.'); case ERRORS.CERTIFICATE_NOT_VALID: // eslint-disable-next-line no-param-reassign @@ -183,11 +183,15 @@ export default function obtainLetsEncryptCertificateTaskFactory( + 'for incoming HTTP connections.'); } - // Verify certificate was created + // Verify certificate and key were created if (!fs.existsSync(ctx.legoCertPath)) { throw new Error('Certificate file was not created by lego'); } + if (!fs.existsSync(ctx.legoKeyPath)) { + throw new Error('Private key file was not created by lego'); + } + // eslint-disable-next-line no-param-reassign task.output = 'Certificate obtained successfully'; }, From 97555b0f1ab58677aebbc4e96c2b85ba0b177ea8 Mon Sep 17 00:00:00 2001 From: ktechmidas Date: Tue, 20 Jan 2026 10:56:55 +0000 Subject: [PATCH 5/7] Small fixes --- ...eduleRenewLetsEncryptCertificateFactory.js | 19 ++++++++++++++++++- ...obtainLetsEncryptCertificateTaskFactory.js | 9 +++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js b/packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js index a3319735a8a..f14f1c937a2 100644 --- a/packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js +++ b/packages/dashmate/src/helper/scheduleRenewLetsEncryptCertificateFactory.js @@ -64,6 +64,8 @@ export default function scheduleRenewLetsEncryptCertificateFactory( 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); @@ -82,15 +84,30 @@ export default function scheduleRenewLetsEncryptCertificateFactory( // 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 - process.nextTick(() => scheduleRenewLetsEncryptCertificate(config)); + 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); + } }); job.start(); diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index a1511dc9a2b..0e69b5e3744 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -135,6 +135,15 @@ export default function obtainLetsEncryptCertificateTaskFactory( try { const existingContainer = await docker.getContainer(containerName); await existingContainer.remove({ force: true }); + + try { + await existingContainer.wait(); + } catch (waitError) { + // Skip error if container is already removed + if (waitError.statusCode !== 404) { + throw waitError; + } + } } catch (e) { // Container doesn't exist, that's fine if (e.statusCode !== 404) { From e5f51a1e6e65809d2560674083853a46d849ae58 Mon Sep 17 00:00:00 2001 From: ktechmidas Date: Tue, 20 Jan 2026 10:58:16 +0000 Subject: [PATCH 6/7] Using nullish coalescing --- .../ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js index 0e69b5e3744..004e45de7d9 100644 --- a/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/letsencrypt/obtainLetsEncryptCertificateTaskFactory.js @@ -40,7 +40,7 @@ export default function obtainLetsEncryptCertificateTaskFactory( title: 'Check if certificate already exists and is valid', skip: (ctx) => ctx.force, task: async (ctx, task) => { - const expirationDays = ctx.expirationDays || LegoCertificate.EXPIRATION_LIMIT_DAYS; + const expirationDays = ctx.expirationDays ?? LegoCertificate.EXPIRATION_LIMIT_DAYS; const { error, data } = await validateLetsEncryptCertificate(config, expirationDays); Object.assign(ctx, data); From b59d867a3c04aa153b153331f5bb72f21fa68252 Mon Sep 17 00:00:00 2001 From: ktechmidas Date: Tue, 20 Jan 2026 11:00:54 +0000 Subject: [PATCH 7/7] A few more fixes --- .../src/ssl/letsencrypt/LegoCertificate.js | 24 +++++++++++++++++-- .../validateLetsEncryptCertificateFactory.js | 9 ++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/dashmate/src/ssl/letsencrypt/LegoCertificate.js b/packages/dashmate/src/ssl/letsencrypt/LegoCertificate.js index 2dab024a990..fb4d2c88763 100644 --- a/packages/dashmate/src/ssl/letsencrypt/LegoCertificate.js +++ b/packages/dashmate/src/ssl/letsencrypt/LegoCertificate.js @@ -13,22 +13,29 @@ export default class LegoCertificate { created; /** - * @type {string} + * @type {string|null} */ commonName; + /** + * @type {string[]} + */ + ipAddresses; + static EXPIRATION_LIMIT_DAYS = 2; /** * @param {Object} data * @param {Date} data.expires * @param {Date} data.created - * @param {string} data.commonName + * @param {string|null} data.commonName + * @param {string[]} data.ipAddresses */ constructor(data) { this.expires = data.expires; this.created = data.created; this.commonName = data.commonName; + this.ipAddresses = data.ipAddresses || []; } /** @@ -55,10 +62,23 @@ export default class LegoCertificate { (attr) => attr.shortName === 'CN', ); + // Extract IP addresses from Subject Alternative Name extension + const ipAddresses = []; + const sanExtension = cert.getExtension('subjectAltName'); + if (sanExtension && sanExtension.altNames) { + for (const altName of sanExtension.altNames) { + // Type 7 is IP address in SAN + if (altName.type === 7 && altName.ip) { + ipAddresses.push(altName.ip); + } + } + } + return new LegoCertificate({ expires: cert.validity.notAfter, created: cert.validity.notBefore, commonName: commonNameAttr ? commonNameAttr.value : null, + ipAddresses, }); } diff --git a/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js index 503098c30e3..02edc2710d4 100644 --- a/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js +++ b/packages/dashmate/src/ssl/letsencrypt/validateLetsEncryptCertificateFactory.js @@ -96,7 +96,14 @@ export default function validateLetsEncryptCertificateFactory(homeDir) { data.expirationDays = expirationDays; // Check if certificate IP matches external IP - if (data.certificate.commonName && data.certificate.commonName !== data.externalIp) { + // First check SANs (preferred for IP certificates with --disable-cn) + // Fall back to commonName if no IP SANs present + const certIpAddresses = data.certificate.ipAddresses; + const hasMatchingIp = certIpAddresses.length > 0 + ? certIpAddresses.includes(data.externalIp) + : data.certificate.commonName === data.externalIp; + + if (!hasMatchingIp) { return { error: ERRORS.CERTIFICATE_IP_MISMATCH, data,