diff --git a/packages/dashmate/src/createDIContainer.js b/packages/dashmate/src/createDIContainer.js index 839b4dfa77e..2e789d4a884 100644 --- a/packages/dashmate/src/createDIContainer.js +++ b/packages/dashmate/src/createDIContainer.js @@ -117,6 +117,7 @@ import analyseSamplesTaskFactory from './listr/tasks/doctor/analyseSamplesTaskFa import collectSamplesTaskFactory from './listr/tasks/doctor/collectSamplesTaskFactory.js'; import prescriptionTaskFactory from './listr/tasks/doctor/prescriptionTaskFactory.js'; import verifySystemRequirementsFactory from './doctor/verifySystemRequirementsFactory.js'; +import validateZeroSslCertificateFactory from './ssl/zerossl/validateZeroSslCertificateFactory.js'; /** * @param {Object} [options] @@ -315,6 +316,13 @@ export default async function createDIContainer(options = {}) { prescriptionTask: asFunction(prescriptionTaskFactory).singleton(), }); + /** + * SSL + */ + container.register({ + validateZeroSslCertificate: asFunction(validateZeroSslCertificateFactory).singleton(), + }); + /** * Doctor */ diff --git a/packages/dashmate/src/listr/prompts/validators/validateSslCertificateFiles.js b/packages/dashmate/src/listr/prompts/validators/validateSslCertificateFiles.js new file mode 100644 index 00000000000..8c23a840699 --- /dev/null +++ b/packages/dashmate/src/listr/prompts/validators/validateSslCertificateFiles.js @@ -0,0 +1,33 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; + +/** + * @param {string} chainFilePath + * @param {string} privateFilePath + * @return {boolean} + */ +export default function validateSslCertificateFiles(chainFilePath, privateFilePath) { + const bundlePem = fs.readFileSync(chainFilePath, 'utf8'); + const privateKeyPem = fs.readFileSync(privateFilePath, 'utf8'); + + // Step 2: Create a signature using the private key + const data = 'This is a test message'; + const sign = crypto.createSign('SHA256'); + sign.update(data); + sign.end(); + + const signature = sign.sign(privateKeyPem, 'hex'); + + // Verify the signature using the public key from the certificate + const verify = crypto.createVerify('SHA256'); + verify.update(data); + verify.end(); + + // Extract the public key from the first certificate in the bundle + const certificate = crypto.createPublicKey({ + key: bundlePem, + format: 'pem', + }); + + return verify.verify(certificate, signature, 'hex'); +} diff --git a/packages/dashmate/src/listr/tasks/doctor/analyseSamplesTaskFactory.js b/packages/dashmate/src/listr/tasks/doctor/analyseSamplesTaskFactory.js index 9acf36ed07b..5689c5d7580 100644 --- a/packages/dashmate/src/listr/tasks/doctor/analyseSamplesTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/doctor/analyseSamplesTaskFactory.js @@ -1,17 +1,26 @@ import chalk from 'chalk'; import { Listr } from 'listr2'; +import validateSslCertificateFiles from "../../prompts/validators/validateSslCertificateFiles.js"; +import fs from "fs"; +import path from "path"; +import validateZeroSslCertificateFactory, {ERRORS} from "../../../ssl/zerossl/validateZeroSslCertificateFactory.js"; +import Certificate from "../../../ssl/zerossl/Certificate.js"; /** * * @param {DockerCompose} dockerCompose * @param {getServiceList} getServiceList * @param {verifySystemRequirements} verifySystemRequirements + * @param {HomeDir} homeDir + * @param {validateZeroSslCertificate} validateZeroSslCertificate * @return {analyseSamplesTask} */ export default function analyseSamplesTaskFactory( dockerCompose, getServiceList, verifySystemRequirements, + homeDir, + validateZeroSslCertificate, ) { /** * @typedef {function} analyseSamplesTask @@ -216,7 +225,7 @@ export default function analyseSamplesTaskFactory( problem = chalk`Services ${ctx.servicesNotStarted.map((e) => e.service.title).join(', ')} aren't started.` } - problem += chalk`\n\nTry {bold.blueBright dashmate start --force} to make sure all services are started`; + problem += chalk`\n\nTry {bold.cyanBright dashmate start --force} to make sure all services are started`; ctx.problems.push(problem); } @@ -267,19 +276,160 @@ export default function analyseSamplesTaskFactory( ctx.problems.push(problem); } - if (ctx.systemResourceProblems.length > 0) { + const systemResourceProblems = Object.values(ctx.systemResourceProblems); + if (systemResourceProblems.length > 0) { + let problem; + if (systemResourceProblems.length === 1) { + problem = chalk`A system resource warning:\n${systemResourceProblems[0]}`; + } else { + problem = chalk`${systemResourceProblems.length} system resource warnings:\n${systemResourceProblems.join('\n')}`; + } + ctx.problems.push(problem); } }, }, - // TODO: dont have priavate ky to sign { - title: 'Services are started', - skip: (ctx) => ctx.skipOthers, - task: async (ctx, task) => { + title: 'Configuration', + task: async () => ( + new Listr([ + { + title: 'Gateway admin is enabled if metrics are enabled', + enabled: config.get('platform.enabled'), + task: async (ctx, task) => { + if (config.get('platform.gateway.metrics.enabled') && !config.get('platform.gateway.admin.enabled')) { + const problem = chalk`Gateway admin is disabled while metrics are enabled - // TODO: metrics enabled but admin is not - }, +Please enable gateway admin: {bold.cyanBright dashmate config set platform.gateway.admin.enabled true}`; + + ctx.problems.push(problem); + + throw new Error(task.title); + } + }, + }, + { + title: 'Platform Node ID', + enabled: config.get('platform.enabled'), + task: async (ctx, task) => { + const masternodeStatus = ctx.samples.getServiceInfo('core', 'masternodeStatus'); + const platformNodeId = masternodeStatus?.dmnState?.platformNodeId; + if (platformNodeId && config.get('platform.drive.tenderdash.node.id') !== platformNodeId) { + const problem = chalk`Platform Node ID doesn't match the one in the ProReg transaction + +Please set correct Node ID and Node Key: +{bold.cyanBright dashmate config set platform.drive.tenderdash.node.id ID +dashmate config set platform.drive.tenderdash.node.key KEY} + +Or update Node ID in masternode list with ProServUp transaction`; + + ctx.problems.push(problem); + + throw new Error(task.title); + } + }, + }, + { + title: 'SSL certificate', + enabled: config.get('platform.enabled'), + task: async (ctx, task) => { + const certificatesDir = homeDir.joinPath( + config.getName(), + 'platform', + 'gateway', + 'ssl', + ); + + const chainFilePath = path.join(certificatesDir, 'bundle.crt'); + const privateFilePath = path.join(certificatesDir, 'private.key'); + + if (!fs.existsSync(chainFilePath) || !fs.existsSync(privateFilePath)) { + const problem = chalk`SSL certificate files are not found + +Certificate chain file path: {bold.cyanBright ${chainFilePath}} +Private key file path: {bold.cyanBright ${privateFilePath}} + +Please get certificates and place files to the correct location. +Another optionUse ZeroSSL to obtain a new one https://docs.dash.org/en/stable/masternodes/dashmate.html#ssl-certificate`; + + ctx.problems.push(problem); + + throw new Error(task.title); + } + + const isValid = validateSslCertificateFiles(chainFilePath, privateFilePath); + + if (!isValid) { + const problem = chalk`SSL certificate files aren't valid + +Certificate chain file path: {bold.cyanBright ${chainFilePath}} +Private key file path: {bold.cyanBright ${privateFilePath}} + +Please make sure certificate chain contains actual server certificate at the top of the file and it corresponds to private`; + + ctx.problems.push(problem); + + throw new Error(task.title); + } + }, + }, + { + title: 'ZeroSSL certificate', + enabled: config.get('platform.gateway.ssl.provider') === 'zerossl', + task: async (ctx, task) => { + const { + error, + data, + } = validateZeroSslCertificate(config, Certificate.EXPIRATION_LIMIT_DAYS); + + const problem = { + [ERRORS.API_KEY_IS_NOT_SET]: chalk`ZeroSSL API key is not set. + +Please obtain your API key in {underline.cyanBright https://app.zerossl.com/developer} +And then update configuration with {block.cyanBright dashmate config set platform.gateway.ssl.providerConfigs.zerossl.apiKey [KEY]}`, + [ERRORS.EXTERNAL_IP_IS_NOT_SET]: chalk`External IP is not set. + +Please update configuration with your external IP using {block.cyanBright dashmate config set externalIp [IP]}`, + [ERRORS.CERTIFICATE_ID_IS_NOT_SET]: chalk`ZeroSSL certificate is not configured + +Please run {bold.cyanBright dashmate ssl obtain} to get a new one`, + [ERRORS.PRIVATE_KEY_IS_NOT_PRESENT]: chalk`ZeroSSL private key file not found in ${data.privateKeyFilePath}. + +Please regenerate the certificate using {bold.cyanBright dashmate ssl obtain --force} +and revoke the previous certificate in the ZeroSSL dashboard`, + [ERRORS.EXTERNAL_IP_MISMATCH]: chalk`ZeroSSL IP ${data.certificate.common_name} does not match external IP ${data.externalIp}. + +Please regenerate the certificate using {bold.cyanBright dashmate ssl obtain --force} +and revoke the previous certificate in the ZeroSSL dashboard`, + [ERRORS.CSR_FILE_IS_NOT_PRESENT]: chalk`ZeroSSL certificate request file not found in ${data.csrFilePath}. +This makes auto renew impossible. + +If you need auto renew, please regenerate the certificate using {bold.cyanBright dashmate ssl obtain --force} +and revoke the previous certificate in the ZeroSSL dashboard`, + [ERRORS.CERTIFICATE_EXPIRES_SOON]: chalk`ZeroSSL certificate expires at ${data.certificate.expires}. + +Please run {bold.cyanBright dashmate ssl obtain} to get a new one`, + [ERRORS.CERTIFICATE_IS_NOT_VALIDATED]: chalk`ZeroSSL certificate expires at ${data.certificate.expires}. + +Please run {bold.cyanBright dashmate ssl obtain} to get a new one`, + [ERRORS.CERTIFICATE_IS_NOT_VALID]: chalk`ZeroSSL certificate is not valid. + + Please run {bold.cyanBright dashmate ssl zerossl obtain} to get a new one.`, + }[error]; + + if (problem) { + ctx.problems.push(problem); + + throw new Error(task.title); + } + }, + }, + // TODO: Get checks from the status command + // TODO: Errors in logs + ], { + exitOnError: false, + }) + ), }, ], { exitOnError: false, diff --git a/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js index ef6082771c1..50b5f5f050d 100644 --- a/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/setup/regular/configureSSLCertificateTaskFactory.js @@ -1,12 +1,14 @@ import fs from 'fs'; import { Listr } from 'listr2'; -import crypto from 'node:crypto'; + +import validateSslCertificateFiles from '../../../prompts/validators/validateSslCertificateFiles.js'; import { PRESET_MAINNET, SSL_PROVIDERS, NODE_TYPE_FULLNODE, } from '../../../../constants.js'; + import validateFileExists from '../../../prompts/validators/validateFileExists.js'; import listCertificates from '../../../../ssl/zerossl/listCertificates.js'; @@ -66,29 +68,7 @@ export default function configureSSLCertificateTaskFactory( return 'the same path for both files'; } - const bundlePem = fs.readFileSync(chainFilePath, 'utf8'); - const privateKeyPem = fs.readFileSync(privateFilePath, 'utf8'); - - // Step 2: Create a signature using the private key - const data = 'This is a test message'; - const sign = crypto.createSign('SHA256'); - sign.update(data); - sign.end(); - - const signature = sign.sign(privateKeyPem, 'hex'); - - // Verify the signature using the public key from the certificate - const verify = crypto.createVerify('SHA256'); - verify.update(data); - verify.end(); - - // Extract the public key from the first certificate in the bundle - const certificate = crypto.createPublicKey({ - key: bundlePem, - format: 'pem', - }); - - const isValid = verify.verify(certificate, signature, 'hex'); + const isValid = validateSslCertificateFiles(chainFilePath, privateFilePath); if (!isValid) { return 'The certificate and private key do not match'; diff --git a/packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js b/packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js index 12718ad4de4..4c76edb7a20 100644 --- a/packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js +++ b/packages/dashmate/src/listr/tasks/ssl/zerossl/obtainZeroSSLCertificateTaskFactory.js @@ -1,9 +1,10 @@ import { Listr } from 'listr2'; import chalk from 'chalk'; -import path from 'path'; import fs from 'fs'; +import lodash from 'lodash'; import wait from '../../../../util/wait.js'; +import { ERRORS } from '../../../../ssl/zerossl/validateZeroSslCertificateFactory.js'; /** * @param {generateCsr} generateCsr @@ -16,6 +17,7 @@ import wait from '../../../../util/wait.js'; * @param {saveCertificateTask} saveCertificateTask * @param {VerificationServer} verificationServer * @param {HomeDir} homeDir + * @param {validateZeroSslCertificate} validateZeroSslCertificate * @return {obtainZeroSSLCertificateTask} */ export default function obtainZeroSSLCertificateTaskFactory( @@ -29,6 +31,7 @@ export default function obtainZeroSSLCertificateTaskFactory( saveCertificateTask, verificationServer, homeDir, + validateZeroSslCertificate, ) { /** * @typedef {obtainZeroSSLCertificateTask} @@ -36,124 +39,69 @@ export default function obtainZeroSSLCertificateTaskFactory( * @return {Promise} */ async function obtainZeroSSLCertificateTask(config) { - // Make sure that required config options are set - const apiKey = config.get('platform.gateway.ssl.providerConfigs.zerossl.apiKey', true); - const externalIp = config.get('externalIp', true); - - const sslConfigDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'ssl'); - const csrFilePath = path.join(sslConfigDir, 'csr.pem'); - const privateKeyFilePath = path.join(sslConfigDir, 'private.key'); - const bundleFilePath = path.join(sslConfigDir, 'bundle.crt'); - - // Ensure we have config dir created - fs.mkdirSync(sslConfigDir, { recursive: true }); - return new Listr([ { title: 'Check if certificate already exists and not expiring soon', // Skips the check if force flag is set skip: (ctx) => ctx.force, task: async (ctx, task) => { - const certificateId = await config.get('platform.gateway.ssl.providerConfigs.zerossl.id'); - - if (!certificateId) { - // Certificate is not configured - - // eslint-disable-next-line no-param-reassign - task.output = 'Certificate is not configured yet, creating a new one'; - - return; - } - - // Certificate is already configured - - // Check if certificate files are present - ctx.isCrtFilePresent = fs.existsSync(csrFilePath); - - ctx.isPrivateKeyFilePresent = fs.existsSync(privateKeyFilePath); - - ctx.isBundleFilePresent = fs.existsSync(bundleFilePath); + const { error, data } = await validateZeroSslCertificate(config, ctx.expirationDays); - // This function will throw an error if certificate with specified ID is not present - const certificate = await getCertificate(apiKey, certificateId); + lodash.merge(ctx, data); - // If certificate exists but private key does not, then we can't setup TLS connection - // In this case we need to regenerate certificate or put back this private key - if (!ctx.isPrivateKeyFilePresent) { - throw new Error(`Certificate private key file not found in ${privateKeyFilePath}.\n` - + 'Please regenerate the certificate using the the obtain' - + ' command with the --force flag, and revoke the previous certificate in' - + ' the ZeroSSL dashboard'); - } - - // We need to make sure that external IP and certificate IP match - if (certificate.common_name !== externalIp) { - throw new Error(`Certificate IPe ${certificate.common_name} does not match external IP ${externalIp}.\n` - + 'Please change the external IP in config or regenerate the certificate ' - + ' using the obtain command with the --force flag, and revoke the previous' - + ' certificate in the ZeroSSL dashboard'); - } - - if (!certificate.isExpiredInDays(ctx.expirationDays)) { - // Certificate is not going to expire soon - - if (certificate.status === 'issued') { - // Certificate is valid, so we might need only to download certificate bundle - ctx.certificate = certificate; + // Ensure we have config dir created + fs.mkdirSync(ctx.sslConfigDir, { recursive: true }); + switch (error) { + case undefined: // eslint-disable-next-line no-param-reassign - task.output = `Certificate is valid and expires at ${certificate.expires}`; - } else if (['pending_validation', 'draft'].includes(certificate.status)) { - // Certificate is already created, so we just need to pass validation - // and download certificate file - ctx.certificate = certificate; - - // We need to download new certificate bundle - ctx.isBundleFilePresent = false; - - // eslint-disable-next-line no-param-reassign - task.output = 'Certificate was already created, but not validated yet.'; - } else { - // Certificate is not valid, so we need to re-create it - - // We need to download certificate bundle - ctx.isBundleFilePresent = false; - - if (!ctx.isCrtFilePresent) { - throw new Error(`Certificate request file not found in ${csrFilePath}.\n` - + 'To create a new certificate, please use the obtain' - + ' command with the --force flag and revoke the previous certificate' - + ' in the ZeroSSL dashboard'); - } - - ctx.csr = fs.readFileSync(csrFilePath, 'utf8'); - + task.output = `Certificate is valid and expires at ${ctx.certificate.expires}`; + break; + case ERRORS.API_KEY_IS_NOT_SET: + throw new Error('ZeroSSL API key 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_ID_IS_NOT_SET: // eslint-disable-next-line no-param-reassign - task.output = 'Certificate is not valid. Create a new one'; - } - } else { - // Certificate is going to expire soon, we need to obtain a new one - - // We need to download new certificate bundle - ctx.isBundleFilePresent = false; - - if (!ctx.isCrtFilePresent) { - throw new Error(`Certificate request file not found in ${csrFilePath}.\n` + task.output = 'Certificate is not configured yet, creating a new one'; + break; + case ERRORS.PRIVATE_KEY_IS_NOT_PRESENT: + // If certificate exists but private key does not, then we can't set up TLS connection + // In this case we need to regenerate certificate or put back this private key + throw new Error(`Certificate private key file not found in ${ctx.privateKeyFilePath}.\n` + + 'Please regenerate the certificate using the the obtain' + + ' command with the --force flag, and revoke the previous certificate in' + + ' the ZeroSSL dashboard'); + case ERRORS.EXTERNAL_IP_MISMATCH: + throw new Error(`Certificate IPe ${ctx.certificate.common_name} does not match external IP ${ctx.externalIp}.\n` + + 'Please change the external IP in config or regenerate the certificate ' + + ' using the obtain command with the --force flag, and revoke the previous' + + ' certificate in the ZeroSSL dashboard'); + case ERRORS.CSR_FILE_IS_NOT_PRESENT: + throw new Error(`Certificate request file not found in ${ctx.csrFilePath}.\n` + 'To renew certificate please use the obtain' + ' command with the --force flag, and revoke the previous certificate in' + ' the ZeroSSL dashboard'); - } - - ctx.csr = fs.readFileSync(csrFilePath, 'utf8'); - - // eslint-disable-next-line no-param-reassign - task.output = `Certificate exists but expires in less than ${ctx.expirationDays} days at ${certificate.expires}. Obtain a new one`; + case ERRORS.CERTIFICATE_EXPIRES_SOON: + // eslint-disable-next-line no-param-reassign + task.output = `Certificate exists but expires in less than ${ctx.expirationDays} days at ${ctx.certificate.expires}. Obtain a new one`; + break; + case ERRORS.CERTIFICATE_IS_NOT_VALIDATED: + // eslint-disable-next-line no-param-reassign + task.output = 'Certificate was already created, but not validated yet.'; + break; + case ERRORS.CERTIFICATE_IS_NOT_VALID: + // eslint-disable-next-line no-param-reassign + task.output = 'Certificate is not valid. Create a new one'; + break; + default: + throw new Error(`Unknown error: ${error}`); } }, }, { title: 'Generate a keypair', - enabled: (ctx) => !ctx.isCrtFilePresent, + enabled: (ctx) => !ctx.isCsrFilePresent, task: async (ctx) => { ctx.keyPair = await generateKeyPair(); ctx.privateKeyFile = ctx.keyPair.privateKey; @@ -161,11 +109,11 @@ export default function obtainZeroSSLCertificateTaskFactory( }, { title: 'Generate certificate request', - enabled: (ctx) => !ctx.isCrtFilePresent, + enabled: (ctx) => !ctx.isCsrFilePresent, task: async (ctx) => { ctx.csr = await generateCsr( ctx.keyPair, - externalIp, + ctx.externalIp, ); }, }, @@ -175,8 +123,8 @@ export default function obtainZeroSSLCertificateTaskFactory( task: async (ctx) => { ctx.certificate = await createZeroSSLCertificate( ctx.csr, - externalIp, - apiKey, + ctx.externalIp, + ctx.apiKey, ); config.set('platform.gateway.ssl.enabled', true); @@ -188,7 +136,7 @@ export default function obtainZeroSSLCertificateTaskFactory( title: 'Set up verification server', skip: (ctx) => ctx.certificate && !['pending_validation', 'draft'].includes(ctx.certificate.status), task: async (ctx) => { - const validationResponse = ctx.certificate.validation.other_methods[externalIp]; + const validationResponse = ctx.certificate.validation.other_methods[ctx.externalIp]; await verificationServer.setup( config, @@ -209,14 +157,14 @@ export default function obtainZeroSSLCertificateTaskFactory( let retry; do { try { - await verifyDomain(ctx.certificate.id, apiKey); + await verifyDomain(ctx.certificate.id, ctx.apiKey); } catch (e) { if (ctx.noRetry !== true) { retry = await task.prompt({ type: 'toggle', header: chalk` An error occurred during verification: {red ${e.message}} - Please ensure that port 80 on your public IP address ${externalIp} is open + Please ensure that port 80 on your public IP address ${ctx.externalIp} is open for incoming HTTP connections. You may need to configure your firewall to ensure this port is accessible from the public internet. If you are using Network Address Translation (NAT), please enable port forwarding for port 80 @@ -245,7 +193,7 @@ export default function obtainZeroSSLCertificateTaskFactory( try { ctx.certificateFile = await downloadCertificate( ctx.certificate.id, - apiKey, + ctx.apiKey, ); // eslint-disable-next-line no-param-reassign @@ -271,30 +219,30 @@ export default function obtainZeroSSLCertificateTaskFactory( title: 'Save certificate private key file', enabled: (ctx) => !ctx.isPrivateKeyFilePresent, task: async (ctx, task) => { - fs.writeFileSync(privateKeyFilePath, ctx.privateKeyFile, 'utf8'); + fs.writeFileSync(ctx.privateKeyFilePath, ctx.privateKeyFile, 'utf8'); // eslint-disable-next-line no-param-reassign - task.output = privateKeyFilePath; + task.output = ctx.privateKeyFilePath; }, }, { title: 'Save certificate request file', - enabled: (ctx) => !ctx.isCrtFilePresent, + enabled: (ctx) => !ctx.isCsrFilePresent, task: async (ctx, task) => { - fs.writeFileSync(csrFilePath, ctx.csr, 'utf8'); + fs.writeFileSync(ctx.csrFilePath, ctx.csr, 'utf8'); // eslint-disable-next-line no-param-reassign - task.output = csrFilePath; + task.output = ctx.csrFilePath; }, }, { title: 'Save certificate file', skip: (ctx) => ctx.isBundleFilePresent, task: async (ctx, task) => { - fs.writeFileSync(bundleFilePath, ctx.certificateFile, 'utf8'); + fs.writeFileSync(ctx.bundleFilePath, ctx.certificateFile, 'utf8'); // eslint-disable-next-line no-param-reassign - task.output = bundleFilePath; + task.output = ctx.bundleFilePath; }, }, { diff --git a/packages/dashmate/src/ssl/zerossl/validateZeroSslCertificateFactory.js b/packages/dashmate/src/ssl/zerossl/validateZeroSslCertificateFactory.js new file mode 100644 index 00000000000..eda9a19b719 --- /dev/null +++ b/packages/dashmate/src/ssl/zerossl/validateZeroSslCertificateFactory.js @@ -0,0 +1,139 @@ +import fs from 'fs'; +import path from 'path'; +import getCertificate from './getCertificate.js'; + +export const ERRORS = { + API_KEY_IS_NOT_SET: 'API_KEY_IS_NOT_SET', + EXTERNAL_IP_IS_NOT_SET: 'EXTERNAL_IP_IS_NOT_SET', + CERTIFICATE_ID_IS_NOT_SET: 'CERTIFICATE_ID_IS_NOT_SET', + PRIVATE_KEY_IS_NOT_PRESENT: 'PRIVATE_KEY_IS_NOT_PRESENT', + EXTERNAL_IP_MISMATCH: 'EXTERNAL_IP_MISMATCH', + CSR_FILE_IS_NOT_PRESENT: 'CSR_FILE_IS_NOT_PRESENT', + CERTIFICATE_EXPIRES_SOON: 'CERTIFICATE_EXPIRES_SOON', + CERTIFICATE_IS_NOT_VALIDATED: 'CERTIFICATE_IS_NOT_VALIDATED', + CERTIFICATE_IS_NOT_VALID: 'CERTIFICATE_IS_NOT_VALID', +}; + +/** + * @param {HomeDir} homeDir + * @return {validateZeroSslCertificate} + */ +export default function validateZeroSslCertificateFactory(homeDir) { + /** + * @typedef {validateZeroSslCertificate} + * @param {Config} config + * @param {number} expirationDays + * @return {Promise<{ [error: String], [data: Object] }>} + */ + async function validateZeroSslCertificate(config, expirationDays) { + const data = {}; + + data.sslConfigDir = homeDir.joinPath(config.getName(), 'platform', 'gateway', 'ssl'); + data.csrFilePath = path.join(data.sslConfigDir, 'csr.pem'); + data.privateKeyFilePath = path.join(data.sslConfigDir, 'private.key'); + data.bundleFilePath = path.join(data.sslConfigDir, 'bundle.crt'); + + data.apiKey = config.get('platform.gateway.ssl.providerConfigs.zerossl.apiKey'); + + if (!data.apiKey) { + return { + error: ERRORS.API_KEY_IS_NOT_SET, + data, + }; + } + + data.externalIp = config.get('externalIp'); + + if (!data.externalIp) { + return { + error: ERRORS.EXTERNAL_IP_IS_NOT_SET, + data, + }; + } + + const certificateId = config.get('platform.gateway.ssl.providerConfigs.zerossl.id'); + + if (!certificateId) { + return { + error: ERRORS.CERTIFICATE_ID_IS_NOT_SET, + data, + }; + } + + // Certificate is already configured + + // Check if certificate files are present + data.isCsrFilePresent = fs.existsSync(data.csrFilePath); + data.isPrivateKeyFilePresent = fs.existsSync(data.privateKeyFilePath); + data.isBundleFilePresent = fs.existsSync(data.bundleFilePath); + + // This function will throw an error if certificate with specified ID is not present + const certificate = await getCertificate(data.apiKey, certificateId); + + data.isExpiresSoon = certificate.isExpiredInDays(expirationDays); + + // If certificate exists but private key does not, then we can't setup TLS connection + // In this case we need to regenerate a certificate or put back this private key + if (!data.isPrivateKeyFilePresent) { + return { + error: ERRORS.PRIVATE_KEY_IS_NOT_PRESENT, + data, + }; + } + + // We need to make sure that external IP and certificate IP match + if (certificate.common_name !== data.externalIp) { + return { + error: ERRORS.EXTERNAL_IP_MISMATCH, + data, + }; + } + + if (['pending_validation', 'draft'].includes(certificate.status)) { + // Certificate is already created, so we just need to pass validation + // and download certificate file + data.certificate = certificate; + + // We need to download new certificate bundle + data.isBundleFilePresent = false; + + return { + error: ERRORS.CERTIFICATE_IS_NOT_VALIDATED, + data, + }; + } + + if (certificate.status !== 'issued' || data.isExpiresSoon) { + // Certificate is going to expire soon, or current certificate is not valid + // we need to obtain a new one + + // We need to download new certificate bundle + data.isBundleFilePresent = false; + + if (!data.isCsrFilePresent) { + return { + error: ERRORS.CSR_FILE_IS_NOT_PRESENT, + data, + }; + } + + data.csr = fs.readFileSync(data.csrFilePath, 'utf8'); + + return { + error: data.isExpiresSoon + ? ERRORS.CERTIFICATE_EXPIRES_SOON + : ERRORS.CERTIFICATE_IS_NOT_VALID, + data, + }; + } + + // Certificate is valid, so we might need only to download certificate bundle + data.certificate = certificate; + + return { + data, + }; + } + + return validateZeroSslCertificate; +}