From 6298d7b8e81289a56947c077c35a921e1747a84e Mon Sep 17 00:00:00 2001 From: Andre Kurait Date: Tue, 11 Jun 2024 10:16:48 -0500 Subject: [PATCH] Inline acm cert creation into cdk Signed-off-by: Andre Kurait --- .../create-acm-cert.ts | 135 -------------- .../lib/lambda/acm-cert-importer-handler.ts | 176 ++++++++++++++++++ .../lib/lambda/index.ts | 3 +- .../lib/lambda/package-lock.json | 6 + .../lib/network-stack.ts | 25 ++- .../lib/service-stacks/acm-cert-importer.ts | 58 ++++++ .../opensearch-service-migration/options.md | 2 +- .../opensearch-service-migration/package.json | 2 +- 8 files changed, 268 insertions(+), 139 deletions(-) delete mode 100644 deployment/cdk/opensearch-service-migration/create-acm-cert.ts create mode 100644 deployment/cdk/opensearch-service-migration/lib/lambda/acm-cert-importer-handler.ts create mode 100644 deployment/cdk/opensearch-service-migration/lib/lambda/package-lock.json create mode 100644 deployment/cdk/opensearch-service-migration/lib/service-stacks/acm-cert-importer.ts diff --git a/deployment/cdk/opensearch-service-migration/create-acm-cert.ts b/deployment/cdk/opensearch-service-migration/create-acm-cert.ts deleted file mode 100644 index 6093e13bec..0000000000 --- a/deployment/cdk/opensearch-service-migration/create-acm-cert.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Construct } from 'constructs'; -import { App, Environment, Stack, StackProps } from 'aws-cdk-lib'; -import * as fs from 'fs'; -import * as path from 'path'; -import { ACMClient, ImportCertificateCommand, AddTagsToCertificateCommand } from '@aws-sdk/client-acm'; -import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts'; -import * as forge from 'node-forge'; - - -interface ACMImportCertificateStackProps extends StackProps { - env: Environment; -} - -class ACMImportCertificateStack extends Stack { - - constructor(scope: Construct, id: string, props: ACMImportCertificateStackProps) { - super(scope, id, props); - - const certDir = path.join(__dirname, 'certs'); - if (!fs.existsSync(certDir)) { - fs.mkdirSync(certDir); - } - - const certPath = path.join(certDir, 'certificate.pem'); - const keyPath = path.join(certDir, 'privateKey.pem'); - const chainPath = path.join(certDir, 'certificateChain.pem'); - - this.generateSelfSignedCertificate(certPath, keyPath, chainPath); - - this.importCertificate(certPath, keyPath, chainPath).then(certificateArn => { - console.log(`Certificate imported with ARN: ${certificateArn}`); - this.addTagsToCertificate(certificateArn); - }).catch(error => { - console.error('Error importing certificate:', error); - }); - } - -private generateSelfSignedCertificate(certPath: string, keyPath: string, chainPath: string): void { - const keys = forge.pki.rsa.generateKeyPair(2048); - const cert = forge.pki.createCertificate(); - - cert.publicKey = keys.publicKey; - cert.serialNumber = '01'; - cert.validity.notBefore = new Date(Date.UTC(1970, 0, 1, 0, 0, 0)); - cert.validity.notAfter = new Date(Date.UTC(9999, 11, 31, 23, 59, 59)); - const attrs = [{ - name: 'commonName', - value: 'localhost' - }]; - - cert.setSubject(attrs); - cert.setIssuer(attrs); - - cert.setExtensions([{ - name: 'basicConstraints', - cA: true - }, { - name: 'subjectAltName', - altNames: [{ - type: 2, // DNS - value: 'localhost' - }] - }, { - name: 'keyUsage', - keyCertSign: true, - digitalSignature: true, - keyEncipherment: true - }, { - name: 'extKeyUsage', - serverAuth: true, - clientAuth: true - },]); - cert.sign(keys.privateKey, forge.md.sha384.create()); // Using SHA-384 for signing - - const pemCert = forge.pki.certificateToPem(cert); - const pemKey = forge.pki.privateKeyToPem(keys.privateKey); - - fs.writeFileSync(certPath, pemCert); - fs.writeFileSync(keyPath, pemKey); - fs.writeFileSync(chainPath, pemCert); // For simplicity, we use the same certificate as the chain -} - private async importCertificate(certPath: string, keyPath: string, chainPath: string): Promise { - const client = new ACMClient({ region: this.region }); - - const certificate = fs.readFileSync(certPath, 'utf8'); - const privateKey = fs.readFileSync(keyPath, 'utf8'); - const certificateChain = fs.readFileSync(chainPath, 'utf8'); - - const command = new ImportCertificateCommand({ - Certificate: Buffer.from(certificate), - PrivateKey: Buffer.from(privateKey), - CertificateChain: Buffer.from(certificateChain), - }); - - const response = await client.send(command); - return response.CertificateArn!; - } - - private async addTagsToCertificate(certificateArn: string): Promise { - const client = new ACMClient({ region: this.region }); - const currentDate = new Date().toISOString().split('T')[0]; - - const command = new AddTagsToCertificateCommand({ - CertificateArn: certificateArn, - Tags: [ - { Key: 'Name', Value: 'Migration Assistant Certificate' }, - ] - }); - - await client.send(command); - } -} - -async function main() { - const region = process.argv[2]; - if (!region) { - throw new Error('A valid AWS region must be specified.'); - } - - const stsClient = new STSClient({ region }); - const identity = await stsClient.send(new GetCallerIdentityCommand({})); - const account = identity.Account; - - const app = new App(); - new ACMImportCertificateStack(app, 'ACMImportCertificateStack', { - env: { account, region } - }); - app.synth(); -} - -main().catch(error => { - console.error(error); - process.exit(1); -}); - diff --git a/deployment/cdk/opensearch-service-migration/lib/lambda/acm-cert-importer-handler.ts b/deployment/cdk/opensearch-service-migration/lib/lambda/acm-cert-importer-handler.ts new file mode 100644 index 0000000000..4763f960da --- /dev/null +++ b/deployment/cdk/opensearch-service-migration/lib/lambda/acm-cert-importer-handler.ts @@ -0,0 +1,176 @@ +import { + Context, + CloudFormationCustomResourceEvent, + CloudFormationCustomResourceResponse, +} from 'aws-lambda'; +import { ACMClient, ImportCertificateCommand, DeleteCertificateCommand } from '@aws-sdk/client-acm'; +import * as https from 'https'; +import * as forge from 'node-forge'; + +export const handler = async (event: CloudFormationCustomResourceEvent, context: Context): Promise => { + console.log('Received event:', JSON.stringify(event, null, 2)); + console.log('Received context:', JSON.stringify(context, null, 2)); + let responseData: { [key: string]: any } = {}; + let physicalResourceId: string = ''; + + try { + switch (event.RequestType) { + case 'Create': + const { certificate, privateKey, certificateChain } = await generateSelfSignedCertificate(); + const certificateArn = await importCertificate(certificate, privateKey, certificateChain); + console.log(`Certificate imported with ARN: ${certificateArn}`); + responseData = { CertificateArn: certificateArn }; + physicalResourceId = certificateArn; + break; + case 'Update': + // No update logic needed, return existing physical resource id + physicalResourceId = event.PhysicalResourceId; + break; + case 'Delete': + const arn = event.PhysicalResourceId; + await deleteCertificate(arn); + responseData = { CertificateArn: arn }; + physicalResourceId = arn; + break; + } + + return await sendResponse(event, context, 'SUCCESS', responseData, physicalResourceId); + } catch (error) { + console.error(error); + return await sendResponse(event, context, 'FAILED', { Error: (error as Error).message }, physicalResourceId); + } +}; + +async function generateSelfSignedCertificate(): Promise<{ certificate: string, privateKey: string, certificateChain: string }> { + return new Promise((resolve, reject) => { + const keys = forge.pki.rsa.generateKeyPair(2048); + const cert = forge.pki.createCertificate(); + + cert.publicKey = keys.publicKey; + cert.serialNumber = '01'; + cert.validity.notBefore = new Date(Date.UTC(1970, 0, 1, 0, 0, 0)); + cert.validity.notAfter = new Date(Date.UTC(9999, 11, 31, 23, 59, 59, 999)); + const attrs = [{ + name: 'commonName', + value: 'localhost' + }]; + + cert.setSubject(attrs); + cert.setIssuer(attrs); + + cert.setExtensions([{ + name: 'basicConstraints', + cA: true + }, { + name: 'subjectAltName', + altNames: [{ + type: 2, // DNS + value: 'localhost' + }] + }, { + name: 'keyUsage', + keyCertSign: true, + digitalSignature: true, + keyEncipherment: true + }, { + name: 'extKeyUsage', + serverAuth: true, + clientAuth: true + },]); + cert.sign(keys.privateKey, forge.md.sha384.create()); + + const pemCert = forge.pki.certificateToPem(cert); + const pemKey = forge.pki.privateKeyToPem(keys.privateKey); + + resolve({ + certificate: pemCert, + privateKey: pemKey, + certificateChain: pemCert + }); + }); +} + +async function importCertificate(certificate: string, privateKey: string, certificateChain: string): Promise { + const client = new ACMClient({ region: process.env.AWS_REGION }); + + const command = new ImportCertificateCommand({ + Certificate: Buffer.from(certificate), + PrivateKey: Buffer.from(privateKey), + CertificateChain: Buffer.from(certificateChain), + }); + + const response = await client.send(command); + return response.CertificateArn!; +} + +async function deleteCertificate(certificateArn: string): Promise { + const client = new ACMClient({ region: process.env.AWS_REGION }); + const command = new DeleteCertificateCommand({ + CertificateArn: certificateArn, + }); + + await client.send(command); +} + +async function sendResponse(event: CloudFormationCustomResourceEvent, context: Context, responseStatus: string, responseData: { [key: string]: any }, physicalResourceId: string): Promise { + const responseBody = JSON.stringify({ + Status: responseStatus, + Reason: `See the details in CloudWatch Log Stream: ${context.logStreamName}`, + PhysicalResourceId: physicalResourceId, + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + Data: responseData + }); + + console.log('Response body:', responseBody); + + if (event.ResponseURL === "Local") { + console.log('Running locally, simulating response success.'); + return { + Status: 'SUCCESS', + Reason: 'Running locally, response simulated.', + PhysicalResourceId: physicalResourceId, + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + Data: responseData + }; + } + + const parsedUrl = new URL(event.ResponseURL); + const options = { + hostname: parsedUrl.hostname, + port: 443, + path: parsedUrl.pathname + parsedUrl.search, + method: 'PUT', + headers: { + 'Content-Type': '', + 'Content-Length': responseBody.length + } + }; + + return new Promise((resolve, reject) => { + const request = https.request(options, (response) => { + console.log(`Status code: ${response.statusCode}`); + console.log(`Status message: ${response.statusMessage}`); + resolve({ + Status: responseStatus === 'SUCCESS' || responseStatus === 'FAILED' ? responseStatus : 'FAILED', + Reason: `See the details in CloudWatch Log Stream: ${context.logStreamName}`, + PhysicalResourceId: physicalResourceId, + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + Data: responseData + }); + }); + + request.on('error', (error) => { + console.error('sendResponse Error:', error); + reject(error); + }); + + request.write(responseBody); + request.end(); + }); +} \ No newline at end of file diff --git a/deployment/cdk/opensearch-service-migration/lib/lambda/index.ts b/deployment/cdk/opensearch-service-migration/lib/lambda/index.ts index 72d1343f06..4d4be7f6ac 100644 --- a/deployment/cdk/opensearch-service-migration/lib/lambda/index.ts +++ b/deployment/cdk/opensearch-service-migration/lib/lambda/index.ts @@ -1,2 +1,3 @@ export * as msk_ordered_endpoints_handler from './msk-ordered-endpoints-handler' -export * as msk_public_endpoint_handler from './msk-public-endpoint-handler' \ No newline at end of file +export * as msk_public_endpoint_handler from './msk-public-endpoint-handler' +export * as acm_cert_importer_handler from './acm-cert-importer-handler' \ No newline at end of file diff --git a/deployment/cdk/opensearch-service-migration/lib/lambda/package-lock.json b/deployment/cdk/opensearch-service-migration/lib/lambda/package-lock.json new file mode 100644 index 0000000000..374cda07d8 --- /dev/null +++ b/deployment/cdk/opensearch-service-migration/lib/lambda/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "asset-input", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/deployment/cdk/opensearch-service-migration/lib/network-stack.ts b/deployment/cdk/opensearch-service-migration/lib/network-stack.ts index e8e4a2dc67..ee0acb880a 100644 --- a/deployment/cdk/opensearch-service-migration/lib/network-stack.ts +++ b/deployment/cdk/opensearch-service-migration/lib/network-stack.ts @@ -7,8 +7,11 @@ import { import {Construct} from "constructs"; import {StackPropsExt} from "./stack-composer"; import {StringParameter} from "aws-cdk-lib/aws-ssm"; -import { ApplicationLoadBalancer, ApplicationProtocol, ApplicationTargetGroup, IApplicationLoadBalancer, ListenerAction, SslPolicy, TargetType } from "aws-cdk-lib/aws-elasticloadbalancingv2"; +import { ApplicationLoadBalancer, IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2"; import { Certificate, ICertificate } from "aws-cdk-lib/aws-certificatemanager"; +import { ARecord, HostedZone, RecordTarget } from "aws-cdk-lib/aws-route53"; +import { LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets"; +import { AcmCertificateImporter } from "./service-stacks/acm-cert-importer"; export interface NetworkStackProps extends StackPropsExt { readonly vpcId?: string; @@ -122,7 +125,27 @@ export class NetworkStack extends Stack { if (props.albAcmCertArn) { const cert = Certificate.fromCertificateArn(this, 'ALBListenerCert', props.albAcmCertArn); this.albListenerCert = cert; + } else { + const cert = new AcmCertificateImporter(this, 'ALBListenerCertImport').acmCert; + this.albListenerCert = cert; } + // this.exportValue(this.albListenerCert); + + const route53 = new HostedZone(this, 'ALBHostedZone', { + zoneName: `alb.migration.${props.stage}.local`, + vpcs: [this.vpc] + }); + + const albDnsRecord = new ARecord(this, 'albDnsRecord', { + zone: route53, + target: RecordTarget.fromAlias(new LoadBalancerTarget(this.alb)), + }); + + new StringParameter(this, 'SSMParameterAlbUrl', { + description: 'OpenSearch migration parameter for ALB to migration services', + parameterName: `/migration/${props.stage}/${props.defaultDeployId}/albMigrationUrl`, + stringValue: `https://${albDnsRecord.domainName}` + }); } if (!props.addOnMigrationDeployId) { diff --git a/deployment/cdk/opensearch-service-migration/lib/service-stacks/acm-cert-importer.ts b/deployment/cdk/opensearch-service-migration/lib/service-stacks/acm-cert-importer.ts new file mode 100644 index 0000000000..f5974dcdc3 --- /dev/null +++ b/deployment/cdk/opensearch-service-migration/lib/service-stacks/acm-cert-importer.ts @@ -0,0 +1,58 @@ +import { Construct } from 'constructs'; +import { Certificate, ICertificate } from "aws-cdk-lib/aws-certificatemanager"; +import { Provider } from 'aws-cdk-lib/custom-resources'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import { CustomResource, Duration, Stack } from 'aws-cdk-lib/core'; +import * as path from 'path'; +import { Runtime } from 'aws-cdk-lib/aws-lambda'; +import { PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; + +export class AcmCertificateImporter extends Construct { + public readonly acmCert: ICertificate; + + constructor(scope: Construct, id: string) { + super(scope, id); + + // Create a role for the Lambda function with the necessary permissions + const lambdaRole = new Role(this, 'AcmCertificateImporterRole', { + assumedBy: new ServicePrincipal('lambda.amazonaws.com'), + }); + + const partition = Stack.of(this).partition; + lambdaRole.addManagedPolicy({ + managedPolicyArn: `arn:${partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole` + }); + lambdaRole.addManagedPolicy({ + managedPolicyArn: `arn:${partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole` + }); + lambdaRole.addToPolicy(new PolicyStatement({ + actions: ['acm:ImportCertificate', 'acm:DeleteCertificate'], + resources: ['*'], + })); + + const onEvent = new NodejsFunction(this, 'AcmCertificateImporterHandler', { + runtime: Runtime.NODEJS_20_X, + handler: 'handler', + entry: path.join(__dirname, '../lambda/acm-cert-importer-handler.ts'), + environment: { + }, + timeout: Duration.seconds(30), + role: lambdaRole, + logGroup: new LogGroup(this, 'AcmCertificateImporterLogGroup', { + logGroupName: `/aws/lambda/AcmCertificateImporterHandler-${id}`, + retention: RetentionDays.ONE_DAY + }) + }); + + const myProvider = new Provider(this, 'AcmCertificateImporterProvider'+id, { + onEventHandler: onEvent, + }); + + const resource = new CustomResource(this, 'AcmCertificateImporterResource' + id, { + serviceToken: myProvider.serviceToken, + }); + const acmCertArn = resource.ref; + this.acmCert = Certificate.fromCertificateArn(this, "ImportedCert"+id, acmCertArn); + } +} diff --git a/deployment/cdk/opensearch-service-migration/options.md b/deployment/cdk/opensearch-service-migration/options.md index 9b46d2139b..b80c464d20 100644 --- a/deployment/cdk/opensearch-service-migration/options.md +++ b/deployment/cdk/opensearch-service-migration/options.md @@ -29,7 +29,7 @@ These tables list all CDK context configuration values a user can specify for th | trafficReplayerMaxUptime | string | "P1D" | The maximum uptime for the Traffic Replayer service, specified in ISO 8601 duration format. This controls how long the Traffic Replayer will run before automatically shutting down. Example values: "PT1H" (hourly), "P1D" (daily). When this duration is reached, ECS will initiate the startup of a new Traffic Replayer task to ensure continuous operation. This mechanism ensures that the Traffic Replayer service can manage its resources effectively and prevent issues associated with long running processes. Set to the greater of the given value 5 minutes. When not specified, the replayer will run continuously. | | captureProxySourceEndpoint | string | `"https://my-source-cluster.com:443"` | The URI of the source cluster from which requests will be captured for. **Note**: This is only applicable to the standalone `capture-proxy` service | | albEnabled | boolean | false | Enable deploying an ALB in front of all services that expose an ingress API. | -| albAcmCertArn | string | `"arn:aws:acm:us-east-1:12345678912:certificate/abc123de-4888-4fa7-a508-3811e2d49fc3"` | The ACM certificate ARN to use for the ALB. A helper script has been provded to aid in creating and uploading this and can be invoked with `npm run create-acm-cert -- ` and will return the uploaded cert arn. | +| albAcmCertArn | string | `"arn:aws:acm:us-east-1:12345678912:certificate/abc123de-4888-4fa7-a508-3811e2d49fc3"` | The ACM certificate ARN to use for the ALB. If not specified, a custom resource will be deployed to create one. If creation must happen locally, a script has been provded to create and upload a cert and can be invoked with `npm run create-acm-cert` and will return the uploaded cert arn. | | targetClusterProxyServiceEnabled | boolean | false | Enable a non-capturing proxy to use a load balancer against a managed OpenSearch cluster. | ### Fetch Migration Service Options diff --git a/deployment/cdk/opensearch-service-migration/package.json b/deployment/cdk/opensearch-service-migration/package.json index ba50025d59..f953d1b796 100644 --- a/deployment/cdk/opensearch-service-migration/package.json +++ b/deployment/cdk/opensearch-service-migration/package.json @@ -40,7 +40,7 @@ "clean": "rm -rf dist", "release": "npm run build", "cdk": "cdk", - "create-acm-cert": "ts-node create-acm-cert.ts" + "create-acm-cert": "ts-node -e \"require('./lib/lambda/acm-cert-importer-handler').handler({ RequestType: 'Create', ResponseURL: 'Local' }, {})\"" }, "devDependencies": { "@aws-cdk/aws-servicecatalogappregistry-alpha": "2.117.0-alpha.0",