From 33ed30ecd2ca7bf1ceb91145831c1eed9ac1118e Mon Sep 17 00:00:00 2001 From: fernando Date: Thu, 12 Aug 2021 08:19:35 -0300 Subject: [PATCH] feat: renewCertificates commands feat: certificate expiration warnings when upgrading feat: certificate expiration check in healthCheck Note: Node Private Keys are kept, not regenerated --- CHANGELOG.md | 1 + README.md | 1 + docs/renewCertificates.md | 54 ++++++ presets/shared.yml | 4 +- presets/testnet/network.yml | 2 +- src/commands/renewCertificates.ts | 107 +++++++++++ src/model/ConfigPreset.ts | 3 + src/service/CertificateService.ts | 246 +++++++++++++++++------- src/service/ConfigService.ts | 14 +- src/service/RunService.ts | 31 ++- src/service/RuntimeService.ts | 12 +- test/service/CertificateService.test.ts | 137 ++++++++++--- test/service/RuntimeService.test.ts | 6 + 13 files changed, 503 insertions(+), 115 deletions(-) create mode 100644 docs/renewCertificates.md create mode 100644 src/commands/renewCertificates.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d4524d4e..3f85f2505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The changelog format is based on [Keep a Changelog](https://keepachangelog.com/e | Symbol Bootstrap | v1.1.2 | [symbol-bootstrap](https://www.npmjs.com/package/symbol-bootstrap) | - Joeynet Testnet Release. +- Added Node SSL Certificate check and upgrade. Added `renewCertificates` command to renew the certificates. - The `bootstrap` preset is not the default anymore. The name must be provided via `--preset` or as a custom preset field. - A 'safe' custom preset is cached in the target folder. It's not required when upgrading the node without a configuration change. - Added `--logger` option to the commands. diff --git a/README.md b/README.md index f16719b59..3ccb9a667 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,7 @@ npm run style:fix * [`symbol-bootstrap link`](docs/link.md) - It announces VRF and Voting Link transactions to the network for each node with 'Peer' or 'Voting' roles. This command finalizes the node registration to an existing network. * [`symbol-bootstrap modifyMultisig`](docs/modifyMultisig.md) - Create or modify a multisig account * [`symbol-bootstrap pack`](docs/pack.md) - It configures and packages your node into a zip file that can be uploaded to the final node machine. +* [`symbol-bootstrap renewCertificates`](docs/renewCertificates.md) - It renews the SSL certificates of the node regenerating the main ca.cert.pem and node.csr.pem files but reusing the current private keys. * [`symbol-bootstrap report`](docs/report.md) - it generates reStructuredText (.rst) reports describing the configuration of each node. * [`symbol-bootstrap resetData`](docs/resetData.md) - It removes the data keeping the generated configuration, certificates, keys and block 1. * [`symbol-bootstrap run`](docs/run.md) - It boots the network via docker using the generated `docker-compose.yml` file and configuration. The config and compose methods/commands need to be called before this method. This is just a wrapper for the `docker-compose up` bash call. diff --git a/docs/renewCertificates.md b/docs/renewCertificates.md new file mode 100644 index 000000000..4fab0eba3 --- /dev/null +++ b/docs/renewCertificates.md @@ -0,0 +1,54 @@ +`symbol-bootstrap renewCertificates` +==================================== + +It renews the SSL certificates of the node regenerating the main ca.cert.pem and node.csr.pem files but reusing the current private keys. + +This command does not change the node private key (yet). This change would require a harvesters.dat migration and relinking the node key. + +It's recommended to backup the target folder before running this operation! + +* [`symbol-bootstrap renewCertificates`](#symbol-bootstrap-renewcertificates) + +## `symbol-bootstrap renewCertificates` + +It renews the SSL certificates of the node regenerating the main ca.cert.pem and node.csr.pem files but reusing the current private keys. + +``` +USAGE + $ symbol-bootstrap renewCertificates + +OPTIONS + -c, --customPreset=customPreset This command uses the encrypted addresses.yml to resolve the main and transport + private key. If the main and transport privates are only stored in the custom preset, + you can provide them using this param. Otherwise, the command may ask for them when + required. + + -h, --help It shows the help of this command. + + -t, --target=target [default: target] The target folder where the symbol-bootstrap network is generated + + -u, --user=user [default: current] User used to run docker images when generating the certificates. + "current" means the current user. + + --logger=logger [default: Console,File] The loggers the command will use. Options are: + Console,File,Silent. Use ',' to select multiple loggers. + + --noPassword When provided, Bootstrap will not use a password, so private keys will be stored in + plain text. Use with caution. + + --password=password A password used to encrypt and decrypt private keys in preset files like + addresses.yml and preset.yml. Bootstrap prompts for a password by default, can be + provided in the command line (--password=XXXX) or disabled in the command line + (--noPassword). + +DESCRIPTION + This command does not change the node private key (yet). This change would require a harvesters.dat migration and + relinking the node key. + + It's recommended to backup the target folder before running this operation! + +EXAMPLE + $ symbol-bootstrap renewCertificates +``` + +_See code: [src/commands/renewCertificates.ts](https://github.com/fboucquez/symbol-bootstrap/blob/v1.1.2/src/commands/renewCertificates.ts)_ diff --git a/presets/shared.yml b/presets/shared.yml index 10dc7453d..320bfe06b 100644 --- a/presets/shared.yml +++ b/presets/shared.yml @@ -86,7 +86,9 @@ catapultAppFolder: /usr/catapult enableRevoteOnBoot: true totalVotingBalanceCalculationFix: 0 treasuryReissuance: 0 - +caCertificateExpirationInDays: 7300 # 20 years +nodeCertificateExpirationInDays: 375 # 1.02 years +certificateExpirationWarningInDays: 30 # certificates are allowed to be renewed 30 before expiring # config database databaseName: catapult maxWriterThreads: 8 diff --git a/presets/testnet/network.yml b/presets/testnet/network.yml index 13da8a290..61141e497 100644 --- a/presets/testnet/network.yml +++ b/presets/testnet/network.yml @@ -22,7 +22,7 @@ importanceGrouping: 180 votingSetGrouping: 720 votingKeyDesiredLifetime: 720 votingKeyDesiredFutureLifetime: 120 -lastKnownNetworkEpoch: 125 +lastKnownNetworkEpoch: 126 minVotingKeyLifetime: 28 maxVotingKeyLifetime: 720 stepDuration: 4m diff --git a/src/commands/renewCertificates.ts b/src/commands/renewCertificates.ts new file mode 100644 index 000000000..5373e998a --- /dev/null +++ b/src/commands/renewCertificates.ts @@ -0,0 +1,107 @@ +/* + * Copyright 2021 Symbol + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Command, flags } from '@oclif/command'; +import { Account } from 'symbol-sdk'; +import { LoggerFactory, System } from '../logger'; +import { CertificatePair, ConfigAccount, ConfigPreset } from '../model'; +import { BootstrapUtils, CertificateService, CommandUtils, ConfigLoader } from '../service'; + +export default class RenewCertificates extends Command { + static description = `It renews the SSL certificates of the node regenerating the main ca.cert.pem and node.csr.pem files but reusing the current private keys. + +This command does not change the node private key (yet). This change would require a harvesters.dat migration and relinking the node key. + +It's recommended to backup the target folder before running this operation! +`; + + static examples = [`$ symbol-bootstrap renewCertificates`]; + + static flags = { + help: CommandUtils.helpFlag, + target: CommandUtils.targetFlag, + password: CommandUtils.passwordFlag, + noPassword: CommandUtils.noPasswordFlag, + customPreset: flags.string({ + char: 'c', + description: `This command uses the encrypted addresses.yml to resolve the main and transport private key. If the main and transport privates are only stored in the custom preset, you can provide them using this param. Otherwise, the command may ask for them when required.`, + required: false, + }), + user: flags.string({ + char: 'u', + description: `User used to run docker images when generating the certificates. "${BootstrapUtils.CURRENT_USER}" means the current user.`, + default: BootstrapUtils.CURRENT_USER, + }), + logger: CommandUtils.getLoggerFlag(...System), + }; + + public async run(): Promise { + const { flags } = this.parse(RenewCertificates); + CommandUtils.showBanner(); + const logger = LoggerFactory.getLogger(flags.logger); + const password = await CommandUtils.resolvePassword( + logger, + flags.password, + flags.noPassword, + CommandUtils.passwordPromptDefaultMessage, + true, + ); + const target = flags.target; + const configLoader = new ConfigLoader(logger); + const presetData = configLoader.loadExistingPresetData(target, password); + const addresses = configLoader.loadExistingAddresses(target, password); + const customPreset = configLoader.loadCustomPreset(flags.customPreset, password); + const mergedPresetData: ConfigPreset = configLoader.mergePresets(presetData, customPreset); + + const networkType = presetData.networkType; + const certificateService = new CertificateService(logger, { + target, + user: flags.user, + }); + const certificateUpgraded = ( + await Promise.all( + (mergedPresetData.nodes || []).map((nodePreset, index) => { + const nodeAccount = addresses.nodes?.[index]; + if (!nodeAccount) { + throw new Error(`There is not node in addresses at index ${index}`); + } + function resolveAccount(configAccount: ConfigAccount, providedPrivateKey: string | undefined): CertificatePair { + if (providedPrivateKey) { + const account = Account.createFromPrivateKey(providedPrivateKey, networkType); + if (account.address.plain() == configAccount.address) { + return account; + } + } + return configAccount; + } + const providedCertificates = { + main: resolveAccount(nodeAccount.main, nodePreset.mainPrivateKey), + transport: resolveAccount(nodeAccount.transport, nodePreset.transportPrivateKey), + }; + return certificateService.run(mergedPresetData, nodePreset.name, providedCertificates, true); + }), + ) + ).find((f) => f); + if (certificateUpgraded) { + logger.warn(''); + logger.warn('Bootstrap has created new SSL certificates. Review the logs!'); + logger.warn(''); + } else { + logger.info(''); + logger.info('The SSL certificates are up-to-date. There is nothing to upgrade.'); + logger.info(''); + } + } +} diff --git a/src/model/ConfigPreset.ts b/src/model/ConfigPreset.ts index 744d97ac0..4061316d0 100644 --- a/src/model/ConfigPreset.ts +++ b/src/model/ConfigPreset.ts @@ -251,6 +251,9 @@ export interface NodeConfigPreset { maxProofSize: number; maxTransactionsPerBlock: number; localNetworks: string; + caCertificateExpirationInDays: number; + nodeCertificateExpirationInDays: number; + certificateExpirationWarningInDays: number; } export interface NodePreset extends DockerServicePreset, Partial { diff --git a/src/service/CertificateService.ts b/src/service/CertificateService.ts index a92c88d06..fdae4e63d 100644 --- a/src/service/CertificateService.ts +++ b/src/service/CertificateService.ts @@ -41,7 +41,17 @@ export interface NodeCertificates { transport: CertificatePair; } +export interface CertificateConfigPreset { + networkType: NetworkType; + symbolServerImage: string; + caCertificateExpirationInDays: number; + nodeCertificateExpirationInDays: number; + certificateExpirationWarningInDays: number; +} + export class CertificateService { + public static NODE_CERTIFICATE_FILE_NAME = 'node.crt.pem'; + public static CA_CERTIFICATE_FILE_NAME = 'ca.cert.pem'; private static readonly METADATA_VERSION = 1; private readonly runtimeService: RuntimeService; @@ -50,62 +60,60 @@ export class CertificateService { this.runtimeService = new RuntimeService(this.logger); } - public static getCertificates(stdout: string): CertificatePair[] { - const locations = (string: string, substring: string): number[] => { - const indexes = []; - let i = -1; - while ((i = string.indexOf(substring, i + 1)) >= 0) indexes.push(i); - return indexes; - }; - - const extractKey = (subtext: string): string => { - const key = subtext - .trim() - .split(':') - .map((m) => m.trim()) - .join(''); - if (!key || key.length !== 64) { - throw Error(`SSL Certificate key cannot be loaded from the openssl script. Output: \n${subtext}`); - } - return key.toUpperCase(); - }; - - const from = 'priv:'; - const middle = 'pub:'; - const to = 'Certificate'; - - const indexes = locations(stdout, from); - - return indexes.map((index) => { - const privateKey = extractKey(stdout.substring(index + from.length, stdout.indexOf(middle, index))); - const publicKey = extractKey(stdout.substring(stdout.indexOf(middle, index) + middle.length, stdout.indexOf(to, index))); - return { privateKey: privateKey, publicKey: publicKey }; - }); - } - public async run( - networkType: NetworkType, - symbolServerImage: string, + presetData: CertificateConfigPreset, name: string, providedCertificates: NodeCertificates, + renewIfRequired: boolean, customCertFolder?: string, randomSerial?: string, - ): Promise { - const copyFrom = join(BootstrapUtils.ROOT_FOLDER, 'config', 'cert'); + ): Promise { const certFolder = customCertFolder || BootstrapUtils.getTargetNodesFolder(this.params.target, false, name, 'cert'); - const metadataFile = join(certFolder, 'metadata.yml'); - if (!(await this.shouldGenerateCertificate(metadataFile, providedCertificates))) { - this.logger.info(`Certificates for node ${name} have been previously generated. Reusing...`); - return; + const willExpireReport = await this.willCertificateExpire( + presetData.symbolServerImage, + certFolder, + CertificateService.NODE_CERTIFICATE_FILE_NAME, + presetData.certificateExpirationWarningInDays, + ); + + if (willExpireReport.willExpire) { + if (renewIfRequired) { + this.logger.info( + `The ${CertificateService.NODE_CERTIFICATE_FILE_NAME} certificate for node ${name} will expire in less than ${presetData.certificateExpirationWarningInDays} days on ${willExpireReport.expirationDate}. Renewing...`, + ); + await this.createCertificate(true, presetData, certFolder, name, providedCertificates, metadataFile, randomSerial); + return true; + } else { + this.logger.warn( + `The ${CertificateService.NODE_CERTIFICATE_FILE_NAME} certificate for node ${name} will expire in less than ${presetData.certificateExpirationWarningInDays} days on ${willExpireReport.expirationDate}. You need to renew it.`, + ); + return false; + } + } else { + this.logger.info( + `The ${CertificateService.NODE_CERTIFICATE_FILE_NAME} certificate for node ${name} will expire on ${willExpireReport.expirationDate}. No need to renew it yet.`, + ); + return false; + } + } else { + await this.createCertificate(false, presetData, certFolder, name, providedCertificates, metadataFile, randomSerial); + return true; } - BootstrapUtils.deleteFolder(this.logger, certFolder); - await BootstrapUtils.mkdir(certFolder); - const newCertsFolder = join(certFolder, 'new_certs'); - await BootstrapUtils.mkdir(newCertsFolder); - const generatedContext = { name }; - await BootstrapUtils.generateConfiguration(generatedContext, copyFrom, certFolder, []); + } + + private async createCertificate( + renew: boolean, + presetData: CertificateConfigPreset, + certFolder: string, + name: string, + providedCertificates: NodeCertificates, + metadataFile: string, + randomSerial?: string, + ) { + const copyFrom = join(BootstrapUtils.ROOT_FOLDER, 'config', 'cert'); + const networkType = presetData.networkType; const mainAccountPrivateKey = await CommandUtils.resolvePrivateKey( this.logger, @@ -123,6 +131,15 @@ export class CertificateService { name, 'generating the server Node certificates', ); + + if (!renew) { + BootstrapUtils.deleteFolder(this.logger, certFolder); + } + + await BootstrapUtils.mkdir(certFolder); + const generatedContext = { name }; + await BootstrapUtils.generateConfiguration(generatedContext, copyFrom, certFolder, []); + BootstrapUtils.createDerFile(mainAccountPrivateKey, join(certFolder, 'ca.der')); BootstrapUtils.createDerFile(transportPrivateKey, join(certFolder, 'node.der')); await BootstrapUtils.writeTextFile( @@ -130,19 +147,19 @@ export class CertificateService { (randomSerial?.trim() || Convert.uint8ToHex(Crypto.randomBytes(19))).toLowerCase() + '\n', ); - // TODO. Migrate this process to forge, sshpk or any node native implementation. - const command = this.createCertCommands('/data'); + const command = this.createCertCommands( + renew, + presetData.caCertificateExpirationInDays, + presetData.nodeCertificateExpirationInDays, + ); await BootstrapUtils.writeTextFile(join(certFolder, 'createNodeCertificates.sh'), command); - const cmd = ['bash', 'createNodeCertificates.sh']; - const binds = [`${resolve(certFolder)}:/data:rw`]; - const userId = await this.runtimeService.resolveDockerUserFromParam(this.params.user); - const { stdout, stderr } = await this.runtimeService.runImageUsingExec({ - image: symbolServerImage, - userId: userId, - workdir: '/data', - cmds: cmd, - binds: binds, - }); + + const { stdout, stderr } = await this.runOpenSslCommand( + presetData.symbolServerImage, + 'bash createNodeCertificates.sh', + certFolder, + false, + ); if (stdout.indexOf('Certificate Created') < 0) { this.logger.info(Utils.secureString(stdout)); this.logger.error(Utils.secureString(stderr)); @@ -153,7 +170,7 @@ export class CertificateService { if (certificates.length != 2) { throw new Error('Certificate creation failed. 2 certificates should have been created but got: ' + certificates.length); } - this.logger.info(`Certificate for node ${name} created`); + this.logger.info(renew ? `Certificate for node ${name} renewed` : `Certificate for node ${name} created`); const caCertificate = certificates[0]; const nodeCertificate = certificates[1]; @@ -187,9 +204,16 @@ export class CertificateService { } } - private createCertCommands(target: string): string { + private createCertCommands(renew: boolean, caCertificateExpirationInDays: number, nodeCertificateExpirationInDays: number): string { + const createCaCertificate = renew + ? `openssl x509 -in ${CertificateService.CA_CERTIFICATE_FILE_NAME} -text -noout` + : `# create CA cert and self-sign it + openssl req -config ca.cnf -keyform PEM -key ca.key.pem -new -x509 -days ${caCertificateExpirationInDays} -out ${CertificateService.CA_CERTIFICATE_FILE_NAME} + openssl x509 -in ${CertificateService.CA_CERTIFICATE_FILE_NAME} -text -noout + `; return `set -e -cd ${target} + +mkdir new_certs chmod 700 new_certs touch index.txt.attr touch index.txt @@ -199,9 +223,7 @@ cat ca.der | openssl pkey -inform DER -outform PEM -out ca.key.pem openssl pkey -inform pem -in ca.key.pem -text -noout openssl pkey -in ca.key.pem -pubout -out ca.pubkey.pem -# create CA cert and self-sign it -openssl req -config ca.cnf -keyform PEM -key ca.key.pem -new -x509 -days 7300 -out ca.cert.pem -openssl x509 -in ca.cert.pem -text -noout +${createCaCertificate} # create node key cat node.der | openssl pkey -inform DER -outform PEM -out node.key.pem @@ -215,11 +237,11 @@ openssl req -text -noout -verify -in node.csr.pem # CA side # sign cert for 375 days -openssl ca -batch -config ca.cnf -days 375 -notext -in node.csr.pem -out node.crt.pem -openssl verify -CAfile ca.cert.pem node.crt.pem +openssl ca -batch -config ca.cnf -days ${nodeCertificateExpirationInDays} -notext -in node.csr.pem -out ${CertificateService.NODE_CERTIFICATE_FILE_NAME} +openssl verify -CAfile ${CertificateService.CA_CERTIFICATE_FILE_NAME} ${CertificateService.NODE_CERTIFICATE_FILE_NAME} # finally create full crt -cat node.crt.pem ca.cert.pem > node.full.crt.pem +cat ${CertificateService.NODE_CERTIFICATE_FILE_NAME} ${CertificateService.CA_CERTIFICATE_FILE_NAME} > node.full.crt.pem rm createNodeCertificates.sh rm ca.key.pem @@ -234,4 +256,92 @@ rm -rf new_certs echo "Certificate Created" `; } + + public async willCertificateExpire( + symbolServerImage: string, + certFolder: string, + certificateFileName: string, + certificateExpirationWarningInDays: number, + ): Promise<{ willExpire: boolean; expirationDate: string }> { + const command = `openssl x509 -enddate -noout -in ${certificateFileName} -checkend ${ + certificateExpirationWarningInDays * 24 * 60 * 60 + }`; + const { stdout, stderr } = await this.runOpenSslCommand(symbolServerImage, command, certFolder, true); + const expirationDate = stdout.match('notAfter\\=(.*)\\n')?.[1]; + if (!expirationDate) { + this.logger.info(Utils.secureString(stdout)); + this.logger.error(Utils.secureString(stderr)); + throw new Error( + `Cannot validate ${certificateFileName} certificate expiration. Expiration Date cannot be resolved. Check the logs!`, + ); + } + if (stdout.indexOf('Certificate will expire') > -1) { + return { + willExpire: true, + expirationDate: expirationDate, + }; + } + if (stdout.indexOf('Certificate will not expire') > -1) { + return { + willExpire: false, + expirationDate: expirationDate, + }; + } + this.logger.info(Utils.secureString(stdout)); + this.logger.error(Utils.secureString(stderr)); + throw new Error(`Cannot validate ${certificateFileName} certificate expiration. Check the logs!`); + } + + private async runOpenSslCommand( + symbolServerImage: string, + cmd: string, + certFolder: string, + ignoreErrors: boolean, + ): Promise<{ + stdout: string; + stderr: string; + }> { + const userId = await this.runtimeService.resolveDockerUserFromParam(this.params.user); + const binds = [`${resolve(certFolder)}:/data:rw`]; + const { stdout, stderr } = await this.runtimeService.runImageUsingExec({ + image: symbolServerImage, + userId: userId, + workdir: '/data', + cmds: cmd.split(' '), + binds: binds, + ignoreErrors: ignoreErrors, + }); + return { stdout, stderr }; + } + + public static getCertificates(stdout: string): CertificatePair[] { + const locations = (string: string, substring: string): number[] => { + const indexes = []; + let i = -1; + while ((i = string.indexOf(substring, i + 1)) >= 0) indexes.push(i); + return indexes; + }; + + const extractKey = (subtext: string): string => { + const key = subtext + .trim() + .split(':') + .map((m) => m.trim()) + .join(''); + if (!key || key.length !== 64) { + throw Error(`SSL Certificate key cannot be loaded from the openssl script. Output: \n${subtext}`); + } + return key.toUpperCase(); + }; + + const from = 'priv:'; + const middle = 'pub:'; + const to = 'Certificate'; + + return locations(stdout, from).map((index) => { + const privateKey = extractKey(stdout.substring(index + from.length, stdout.indexOf(middle, index))); + const publicKey = extractKey(stdout.substring(stdout.indexOf(middle, index) + middle.length, stdout.indexOf(to, index))); + return { privateKey: privateKey, publicKey: publicKey }; + }); + } } diff --git a/src/service/ConfigService.ts b/src/service/ConfigService.ts index ed4101b4c..f52d33b74 100644 --- a/src/service/ConfigService.ts +++ b/src/service/ConfigService.ts @@ -319,15 +319,11 @@ export class ConfigService { private async generateNodeCertificates(presetData: ConfigPreset, addresses: Addresses): Promise { await Promise.all( (addresses.nodes || []).map((account) => { - return new CertificateService(this.logger, this.params).run( - presetData.networkType, - presetData.symbolServerImage, - account.name, - { - main: account.main, - transport: account.transport, - }, - ); + const providedCertificates = { + main: account.main, + transport: account.transport, + }; + return new CertificateService(this.logger, this.params).run(presetData, account.name, providedCertificates, false); }), ); } diff --git a/src/service/RunService.ts b/src/service/RunService.ts index 4fd6b2fad..b8637650c 100644 --- a/src/service/RunService.ts +++ b/src/service/RunService.ts @@ -22,6 +22,7 @@ import { RepositoryFactoryHttp } from 'symbol-sdk'; import { Logger } from '../logger'; import { DockerCompose, DockerComposeService } from '../model'; import { BootstrapUtils } from './BootstrapUtils'; +import { CertificateService } from './CertificateService'; import { ConfigLoader } from './ConfigLoader'; import { OSUtils } from './OSUtils'; import { PortService } from './PortService'; @@ -89,9 +90,11 @@ export class RunService { this.logger.info(`Docker compose ${dockerFile} does not exist. Cannot check the status of the service.`); return; } + if (!(await this.checkCertificates())) { + throw new Error(`Certificates are about to expire. Check the logs!`); + } const dockerCompose: DockerCompose = BootstrapUtils.fromYaml(await BootstrapUtils.readTextFile(dockerFile)); const services = Object.values(dockerCompose.services); - const timeout = this.params.timeout || RunService.defaultParams.timeout || 0; const started = await BootstrapUtils.poll(this.logger, () => this.runOneCheck(services), timeout, pollIntervalMs); if (!started) { @@ -101,6 +104,32 @@ export class RunService { } } + private async checkCertificates(): Promise { + const presetData = this.configLoader.loadExistingPresetData(this.params.target, false); + const service = new CertificateService(this.logger, { target: this.params.target, user: BootstrapUtils.CURRENT_USER }); + const allServicesChecks: Promise[] = (presetData.nodes || []).map(async (nodePreset) => { + const name = nodePreset.name; + const certFolder = BootstrapUtils.getTargetNodesFolder(this.params.target, false, name, 'cert'); + const willExpireReport = await service.willCertificateExpire( + presetData.symbolServerImage, + certFolder, + CertificateService.NODE_CERTIFICATE_FILE_NAME, + presetData.certificateExpirationWarningInDays, + ); + if (willExpireReport.willExpire) { + this.logger.warn( + `The ${CertificateService.NODE_CERTIFICATE_FILE_NAME} certificate for node ${name} will expire in less than ${presetData.certificateExpirationWarningInDays} days on ${willExpireReport.expirationDate}. You need to renew it.`, + ); + } else { + this.logger.info( + `The ${CertificateService.NODE_CERTIFICATE_FILE_NAME} certificate for node ${name} will expire on ${willExpireReport.expirationDate}. No need to renew it yet.`, + ); + } + return !willExpireReport.willExpire; + }); + return (await Promise.all(allServicesChecks)).every((t) => t); + } + private async runOneCheck(services: DockerComposeService[]): Promise { const runningContainers = (await this.runtimeService.exec('docker ps --format {{.Names}}')).stdout.split(`\n`); const allServicesChecks: Promise[] = services.map(async (service) => { diff --git a/src/service/RuntimeService.ts b/src/service/RuntimeService.ts index 9b21dbd5e..82aa73886 100644 --- a/src/service/RuntimeService.ts +++ b/src/service/RuntimeService.ts @@ -36,6 +36,7 @@ export interface RunImageUsingExecParams { workdir?: string; cmds: string[]; binds: string[]; + ignoreErrors?: boolean; } /** @@ -47,10 +48,12 @@ export class RuntimeService { public static readonly CURRENT_USER = 'current'; constructor(private readonly logger: Logger) {} - public async exec(runCommand: string): Promise<{ stdout: string; stderr: string }> { + public exec(runCommand: string, ignoreErrors?: boolean): Promise<{ stdout: string; stderr: string }> { this.logger.debug(`Exec command: ${runCommand}`); - const { stdout, stderr } = await exec(runCommand); - return { stdout, stderr }; + return exec(runCommand).catch((error) => { + if (ignoreErrors) return { stdout: error.stdout, stderr: error.stderr }; + throw error; + }); } public runImageUsingExec({ @@ -60,6 +63,7 @@ export class RuntimeService { workdir, cmds, binds, + ignoreErrors, }: RunImageUsingExecParams): Promise<{ stdout: string; stderr: string }> { const volumes = binds.map((b) => `-v ${b}`).join(' '); const userParam = userId ? `-u ${userId}` : ''; @@ -68,7 +72,7 @@ export class RuntimeService { const commandLine = cmds.map((a) => `"${a}"`).join(' '); const runCommand = `docker run --rm ${userParam} ${workdirParam} ${environmentParam} ${volumes} ${image} ${commandLine}`; this.logger.info(Utils.secureString(`Running image using Exec: ${image} ${cmds.join(' ')}`)); - return this.exec(runCommand); + return this.exec(runCommand, ignoreErrors); } public async spawn({ command, args, useLogger, logPrefix = '', shell }: SpawnParams): Promise { diff --git a/test/service/CertificateService.test.ts b/test/service/CertificateService.test.ts index 6869ffe49..a35c7dd26 100644 --- a/test/service/CertificateService.test.ts +++ b/test/service/CertificateService.test.ts @@ -14,16 +14,45 @@ * limitations under the License. */ +import { expect } from '@oclif/test'; import { deepStrictEqual } from 'assert'; -import { expect } from 'chai'; import { promises as fsPromises, readFileSync } from 'fs'; import 'mocha'; import { join } from 'path'; import { Account, NetworkType } from 'symbol-sdk'; -import { LoggerFactory, LogType, RuntimeService } from '../../src'; -import { BootstrapUtils, CertificateMetadata, CertificateService, ConfigLoader, NodeCertificates, Preset } from '../../src/service'; +import { LoggerFactory, LogType } from '../../src/logger'; +import { + BootstrapUtils, + CertificateMetadata, + CertificateService, + ConfigLoader, + NodeCertificates, + Preset, + RuntimeService, +} from '../../src/service'; + const logger = LoggerFactory.getLogger(LogType.Silent); +const runtimeService = new RuntimeService(logger); describe('CertificateService', () => { + const target = 'target/tests/CertificateService.test'; + const presetData = new ConfigLoader(logger).createPresetData({ + workingDir: BootstrapUtils.defaultWorkingDir, + preset: Preset.testnet, + assembly: 'dual', + password: 'abc', + }); + const networkType = NetworkType.TEST_NET; + const keys: NodeCertificates = { + main: ConfigLoader.toConfig( + Account.createFromPrivateKey('E095162875BB1D98CA5E0941670E01C1B0DBDF86DF7B3BEDA4A93635F8E51A03', networkType), + ), + transport: ConfigLoader.toConfig( + Account.createFromPrivateKey('415F253ABF0FB2DFD39D7F409EFA2E88769873CAEB45617313B98657A1476A15', networkType), + ), + }; + const randomSerial = '4C87E5C49034B711E2DA38D116366829DA144B\n'.toLowerCase(); + const name = 'test-node'; + it('getCertificates from output', async () => { const outputFile = `./test/certificates/output.txt`; const output = BootstrapUtils.loadFileAsText(outputFile); @@ -40,27 +69,25 @@ describe('CertificateService', () => { ]); }); + async function verifyCertFolder() { + const files = await fsPromises.readdir(target); + expect(files).deep.eq(['ca.cert.pem', 'ca.pubkey.pem', 'metadata.yml', 'node.crt.pem', 'node.full.crt.pem', 'node.key.pem']); + + const diffFiles = ['ca.cert.pem', 'node.crt.pem', 'node.full.crt.pem']; + + // Filtering out files that aren't the same + files + .filter((f) => diffFiles.indexOf(f) === -1) + .forEach((f) => { + expect(readFileSync(join(target, f)), `Different fields: ${f}`).deep.eq(readFileSync(join('test', 'nodeCertificates', f))); + }); + } + it('createCertificates', async () => { - const target = 'target/tests/CertificateService.test'; - await BootstrapUtils.deleteFolder(logger, target); - const service = new CertificateService(logger, { target: target, user: await new RuntimeService(logger).getDockerUserGroup() }); - const presetData = new ConfigLoader(logger).createPresetData({ - workingDir: BootstrapUtils.defaultWorkingDir, - preset: Preset.bootstrap, - password: 'abc', - }); - const networkType = NetworkType.TEST_NET; - const keys: NodeCertificates = { - main: ConfigLoader.toConfig( - Account.createFromPrivateKey('E095162875BB1D98CA5E0941670E01C1B0DBDF86DF7B3BEDA4A93635F8E51A03', networkType), - ), - transport: ConfigLoader.toConfig( - Account.createFromPrivateKey('415F253ABF0FB2DFD39D7F409EFA2E88769873CAEB45617313B98657A1476A15', networkType), - ), - }; + BootstrapUtils.deleteFolder(logger, target); - const randomSerial = '4C87E5C49034B711E2DA38D116366829DA144B\n'.toLowerCase(); - await service.run(networkType, presetData.symbolServerImage, 'test-node', keys, target, randomSerial); + const service = new CertificateService(logger, { target: target, user: await runtimeService.getDockerUserGroup() }); + await service.run(presetData, name, keys, false, target, randomSerial); const expectedMetadata: CertificateMetadata = { version: 1, @@ -68,17 +95,65 @@ describe('CertificateService', () => { mainPublicKey: keys.main.publicKey, }; expect(expectedMetadata).deep.eq(BootstrapUtils.loadYaml(join(target, 'metadata.yml'), false)); + await verifyCertFolder(); + }); - const files = await fsPromises.readdir(target); - expect(files).deep.eq(['ca.cert.pem', 'ca.pubkey.pem', 'metadata.yml', 'node.crt.pem', 'node.full.crt.pem', 'node.key.pem']); + it('createCertificates expiration warnings', async () => { + BootstrapUtils.deleteFolder(logger, target); + const nodeCertificateExpirationInDays = presetData.nodeCertificateExpirationInDays; + const caCertificateExpirationInDays = presetData.caCertificateExpirationInDays; - const diffFiles = ['ca.cert.pem', 'node.crt.pem', 'node.full.crt.pem']; + const service = new CertificateService(logger, { target: target, user: await runtimeService.getDockerUserGroup() }); + await service.run(presetData, name, keys, false, target, randomSerial); - // Filtering out files that aren't the same - files - .filter((f) => diffFiles.indexOf(f) === -1) - .forEach((f) => { - expect(readFileSync(join(target, f)), `Different fields: ${f}`).deep.eq(readFileSync(join('test', 'nodeCertificates', f))); - }); + async function willExpire(certificateFileName: string, certificateExpirationWarningInDays: number): Promise { + const report = await service.willCertificateExpire( + presetData.symbolServerImage, + target, + certificateFileName, + certificateExpirationWarningInDays, + ); + expect(report.expirationDate.endsWith(' GMT')).eq(true); + return report.willExpire; + } + + expect(await willExpire(CertificateService.NODE_CERTIFICATE_FILE_NAME, nodeCertificateExpirationInDays - 1)).eq(false); + expect(await willExpire(CertificateService.NODE_CERTIFICATE_FILE_NAME, nodeCertificateExpirationInDays + 1)).eq(true); + expect(await willExpire(CertificateService.CA_CERTIFICATE_FILE_NAME, caCertificateExpirationInDays - 1)).eq(false); + expect(await willExpire(CertificateService.CA_CERTIFICATE_FILE_NAME, caCertificateExpirationInDays + 1)).eq(true); + }); + + it('create and renew certificates', async () => { + const target = 'target/tests/CertificateService.test'; + BootstrapUtils.deleteFolder(logger, target); + const service = new CertificateService(logger, { target: target, user: await runtimeService.getDockerUserGroup() }); + + async function getCertFile(certificateFileName: string): Promise { + return readFileSync(join(target, certificateFileName), 'utf-8'); + } + + // First time generation + expect(await service.run({ ...presetData, nodeCertificateExpirationInDays: 10 }, name, keys, true, target)).eq(true); + await verifyCertFolder(); + const originalCaFile = await getCertFile(CertificateService.CA_CERTIFICATE_FILE_NAME); + const originalNodeFile = await getCertFile(CertificateService.NODE_CERTIFICATE_FILE_NAME); + + //Renew is not required + expect(await service.run({ ...presetData, certificateExpirationWarningInDays: 9 }, name, keys, true, target)).eq(false); + await verifyCertFolder(); + expect(await getCertFile(CertificateService.CA_CERTIFICATE_FILE_NAME)).eq(originalCaFile); + expect(await getCertFile(CertificateService.NODE_CERTIFICATE_FILE_NAME)).eq(originalNodeFile); + + //Renew is required but not auto-upgrade + expect(await service.run({ ...presetData, certificateExpirationWarningInDays: 11 }, name, keys, false, target)).eq(false); + await verifyCertFolder(); + expect(await getCertFile(CertificateService.CA_CERTIFICATE_FILE_NAME)).eq(originalCaFile); + expect(await getCertFile(CertificateService.NODE_CERTIFICATE_FILE_NAME)).eq(originalNodeFile); + + //Renew is required and auto-upgrade + expect(await service.run({ ...presetData, certificateExpirationWarningInDays: 11 }, name, keys, true, target)).eq(true); + await verifyCertFolder(); + expect(await getCertFile(CertificateService.CA_CERTIFICATE_FILE_NAME)).eq(originalCaFile); + expect(await getCertFile(CertificateService.NODE_CERTIFICATE_FILE_NAME)).not.eq(originalNodeFile); // Renewed }); }); diff --git a/test/service/RuntimeService.test.ts b/test/service/RuntimeService.test.ts index df86dcead..0121ea06f 100644 --- a/test/service/RuntimeService.test.ts +++ b/test/service/RuntimeService.test.ts @@ -25,6 +25,12 @@ describe('RuntimeService', async () => { } }); + it('exec when invalid ignore error', async () => { + const result = await service.exec('wrong!', true); + expect(result.stderr.indexOf('wrong!')).not.eq(-1); + expect(result.stdout).eq(''); + }); + it('spawn when valid', async () => { const response = await service.spawn({ command: 'echo', args: ['ABC'], useLogger: true, logPrefix: '', shell: true }); expect(response).eq('ABC\n');