"use strict"; const AWS = require('aws-sdk'); const ACME = require('acme'); const Keypairs = require('@root/keypairs'); const CSR = require('@root/csr'); const PEM = require('@root/pem'); const maintainerEmail = "pete-acme-lambda-renewal@cooperjr.name"; //const directoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory'; const directoryUrl = 'https://acme-v02.api.letsencrypt.org/directory'; const skipDryRun = true; const ssmParameterPrefix = "/acme-lambda-renewal/"; const pkg = require('./package.json'); const packageAgent = pkg.name + '/' + pkg.version; const route53 = new AWS.Route53(); const s3 = new AWS.S3(); const sns = new AWS.SNS(); const ssm = new AWS.SSM(); function notify(ev, msg) { console.log("acme notify", ev, msg.altname || '', msg.status || '', msg.message || ''); } const route53challenges = function ({zones}) { return { 'dns-01': { init: async function(deps) { }, zones: async function(args) { return Object.keys(zones); }, set: async function(args) { //console.log("Set: ", args); const {dnsHost, dnsAuthorization, dnsZone} = args.challenge; const request = route53.changeResourceRecordSets({ ChangeBatch: { Changes: [ { Action: "CREATE", ResourceRecordSet: { Name: dnsHost, ResourceRecords: [ { Value: '"' + dnsAuthorization + '"' } ], TTL: 60, Type: "TXT" } } ] }, HostedZoneId: zones[dnsZone] }); console.log("Sending create request for ", dnsHost); const response = await request.promise(); const changeId = response.ChangeInfo.Id; console.log(`Create request sent for ${dnsHost} (Change id ${changeId}); waiting for it to complete`); const waitRequest = route53.waitFor('resourceRecordSetsChanged', {Id: changeId}); const waitResponse = await waitRequest.promise(); console.log(`Create request complete for ${dnsHost}: (Change id ${waitResponse.ChangeInfo.Id}) ${waitResponse.ChangeInfo.Status}`); }, get: async function(args) { //console.log("Get: ", args); const {dnsHost, dnsZone} = args.challenge; const request = route53.listResourceRecordSets({ HostedZoneId: zones[dnsZone], MaxItems: "1", StartRecordName: dnsHost, StartRecordType: "TXT" }); const response = await request.promise(); if (response && response.ResourceRecordSets && response.ResourceRecordSets.length === 1 && response.ResourceRecordSets[0].Name === dnsHost && response.ResourceRecordSets[0].Type === "TXT" && response.ResourceRecordSets[0].ResourceRecords && response.ResourceRecordSets[0].ResourceRecords.length > 0) { return ({ dnsAuthorization: response.ResourceRecordSets[0].ResourceRecords[0].Value }); } else { return ({}); } }, remove: async function(args) { //console.log("Remove: ", args); const {dnsHost, dnsAuthorization, dnsZone} = args.challenge; const request = route53.changeResourceRecordSets({ ChangeBatch: { Changes: [ { Action: "DELETE", ResourceRecordSet: { Name: dnsHost, ResourceRecords: [ { Value: '"' + dnsAuthorization + '"' } ], TTL: 60, Type: "TXT" } } ] }, HostedZoneId: zones[dnsZone] }); console.log("Sending delete request for ", dnsHost); const response = await request.promise(); const changeId = response.ChangeInfo.Id; console.log(`Delete request sent for ${dnsHost} (Change id ${changeId}); not waiting for it to complete`); }, // how long to wait after *all* TXTs are set // before presenting them for validation // (for most this is seconds, for some it may be minutes) propagationDelay: 10000 } } } async function getParameters() { const parameterResponse = await ssm.getParameters({ Names: [ ssmParameterPrefix + "accountId", ssmParameterPrefix + "accountKey", ssmParameterPrefix + "certificates" ], WithDecryption: true }).promise(); const parameters = parameterResponse.Parameters.reduce((result, item, index) => { result[item.Name.slice(ssmParameterPrefix.length)] = JSON.parse(item.Value); return result; }, {}); return parameters; } exports.handler = async function(event, context) { const {accountId, accountKey, certificates} = await getParameters(); const account = { key: { kid: accountId } } const acme = ACME.create({ maintainerEmail, packageAgent, notify }); await acme.init(directoryUrl); const certificateRuns = certificates.map(async certificate => { const {domains, zones, keyType, certStorageBucketName, certStoragePrefix, successSnsTopicArn, failureSnsTopicArn} = certificate; try { // Make new private key const certKeypair = await Keypairs.generate({ kty: keyType, format: 'jwk' }); const certKey = certKeypair.private; // Make CSR const encoding = 'der'; const typ = 'CERTIFICATE REQUEST'; const csrDer = await CSR.csr({ jwk: certKey, domains, encoding }); const csr = PEM.packBlock({ type: typ, bytes: csrDer }); // Request certificates console.log("Starting requests"); const pems = await acme.certificates.create({ account, accountKey, csr, domains, challenges: route53challenges({zones}), skipDryRun }); // Write private key & certificate to S3 const certKeyPem = await Keypairs.export({ jwk: certKey }); const certKeyWritingPromise = s3.putObject({ Body: certKeyPem, Bucket: certStorageBucketName, Key: certStoragePrefix + "privkey.pem", ServerSideEncryption: "AES256" }).promise(); const fullchain = pems.cert + '\n' + pems.chain + '\n'; const certChainWritingPromise = s3.putObject({ Body: fullchain, Bucket: certStorageBucketName, Key: certStoragePrefix + "fullchain.pem" }).promise(); await Promise.all([certKeyWritingPromise, certChainWritingPromise]); console.log("Completed with certificate for ", domains); if (successSnsTopicArn) { await sns.publish({ TopicArn: successSnsTopicArn, Message: `Certificate for ${JSON.stringify(domains)} ready`, Subject: "Certificate ready" }).promise(); } } catch (err) { console.log("Error ", err); if (failureSnsTopicArn) { await sns.publish({ TopicArn: failureSnsTopicArn, Message: `Certificate for ${JSON.stringify(domains)} failed\n${err}\nLog Group ${context.logGroupName}\nLog Stream ${context.logStreamName}`, Subject: "Certificate Acquisition Failure" }).promise(); } throw err; } }); await Promise.all(certificateRuns); }