diff --git a/.gitignore b/.gitignore index 6fb99ff..ec3b516 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ typings/ .next *.iml /.idea +AkamaiSample/action.zip diff --git a/AkamaiSample/README.md b/AkamaiSample/README.md new file mode 100644 index 0000000..8ae9a34 --- /dev/null +++ b/AkamaiSample/README.md @@ -0,0 +1,92 @@ +# Certificate Order - DNS Domain Validation sample for Akamai + +## Prerequisites + +1. An instance of [IBM Cloud Certificate Manager](https://cloud.ibm.com/docs/services/certificate-manager) +2. Account in Akamai + +> **Note:** Before you can work with DNS records in Akamai, make sure to request appropriate access permissions from the account owner. + +## Configuration + +### IBM Cloud Function action + +1. Clone the sample code + +```bash +git clone https://github.com/ibm-cloud-security/certificate-manager-domain-validation-cloud-function-sample +``` + +2. Enter the `AkamaiSmaple` directory + +```bash +cd AkamaiSample/ +``` + +3. Install the package + +```bash +npm install package-lock.json +``` + +4. Compress the content + +```bash +zip -r action.zip * +``` + +5. Follow this doc to install the CLI and plug-in, https://cloud.ibm.com/docs/openwhisk?topic=openwhisk-cli_install + +6. Login IBM cloud + +```bash +ibmcloud login --sso +``` + +7. Create a new namespace + +```bash +ibmcloud fn namespace create DNSCertManagerNS +``` + +8. Target to the new namespace + +```bash +ibmcloud fn namespace target DNSCertManagerNS +``` + +9. Create a cloud function action and upload the sample code + +```bash +ibmcloud fn action create AkamaiCertManagerAction action.zip --kind nodejs:12 +``` + +And you can also update the code with this cmd: + +```bash +ibmcloud fn action update AkamaiCertManagerAction action.zip --kind nodejs:12 +``` + +10. [Bind parameters to the action](https://cloud.ibm.com/docs/openwhisk/parameters.html#default-params-action) + + Select **Parameters** from the sidebar, and add the following: + + 1. `allowedCertificateManagerCRNs` - a JSON Object containing a list of Certificate Manager instances that are allowed to invoke this function. + Apply it in order to protect your cloud function from being invoked by unauthorized clients. + E.g. `{"crn:v1:bluemix:public:cloudcerts:us-south:a....":true,"crn:v1:bluemix:public:cloudcerts:eu-de:a...":true}` + + * Find your Certificate Manager instance CRN from the Settings sidebar item + * Or from CLI: `ibmcloud resource service-instance [INSTANCE NAME]`, grab the `ID` value + + 2. `cmRegion` - your Certificate Manager service instance region value. Can be one of: `us-south`, `eu-gb`, `eu-de`, `jp-tok` + E.g. `"us-south"` + + 3. `host` - The Akamai API endpoint hostname. (Get from Akamai client credential) + + 4. `client_token` - The client token for Akamai API calling. (Get from Akamai client credential) + + 5. `client_secret` - The client secret for Akamai API calling. (Get from Akamai client credential) + + 6. `access_token` - The access token for Akamai API calling. (Get from Akamai client credential) + + * Refer to [this guidance](https://developer.akamai.com/api/getting-started#authsetup) to create the Akamai client credential for API calling. diff --git a/AkamaiSample/main.js b/AkamaiSample/main.js new file mode 100644 index 0000000..8a1e7ab --- /dev/null +++ b/AkamaiSample/main.js @@ -0,0 +1,318 @@ +const {promisify} = require('bluebird'); +let request = promisify(require('request')); +// request = request.defaults({json: true}); +const jwtVerify = promisify(require('jsonwebtoken').verify); +const jwtDecode = require('jsonwebtoken').decode; +const EdgeGrid = require('edgegrid'); +let akamaiHost = ""; + +/** + * Get the public key used to verify that the notification payload is generated by your Certificate Manager instance. + * @param body Object + * @param certificateManagerApiUrl + * @returns {Promise} + */ +const getPublicKey = async (body, certificateManagerApiUrl) => { + console.log(`Get public key for instance ${body.instance_crn}`); + const keysOptions = { + method: 'GET', + url: `${certificateManagerApiUrl}/api/v1/instances/${encodeURIComponent(body.instance_crn)}/notifications/publicKey?keyFormat=pem`, + headers: {'cache-control': 'no-cache'} + }; + let response; + try { + response = await request(keysOptions); + } + catch (err) { + console.log(`Couldn't get the public key for instance ${body.instance_crn}. Reason is: ${getErrorString(err)}`); + throw new Error(`Couldn't get the public key for instance ${body.instance_crn}`); + } + if (response.statusCode !== 200) { + console.error(`Couldn't get the public key for instance ${body.instance_crn} . Reason is: status code ${response.statusCode} and body ${JSON.stringify(response.body)}`); + throw new Error(`Couldn't get the public key for instance ${body.instance_crn}`); + } + return response.body.publicKey; +}; + +/** + * Get TXT record to domain + * @param zoneName zone name + * @param payload challenge data + * @param userInfo user credentials + * @returns {Promise} + */ + const getTxtRecord = async (zoneName, payload, userInfo) => { + const recordName = payload.challenge.txt_record_name; + console.log(`Get TXT record "${recordName}" to zone ${zoneName}`); + + var eg = new EdgeGrid( + userInfo.client_token, + userInfo.client_secret, + userInfo.access_token, + `https://${akamaiHost}`); + + eg.auth({ + path: `/config-dns/v2/zones/${zoneName}/names/${recordName}/types/txt`, + method: 'GET', + headers: {'Content-Type': 'application/json'}, + }); + + let response; + let ret; + try { + response = await request(eg.request); + } + catch (err) { + console.error(`Couldn't get record named ${recordName}. Reason is: ${getErrorString(err)}`); + throw new Error(`Couldn't get record named ${recordName}`); + } if (response.statusCode == 200) { + ret = response.body; + } else if (response.statusCode == 404) { + ret = []; + } else { + console.error(`Couldn't get records named ${recordName}. Reason is: status code ${response.statusCode} and body ${JSON.stringify(response.body)}`); + throw new Error(`Couldn't get record named ${recordName}`); + } + console.log(`Get records named ${recordName} returned ${ret}.`); + return ret; +}; + +/** + * Add TXT record to domain + * @param zoneName zone name + * @param payload challenge data + * @param userInfo user credentials + * @returns {Promise} + */ +const addTxtRecord = async (zoneName, payload, userInfo) => { + const recordName = payload.challenge.txt_record_name; + const recordValue = payload.challenge.txt_record_val; + console.log(`Add TXT record "${recordName}:${recordValue}" to zone ${zoneName}`); + + var data = { + "name": recordName, + "type": "txt", + "ttl": 60, + "rdata": [recordValue] + }; + + var eg = new EdgeGrid( + userInfo.client_token, + userInfo.client_secret, + userInfo.access_token, + `https://${akamaiHost}`); + + eg.auth({ + path: `/config-dns/v2/zones/${zoneName}/names/${recordName}/types/txt`, + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: data + }); + + let response; + try { + response = await request(eg.request); + } + catch (err) { + console.log(`Couldn't add TXT record "${recordName}" to zone ${zoneName}. Reason is: ${getErrorString(error)}`); + throw new Error(`Couldn't add TXT record "${recordName}" to zone ${zoneName}`); + } + if (response.statusCode !== 201) { + console.log(`Couldn't add TXT record "${recordName}" to zone ${zoneName}. Reason is: status code ${response.statusCode} and body ${JSON.stringify(response.body)}`); + throw new Error(`Couldn't add TXT record "${recordName}" to zone ${zoneName}`); + } + console.log(`Add TXT record "${recordName}" finished successfully.`); +}; + +/** + * Update TXT record to domain + * @param zoneName zone name + * @param payload challenge data + * @param userInfo user credentials + * @returns {Promise} + */ + const updateTxtRecord = async (zoneName, payload, userInfo) => { + const recordName = payload.challenge.txt_record_name; + const recordValue = payload.challenge.txt_record_val; + console.log(`Update TXT record "${recordName}" to zone ${zoneName}`); + + var data = { + "name": recordName, + "type": "txt", + "ttl": 60, + "rdata": [recordValue] + }; + + var eg = new EdgeGrid( + userInfo.client_token, + userInfo.client_secret, + userInfo.access_token, + `https://${akamaiHost}`); + + eg.auth({ + path: `/config-dns/v2/zones/${zoneName}/names/${recordName}/types/txt`, + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: data + }); + + let response; + try { + // console.log(eg); + response = await request(eg.request); + } + catch (err) { + console.log(`Couldn't update TXT record "${recordName}" to zone ${zoneName}. Reason is: ${getErrorString(error)}`); + throw new Error(`Couldn't update TXT record "${recordName}" to zone ${zoneName}`); + } + if (response.statusCode !== 200) { + console.log(`Couldn't update TXT record "${recordName}" to zone ${zoneName}. Reason is: status code ${response.statusCode} and body ${JSON.stringify(response.body)}`); + throw new Error(`Couldn't update TXT record "${recordName}" to zone ${zoneName}`); + } + console.log(`Update TXT record "${recordName}" finished successfully.`); +}; + +/** + * Delete single TXT record from zone. + * @param zoneName zone name + * @param payload challenge data + * @param userInfo user credentials + * @returns {Promise} + */ +const removeTxtRecord = async (zoneName, payload, userInfo) => { + const recordName = payload.challenge.txt_record_name; + console.log(`Delete TXT record "${recordName}" to zone ${zoneName}`); + + var eg = new EdgeGrid( + userInfo.client_token, + userInfo.client_secret, + userInfo.access_token, + `https://${akamaiHost}`); + + eg.auth({ + path: `/config-dns/v2/zones/${zoneName}/names/${recordName}/types/txt`, + method: 'DELETE', + headers: {'Content-Type': 'application/json'}, + body: {} + }); + + let response; + try { + response = await request(eg.request); + } + catch (err) { + console.log(`Couldn't delete TXT record "${recordName}". Reason is: ${getErrorString(err)}`); + throw new Error(`Couldn't delete TXT record "${recordName}"`); + } + if (response.statusCode !== 204 && response.statusCode !== 404) { + console.log(`Couldn't delete TXT record "${recordName}". Reason is: status code ${response.statusCode} body ${JSON.stringify(response.body)}`); + throw new Error(`Couldn't delete TXT record "${recordName}"`); + } + console.log(`Delete TXT record "${recordName}" finished successfully.`); +}; + +/** + * Set the challenge . + * @param payload notification with challenge + * @param userInfo user credentials + * @returns {Promise} + */ +const setChallenge = async (payload, userInfo) => { + console.log(`Set Akamai challenge: '${payload.domain} : ${JSON.stringify(payload.challenge)}`); + + let domain = payload.domain; + //remove wildcard in case its wildcard certificate. + domain = domain.replace('*.', ''); + record = await getTxtRecord(domain, payload, userInfo); + if (record.length !== 0) { + //await removeTxtRecord(domain, payload, userInfo); + await updateTxtRecord(domain, payload, userInfo); + } else { + await addTxtRecord(domain, payload, userInfo); + } + console.log(`Add challenge for domain ${domain} finished.`); +}; + +/** + * Remove TXT record of challenge + * @param payload + * @param userInfo user credentials + * @returns {Promise} + */ +const removeChallenge = async (payload, userInfo) => { + console.log(`Remove Akamai challenge: '${payload.domain} : ${JSON.stringify(payload.challenge)}`); + + let domain = payload.domain; + //remove wildcard in case its wildcard certificate. + domain = domain.replace('*.', ''); + + await removeTxtRecord(domain, payload, userInfo); + console.log(`Remove challenge for domain ${domain} finished.`); +}; + +/** + * + * main() will be run when you invoke this action + * + * @param params Cloud Functions actions accept a single parameter, which must be a JSON object. + * + * @return The output of this action, which must be a JSON object. + * + */ +const main = async (params)=> { + console.log("Cloud function invoked."); + + try { + + const body = jwtDecode(params.data); + + // Validate that the notification was sent from a Certificate Manager instance that has allowed access + if (!params.allowedCertificateManagerCRNs || !params.allowedCertificateManagerCRNs[body.instance_crn]) { + console.error(`Certificate Manager instance ${body.instance_crn} is not allowed to invoke this action`); + return Promise.reject({ + statusCode: 403, + headers: {'Content-Type': 'application/json'}, + body: {message: 'Unauthorized'}, + }); + } + const certificateManagerApiUrl = `https://${params.cmRegion}.certificate-manager.cloud.ibm.com`; + const publicKey = await getPublicKey(body, certificateManagerApiUrl); + const decodedNotification = await jwtVerify(params.data, publicKey); + + console.log(`Notification message body: ${JSON.stringify(decodedNotification)}`); + switch (decodedNotification.event_type) { + // Handle other certificate manager event types. + // ... + + // Handling domain validation event types. + case "cert_domain_validation_required": + await setChallenge(decodedNotification, userInfo); + break; + case "cert_domain_validation_completed": + await removeChallenge(decodedNotification, userInfo); + break; + } + } + catch (err) { + console.log(`Action failed. Reason: ${getErrorString(err)}`); + return Promise.reject({ + statusCode: err.statusCode ? err.statusCode : 500, + headers: {'Content-Type': 'application/json'}, + body: {message: err.message ? err.message : 'Error processing your request'}, + }); + } + return { + statusCode: 200, + headers: {'Content-Type': 'application/json'}, + body: {} + }; +}; + +const getErrorString = (error) => { + if (error) + return (typeof error.message === 'string') ? error.message : JSON.stringify(error); + else + return 'Error undefined'; +}; + +exports.main = main; \ No newline at end of file diff --git a/AkamaiSample/package.json b/AkamaiSample/package.json new file mode 100644 index 0000000..4c266cc --- /dev/null +++ b/AkamaiSample/package.json @@ -0,0 +1,10 @@ +{ + "name": "akamai-cert-manager-dns-domain-validation", + "main": "main.js", + "dependencies": { + "bluebird": "^3.7.2", + "edgegrid": "^3.0.8", + "jsonwebtoken": "^8.5.1", + "package-lock.json": "^1.0.0" + } +}