├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── commands ├── api.js ├── purge.js ├── tls.js ├── utils.js └── verify.js ├── index.js ├── package-lock.json ├── package.json └── test ├── api.test.js ├── index.test.js └── utils.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.{diff,md}] 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "parserOptions": { 4 | "ecmaVersion": 6 5 | }, 6 | 7 | "env": { 8 | "es6": true 9 | }, 10 | "rules": { 11 | "semi": ["off", "always"], 12 | "quotes": ["warn", "single"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .envrc 4 | .idea 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastly/heroku-fastly/95ad6d1be4002e99b32c9722f1f2b68c1508ec01/.prettierignore -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | 4 | sudo: required 5 | dist: trusty 6 | 7 | cache: 8 | directories: 9 | - node_modules 10 | 11 | install: 12 | - npm install 13 | - npm prune 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.0.3 (unreleased) 2 | 3 | ### Changed 4 | 5 | - TLS command, (Create & Delete) issues and removes TLS certificates via Fastly tls API endpoints 6 | - Verify command queries state of TLS certificate via Fastly tls API endpoints 7 | - Fixed examples in Readme.md and cli command help output 8 | 9 | ## 2.0.2 10 | 11 | ### Changed 12 | 13 | - ⬆️ Fixed security problems in dependencies 14 | 15 | ## 2.0.1 16 | 17 | ### Added 18 | 19 | - 🔊 better error reporting 20 | 21 | ### Changed 22 | 23 | - 📝 Changelog now in https://keepachangelog.com/en/1.0.0/ format 24 | 25 | ## 2.0.0 26 | 27 | ### Changed 28 | 29 | - Rename package from heroku-fastly to @fastly/heroku-plugin 30 | 31 | ## 1.0.7 32 | 33 | ### Changed 34 | 35 | - Use api.fastly.com instead of app.fastly.com for API access 36 | 37 | ## 1.0.6 38 | 39 | ### Changed 40 | 41 | - update dependencies 42 | 43 | ## 1.0.5 44 | 45 | ### Changed 46 | 47 | - downgrade heroku-cli-util dependency 48 | 49 | ## 1.0.4 50 | 51 | ### Changed 52 | 53 | - upgrade heroku-cli-util dependency 54 | 55 | ## 1.0.3 56 | 57 | ### Changed 58 | 59 | - set `files` in `package.json`, as now required by ocliff 60 | 61 | ## 1.0.2 62 | 63 | ### Added 64 | 65 | - improve messaging of `tls` and `verify` commands 66 | - add soft-purge option 67 | - display CNAME when verification is complete 68 | 69 | ### Changed 70 | 71 | - upgrade `fastly` dependency 72 | 73 | ## 1.0.0 74 | 75 | ### Added 76 | 77 | - first implementation of `verify` command 78 | - improve messaging of `tls` command 79 | 80 | ### Changed 81 | 82 | - less logging 83 | 84 | ## 0.0.3 85 | 86 | ### Added 87 | 88 | - `tls` command prints CNAME 89 | - improve help text 90 | 91 | ### Changed 92 | 93 | - output copyable metatag for `dns` and `url` verifications 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Archived 2 | 3 | This repository is archived because the Fastly Heroku Add-on is no longer available. 4 | 5 | # Heroku Fastly Plugin 6 | 7 | Heroku CLI plugin for interacting with fastly configuration. 8 | 9 | # Installation 10 | 11 | Install the heroku-fastly plugin using the `heroku plugins` command. More details are available in Heroku's [Devcenter](https://devcenter.heroku.com/articles/using-cli-plugins). 12 | 13 | ``` 14 | heroku plugins:install @fastly/heroku-plugin 15 | ``` 16 | 17 | # Usage 18 | 19 | The CLI Plugin includes the commands: `tls`, `verify`, and `purge`. 20 | 21 | --- 22 | 23 | ## TLS and Verify 24 | 25 | To add TLS to a domain your pricing plan must include a TLS domain and the domain must be configured in the active version of your Fastly Service. 26 | The process involves creating the TLS Domain, verifying ownership of your domain, and checking the verification status of your domain. Usage: 27 | 28 | ``` 29 | heroku fastly:tls DOMAIN --app [HEROKU_APP_NAME] 30 | ``` 31 | 32 | To add TLS/SSL to a custom domain: 33 | 34 | ``` 35 | heroku fastly:tls www.example.org --app my-fast-app 36 | ``` 37 | 38 | The output of add TLS/SSL command will provide the required DNS record values that need to be 39 | add to your DNS provider configuration. These include the acme challenge as well as A/CNAME record entries. 40 | 41 | Verifies the state of the add TLS/SSL request. 42 | 43 | ``` 44 | heroku fastly:verify www.example.org --app my-fast-app 45 | ``` 46 | 47 | To remove TLS/SSL from a custom domain, include the the `-d` flag: 48 | 49 | ``` 50 | heroku fastly:tls -d www.example.org --app my-fast-app 51 | ``` 52 | 53 | ## Purge 54 | 55 | Issue a surrogate key purge or purge all. For reference, see the [Purge API docs](https://docs.fastly.com/api/purge). Usage: 56 | 57 | ``` 58 | heroku fastly:purge [KEY] 59 | ``` 60 | 61 | To purge the entire cache: 62 | 63 | ``` 64 | heroku fastly:purge --all --app my-fast-app 65 | ``` 66 | 67 | To purge a surrogate-key from the cache: 68 | 69 | ``` 70 | heroku fastly:purge my-surrogate-key --app my-fast-app 71 | ``` 72 | 73 | To [softpurge](https://docs.fastly.com/api/purge#soft_purge) a key from the cache: 74 | 75 | ``` 76 | heroku fastly:purge my-surrogate-key --soft --app my-fast-app 77 | ``` 78 | 79 | # Development 80 | 81 | Clone the repo and run `npm install` to install dependencies. 82 | 83 | Further detail on building Heroku CLI plugins is available in the [devcenter](https://devcenter.heroku.com/articles/developing-cli-plugins). 84 | 85 | - Clone this repo. 86 | - `cd` into `heroku-fastly` repo 87 | - Run `npm install`. 88 | - Run `heroku plugins:link`. 89 | - You can make sure this is working by running `heroku plugins`, will return something like: 90 | 91 | ``` 92 | heroku-fastly 1.0.7 (link) /Users/your-path/heroku-fastly 93 | ``` 94 | 95 | - Test `tls` command. Run `heroku fastly:tls www.example.org --app my-fast-app`. This command will return something like: 96 | 97 | ``` 98 | The domain www.example.org is currently in a state of pending and the issuing of a certificate may take up to 30 minutes 99 | 100 | To start the domain verification process create a DNS CNAME record with the following values 101 | 102 | DNS Record Type: CNAME 103 | DNS Record Name: _acme-challenge.www.example.org 104 | DNS Record value(s): pkfsreworlfwh23r66.fastly-validations.com 105 | 106 | Alongside the initial verification record configure the following CNAME record 107 | 108 | DNS Record Type: CNAME 109 | DNS Record Name: www.example.org 110 | DNS Record value(s): j.sni.global.fastly.net 111 | 112 | As an alternative to using a CNAME record the following A record can be configured 113 | 114 | DNS Record Type: A 115 | DNS Record Name: www.example.org 116 | DNS Record value(s): 151.101.2.132, 151.101.66.132, 151.101.130.132, 151.101.194.132 117 | ``` 118 | 119 | - Test `verify` command. Run `heroku fastly:verify www.example.org --app my-fast-app`, will return something like: 120 | 121 | ``` 122 | The domain www.example.org is currently in a state of pending and the issuing of a certificate may take up to 30 minutes 123 | 124 | To start the domain verification process create a DNS CNAME record with the following values 125 | 126 | DNS Record Type: CNAME 127 | DNS Record Name: _acme-challenge.www.example.org 128 | DNS Record value(s): pkfsreworlfwh23r66.fastly-validations.com 129 | 130 | Alongside the initial verification record configure the following CNAME record 131 | 132 | DNS Record Type: CNAME 133 | DNS Record Name: www.example.org 134 | DNS Record value(s): j.sni.global.fastly.net 135 | 136 | As an alternative to using a CNAME record the following A record can be configured 137 | 138 | DNS Record Type: A 139 | DNS Record Name: www.example.org 140 | DNS Record value(s): 151.101.2.132, 151.101.66.132, 151.101.130.132, 151.101.194.132 141 | ``` 142 | 143 | - Test `purge` command. Run `heroku fastly:purge --all --app my-fast-app`, will return something like: 144 | 145 | ``` 146 | { status: 'ok' } 147 | ``` 148 | 149 | ## Testing 150 | 151 | Tests can be run with `npm test`. 152 | 153 | ## Publishing 154 | 155 | - We follow [Semantic versioning](https://semver.org/) with regards to the 156 | version of this plugin. Any pull-requests to this project must include an 157 | appropriate change to the `package.json` file (as well as the 158 | `package-lock.json` file) and the `CHANGELOG.md` file. 159 | 160 | - After any PR has been merged, run an `npm publish` command from the `master` 161 | branch after pulling all changes in from `github`. 162 | 163 | ## Contact us 164 | 165 | Have an issue? Please send an email to support@fastly.com. 166 | 167 | ## Contributing 168 | 169 | Want to see new functionality? Please open a pull request. 170 | -------------------------------------------------------------------------------- /commands/api.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fetch = require('node-fetch') 4 | 5 | module.exports = class Fastly { 6 | constructor(config) { 7 | this.apiKey = config.apiKey 8 | this.baseUri = config.baseUri || 'https://api.fastly.com' 9 | } 10 | 11 | request(endpoint = '', options = {}) { 12 | let url = this.baseUri + endpoint 13 | let headers = { 14 | 'Fastly-Key': this.apiKey, 15 | Accept: 'application/vnd.api+json', 16 | 'Content-Type': 'application/vnd.api+json', 17 | } 18 | let config = { 19 | ...options, 20 | headers: headers, 21 | } 22 | 23 | return fetch(url, config).then((r) => { 24 | if (r.ok) { 25 | if (r.status != 204) { 26 | return r.json() 27 | } else { 28 | return {} 29 | } 30 | } 31 | throw new Error(`Fastly API error - ${url} - ${r.status} ${r.statusText}`) 32 | }) 33 | } 34 | 35 | getDomains() { 36 | let endpoint = 37 | '/tls/domains?include=tls_activations,tls_subscriptions.tls_authorizations,tls_subscriptions' 38 | const options = { 39 | method: 'GET', 40 | } 41 | return this.request(endpoint, options) 42 | } 43 | 44 | createSubscription(domain) { 45 | const options = { 46 | method: 'POST', 47 | body: JSON.stringify({ 48 | data: { 49 | type: 'tls_subscription', 50 | attributes: { 51 | certificate_authority: 'lets-encrypt', 52 | }, 53 | relationships: { 54 | tls_domains: { 55 | data: [{ type: 'tls_domain', id: domain }], 56 | }, 57 | tls_configuration: { 58 | data: {}, 59 | }, 60 | }, 61 | }, 62 | }), 63 | } 64 | return this.request('/tls/subscriptions', options) 65 | } 66 | 67 | deleteActivation(id) { 68 | let endpoint = `/tls/activations/${id}` 69 | const options = { 70 | method: 'DELETE', 71 | } 72 | return this.request(endpoint, options) 73 | } 74 | 75 | deleteSubscription(id) { 76 | let endpoint = `/tls/subscriptions/${id}` 77 | const options = { 78 | method: 'DELETE', 79 | } 80 | return this.request(endpoint, options) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /commands/purge.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const hk = require('heroku-cli-util') 3 | const co = require('co') 4 | const fastly = require('fastly') 5 | 6 | module.exports = { 7 | topic: 'fastly', 8 | command: 'purge', 9 | description: 10 | 'Purge the entire Fastly cache or any object(s) with the provided surrogate key', 11 | help: 12 | 'Purge object(s) from the Fastly cache with the provided surrogate key\n\ 13 | More details on purge at docs.fastly.com/api/purge', 14 | needsApp: true, 15 | needsAuth: true, 16 | args: [{ name: 'key', optional: true }], 17 | flags: [ 18 | { name: 'all', description: 'Issues a Fastly PurgeAll', hasValue: false }, 19 | { 20 | name: 'soft', 21 | description: 'Forces revalidation instead of instant purge', 22 | hasValue: false, 23 | }, 24 | ], 25 | 26 | run: hk.command(function (context, heroku) { 27 | if (!context.flags.all && !context.args.key) { 28 | return hk.error('You must specify `--all` or a key to purge with.') 29 | } 30 | 31 | return co(function* () { 32 | let config = yield heroku.get(`/apps/${context.app}/config-vars`) 33 | const api = fastly(config.FASTLY_API_KEY) 34 | 35 | if (context.flags.all) { 36 | api.purgeAll(config.FASTLY_SERVICE_ID, function (err, obj) { 37 | if (err) { 38 | hk.error(err) 39 | } else { 40 | hk.log(obj) 41 | } 42 | }) 43 | } 44 | 45 | if (context.args.key) { 46 | if (context.flags.soft) { 47 | api.softPurgeKey( 48 | config.FASTLY_SERVICE_ID, 49 | context.args.key, 50 | function (err, obj) { 51 | if (err) { 52 | hk.error(err) 53 | } else { 54 | hk.log(obj) 55 | } 56 | } 57 | ) 58 | } else { 59 | api.purgeKey(config.FASTLY_SERVICE_ID, context.args.key, function ( 60 | err, 61 | obj 62 | ) { 63 | if (err) { 64 | hk.error(err) 65 | } else { 66 | hk.log(obj) 67 | } 68 | }) 69 | } 70 | } 71 | }) 72 | }), 73 | } 74 | -------------------------------------------------------------------------------- /commands/tls.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const hk = require('heroku-cli-util') 3 | const co = require('co') 4 | const apiClient = require('./api') 5 | const utils = require('./utils') 6 | 7 | const JsonApiDataStore = require('jsonapi-datastore').JsonApiDataStore 8 | 9 | module.exports = { 10 | topic: 'fastly', 11 | command: 'tls', 12 | description: 'Add/Remove Fastly TLS to DOMAIN', 13 | help: 14 | 'DOMAIN will be added to a Fastly Heroku SAN SSL certificate. \n\n\ 15 | Requirements: \n\ 16 | - The Fastly Service must have DOMAIN configured in the active version \n\ 17 | - Heroku pricing plan must include TLS Domain(s) \n\ 18 | - Wildcard domains are not allowed \n\n\ 19 | Usage: \n\ 20 | heroku fastly:tls www.example.org --app my-fast-app\n ', 21 | needsApp: true, 22 | needsAuth: true, 23 | args: [{ name: 'domain', description: 'The domain for TLS configure' }], 24 | flags: [ 25 | { 26 | name: 'delete', 27 | char: 'd', 28 | description: 'Remove TLS from DOMAIN', 29 | hasValue: false, 30 | }, 31 | { 32 | name: 'api_uri', 33 | char: 'u', 34 | description: 'Override Fastly API URI', 35 | hasValue: true, 36 | }, 37 | { 38 | name: 'api_key', 39 | char: 'k', 40 | description: 'Override FASTLY_API_KEY config var', 41 | hasValue: true, 42 | }, 43 | ], 44 | 45 | run: hk.command(function (context, heroku) { 46 | return co(function* () { 47 | let baseUri = context.flags.api_uri || 'https://api.fastly.com' 48 | let config = yield heroku.get(`/apps/${context.app}/config-vars`) 49 | let apiKey = context.flags.api_key || config.FASTLY_API_KEY 50 | let domain = context.args.domain 51 | 52 | utils.validateAPIKey(apiKey) 53 | 54 | const api = new apiClient({ 55 | baseUri: baseUri, 56 | apiKey: apiKey, 57 | }) 58 | 59 | if (context.flags.delete) { 60 | api 61 | .getDomains() 62 | .then(locateSubscriptionDetails(domain)) 63 | .then(deleteActivation(api, domain)) 64 | .then(deleteSubscription(api, domain)) 65 | .catch(utils.renderFastlyError()) 66 | } else { 67 | api 68 | .getDomains() 69 | .then(locateSubscriptionDetails(domain)) 70 | .then(createSubscription(api, domain)) 71 | .catch(utils.renderFastlyError()) 72 | } 73 | }) 74 | }), 75 | } 76 | 77 | function createSubscription(api, domain) { 78 | return (data) => { 79 | if (!data.subscriptionId) { 80 | api 81 | .createSubscription(domain) 82 | .then((data) => { 83 | const store = new JsonApiDataStore() 84 | let subscription = store.sync(data) 85 | let state = subscription.state 86 | let challenges = subscription.tls_authorizations[0].challenges 87 | 88 | if (state === 'issued' || state === 'renewing') { 89 | hk.log( 90 | `The domain ${domain} is currently in a state of ${state}. It could take up to an hour for the certificate to propagate globally.\n` 91 | ) 92 | 93 | hk.log( 94 | 'To use the certificate configure the following CNAME record\n' 95 | ) 96 | utils.displayChallenge(challenges, 'managed-http-cname') 97 | 98 | hk.log( 99 | 'As an alternative to using a CNAME record the following A record can be configured\n' 100 | ) 101 | utils.displayChallenge(challenges, 'managed-http-a') 102 | } 103 | 104 | if (state === 'pending' || state === 'processing') { 105 | hk.log( 106 | `The domain ${domain} is currently in a state of ${state} and the issuing of a certificate may take up to 30 minutes\n` 107 | ) 108 | 109 | hk.log( 110 | 'To start the domain verification process create a DNS CNAME record with the following values\n' 111 | ) 112 | utils.displayChallenge(challenges, 'managed-dns') 113 | 114 | hk.log( 115 | 'Alongside the initial verification record configure the following CNAME record\n' 116 | ) 117 | utils.displayChallenge(challenges, 'managed-http-cname') 118 | 119 | hk.log( 120 | 'As an alternative to using a CNAME record the following A record can be configured\n' 121 | ) 122 | utils.displayChallenge(challenges, 'managed-http-a') 123 | } 124 | }) 125 | .catch((e) => { 126 | hk.error(`Fastly Plugin execution - ${e.name} - ${e.message}`) 127 | process.exit(1) 128 | }) 129 | } else { 130 | hk.error(`The domain ${domain} already has a TLS subscription`) 131 | } 132 | } 133 | } 134 | 135 | function deleteActivation(api, domain) { 136 | return (data) => { 137 | if (data.activationId) { 138 | api 139 | .deleteActivation(data.activationId) 140 | .then(() => { 141 | hk.log(`TLS subscription for domain ${domain} has been deactivated`) 142 | }) 143 | .catch((e) => { 144 | hk.error(`Fastly Plugin execution - ${e.name} - ${e.message}`) 145 | process.exit(1) 146 | }) 147 | } else { 148 | hk.log(`TLS subscription for domain ${domain} was not active`) 149 | } 150 | return data 151 | } 152 | } 153 | 154 | function deleteSubscription(api, domain) { 155 | return (data) => { 156 | if (data.subscriptionId) { 157 | api 158 | .deleteSubscription(data.subscriptionId) 159 | .then(() => { 160 | hk.log(`TLS subscription for domain ${domain} has been removed`) 161 | hk.log('This domain will no longer support TLS') 162 | }) 163 | .catch((e) => { 164 | hk.error(`Fastly Plugin execution - ${e.name} - ${e.message}`) 165 | process.exit(1) 166 | }) 167 | } else { 168 | hk.error(`The domain ${domain} does not support TLS`) 169 | } 170 | } 171 | } 172 | 173 | function locateSubscriptionDetails(domain) { 174 | return (data) => { 175 | const store = new JsonApiDataStore() 176 | store.sync(data) 177 | const tlsDomain = store.find('tls_domain', domain) 178 | 179 | const subDetails = { 180 | activationId: null, 181 | subscriptionId: null, 182 | } 183 | 184 | if (tlsDomain) { 185 | let activations = tlsDomain.tls_activations 186 | let subscriptions = tlsDomain.tls_subscriptions 187 | 188 | if (activations.length > 0) { 189 | subDetails.activationId = activations[0].id 190 | } 191 | 192 | if (subscriptions.length > 0) { 193 | subDetails.subscriptionId = subscriptions[0].id 194 | } 195 | } 196 | 197 | return subDetails 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /commands/utils.js: -------------------------------------------------------------------------------- 1 | const hk = require('heroku-cli-util') 2 | 3 | function validateAPIKey(apiKey) { 4 | if (!apiKey) { 5 | hk.error( 6 | 'config var FASTLY_API_KEY not found! The Fastly add-on is required to configure TLS. Install Fastly at https://elements.heroku.com/addons/fastly' 7 | ) 8 | process.exit(1) 9 | } 10 | } 11 | 12 | function renderFastlyError() { 13 | return (err) => { 14 | hk.error(`Fastly Plugin execution error - ${err.name} - ${err.message}`) 15 | process.exit(1) 16 | } 17 | } 18 | 19 | function displayChallenge(challenges, type) { 20 | for (var i = 0; i < challenges.length; i++) { 21 | let challenge = challenges[i] 22 | if (challenge.type === type) { 23 | hk.log(`DNS Record Type: ${challenge.record_type}`) 24 | hk.log(`DNS Record Name: ${challenge.record_name}`) 25 | hk.log(`DNS Record value(s): ${challenge.values.join(', ')}\n`) 26 | } 27 | } 28 | } 29 | 30 | exports.displayChallenge = displayChallenge 31 | exports.renderFastlyError = renderFastlyError 32 | exports.validateAPIKey = validateAPIKey 33 | -------------------------------------------------------------------------------- /commands/verify.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const hk = require('heroku-cli-util') 3 | const co = require('co') 4 | const apiClient = require('./api') 5 | const utils = require('./utils') 6 | 7 | const JsonApiDataStore = require('jsonapi-datastore').JsonApiDataStore 8 | 9 | module.exports = { 10 | topic: 'fastly', 11 | command: 'verify', 12 | description: 'Check the status of the Fastly TLS subscription.', 13 | help: 14 | 'A command that allows the status of the Fastly TLS subscription to be checked.', 15 | needsApp: true, 16 | needsAuth: true, 17 | args: [ 18 | { name: 'domain', description: 'The domain to check', optional: false }, 19 | ], 20 | flags: [ 21 | { 22 | name: 'api_uri', 23 | char: 'u', 24 | description: 'Override Fastly API URI', 25 | hasValue: true, 26 | }, 27 | { 28 | name: 'api_key', 29 | char: 'k', 30 | description: 'Override Fastly_API_KEY config var', 31 | hasValue: true, 32 | }, 33 | ], 34 | run: hk.command(function (context, heroku) { 35 | return co(function* () { 36 | let baseUri = context.flags.api_uri || 'https://api.fastly.com' 37 | let config = yield heroku.get(`/apps/${context.app}/config-vars`) 38 | let apiKey = context.flags.api_key || config.FASTLY_API_KEY 39 | let domain = context.args.domain 40 | 41 | utils.validateAPIKey(apiKey) 42 | 43 | const api = new apiClient({ 44 | baseUri: baseUri, 45 | apiKey: apiKey, 46 | }) 47 | 48 | api 49 | .getDomains() 50 | .then(verifyFastlyTlsSubscription(domain)) 51 | .catch(utils.renderFastlyError()) 52 | }) 53 | }), 54 | } 55 | 56 | function verifyFastlyTlsSubscription(domain) { 57 | return (data) => { 58 | const store = new JsonApiDataStore() 59 | const domains = store.sync(data) 60 | 61 | hk.debug( 62 | `Located ${domains.length} tls domains linked to the fastly service` 63 | ) 64 | 65 | const tlsDomain = store.find('tls_domain', domain) 66 | 67 | if (tlsDomain) { 68 | const state = tlsDomain.tls_subscriptions[0].state 69 | const challenges = 70 | tlsDomain.tls_subscriptions[0].tls_authorizations[0].challenges 71 | 72 | if (state === 'issued' || state === 'renewing') { 73 | hk.log( 74 | `The domain ${domain} is currently in a state of ${state}. It could take up to an hour for the certificate to propagate globally.\n` 75 | ) 76 | 77 | hk.log('To use the certificate configure the following CNAME record\n') 78 | utils.displayChallenge(challenges, 'managed-http-cname') 79 | 80 | hk.log( 81 | 'As an alternative to using a CNAME record the following A record can be configured\n' 82 | ) 83 | utils.displayChallenge(challenges, 'managed-http-a') 84 | } 85 | 86 | if (state === 'pending' || state === 'processing') { 87 | hk.log( 88 | `The domain ${domain} is currently in a state of ${state} and the issuing of a certificate may take up to 30 minutes\n` 89 | ) 90 | 91 | hk.log( 92 | 'To start the domain verification process create a DNS CNAME record with the following values\n' 93 | ) 94 | utils.displayChallenge(challenges, 'managed-dns') 95 | 96 | hk.log( 97 | 'Alongside the initial verification record configure the following CNAME record\n' 98 | ) 99 | utils.displayChallenge(challenges, 'managed-http-cname') 100 | 101 | hk.log( 102 | 'As an alternative to using a CNAME record the following A record can be configured\n' 103 | ) 104 | utils.displayChallenge(challenges, 'managed-http-a') 105 | } 106 | } else { 107 | hk.warn(`The domain ${domain} does not support TLS.`) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | exports.topic = { 3 | name: 'fastly', 4 | description: 'Fastly CDN CLI tools for Heroku', 5 | } 6 | 7 | exports.commands = [ 8 | require('./commands/purge'), 9 | require('./commands/tls'), 10 | require('./commands/verify'), 11 | ] 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastly/heroku-plugin", 3 | "version": "2.0.3", 4 | "description": "Heroku CLI Plugin for interacting with Fastly CDN", 5 | "homepage": "https://www.fastly.com", 6 | "main": "index.js", 7 | "author": "Fastly, Inc. ", 8 | "contributors": [ 9 | { 10 | "name": "Michael May", 11 | "email": "michael@fastly.com" 12 | }, 13 | { 14 | "name": "Andri Antoniades", 15 | "email": "andri@fastly.com" 16 | }, 17 | { 18 | "name": "James A Rosen", 19 | "email": "james@fastly.com" 20 | } 21 | ], 22 | "scripts": { 23 | "pretest": "prettier --write . && eslint --ignore-path .gitignore .", 24 | "test": "jest" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/fastly/heroku-fastly" 29 | }, 30 | "bugs": { 31 | "url": "https://github.com/fastly/heroku-fastly/issues" 32 | }, 33 | "keywords": [ 34 | "heroku-plugin", 35 | "fastly" 36 | ], 37 | "license": "ISC", 38 | "dependencies": { 39 | "co": "^4.6.0", 40 | "fastly": "^2.2.1", 41 | "heroku-cli-util": "^8.0.10", 42 | "jsonapi-datastore": "^0.4.0-beta", 43 | "node-fetch": "^2.6.1" 44 | }, 45 | "devDependencies": { 46 | "babel-eslint": "^10.1.0", 47 | "eslint": "^7.9.0", 48 | "jest": "^26.4.2", 49 | "jest-fetch-mock": "^3.0.3", 50 | "jest-mock-process": "^1.4.0", 51 | "prettier": "^2.1.2", 52 | "request": "^2.88.2" 53 | }, 54 | "files": [ 55 | "/index.js", 56 | "/commands" 57 | ], 58 | "private": false 59 | } 60 | -------------------------------------------------------------------------------- /test/api.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | require('jest-fetch-mock').enableMocks() 4 | const apiClient = require('../commands/api') 5 | 6 | describe('interaction with fastly api', () => { 7 | beforeEach(() => { 8 | fetch.resetMocks() 9 | }) 10 | 11 | it('calls fastly create subscription with expected JSON body', async () => { 12 | fetch.mockResponses(JSON.stringify({ data: {} }), { status: 201 }) 13 | 14 | let expectedTestDomain = 'www.mytestdomain.com' 15 | let expectedFastlyApiKey = 'XXXXXXXXX' 16 | 17 | let api = new apiClient({ 18 | apiKey: `${expectedFastlyApiKey}`, 19 | }) 20 | 21 | let json = await api.createSubscription(expectedTestDomain) 22 | 23 | expect(json).not.toBeNull() 24 | expect(fetch).toHaveBeenCalledTimes(1) 25 | expect(fetch).toHaveBeenCalledWith( 26 | 'https://api.fastly.com/tls/subscriptions', 27 | { 28 | method: 'POST', 29 | headers: { 30 | Accept: 'application/vnd.api+json', 31 | 'Content-Type': 'application/vnd.api+json', 32 | 'Fastly-Key': `${expectedFastlyApiKey}`, 33 | }, 34 | body: JSON.stringify({ 35 | data: { 36 | type: 'tls_subscription', 37 | attributes: { 38 | certificate_authority: 'lets-encrypt', 39 | }, 40 | relationships: { 41 | tls_domains: { 42 | data: [{ type: 'tls_domain', id: `${expectedTestDomain}` }], 43 | }, 44 | tls_configuration: { 45 | data: {}, 46 | }, 47 | }, 48 | }, 49 | }), 50 | } 51 | ) 52 | }) 53 | 54 | it('calls fastly get domains with correct url and headers', async () => { 55 | fetch.mockResponses(JSON.stringify({ data: {} }), { status: 200 }) 56 | 57 | let expectedFastlyApiKey = 'XXXXXXXXX' 58 | 59 | let api = new apiClient({ 60 | apiKey: `${expectedFastlyApiKey}`, 61 | }) 62 | 63 | let json = await api.getDomains() 64 | 65 | expect(json).not.toBeNull() 66 | expect(fetch).toHaveBeenCalledTimes(1) 67 | expect(fetch).toHaveBeenCalledWith( 68 | 'https://api.fastly.com/tls/domains?include=tls_activations,tls_subscriptions.tls_authorizations,tls_subscriptions', 69 | { 70 | method: 'GET', 71 | headers: { 72 | Accept: 'application/vnd.api+json', 73 | 'Content-Type': 'application/vnd.api+json', 74 | 'Fastly-Key': `${expectedFastlyApiKey}`, 75 | }, 76 | } 77 | ) 78 | }) 79 | 80 | it('calls fastly delete activation with correct url and headers', async () => { 81 | fetch.mockResponses(JSON.stringify({ data: {} }), { status: 204 }) 82 | 83 | let expectedFastlyApiKey = 'XXXXXXXXX' 84 | let expectedFastlyActivationId = 'xnXOIZM4ebbmSA5dgk3xmw' 85 | 86 | let api = new apiClient({ 87 | apiKey: `${expectedFastlyApiKey}`, 88 | }) 89 | 90 | let json = await api.deleteActivation(expectedFastlyActivationId) 91 | 92 | expect(json).not.toBeNull() 93 | expect(fetch).toHaveBeenCalledTimes(1) 94 | expect(fetch).toHaveBeenCalledWith( 95 | `https://api.fastly.com/tls/activations/${expectedFastlyActivationId}`, 96 | { 97 | method: 'DELETE', 98 | headers: { 99 | Accept: 'application/vnd.api+json', 100 | 'Content-Type': 'application/vnd.api+json', 101 | 'Fastly-Key': `${expectedFastlyApiKey}`, 102 | }, 103 | } 104 | ) 105 | }) 106 | 107 | it('calls fastly delete subscription with correct url and headers', async () => { 108 | fetch.mockResponses(JSON.stringify({ data: {} }), { status: 204 }) 109 | 110 | let expectedFastlyApiKey = 'XXXXXXXXX' 111 | let expectedFastlySubscriptionId = 'DzZk7Txr2XJmDDqYSOSA0A' 112 | 113 | let api = new apiClient({ 114 | apiKey: `${expectedFastlyApiKey}`, 115 | }) 116 | 117 | let json = await api.deleteSubscription(expectedFastlySubscriptionId) 118 | 119 | expect(json).not.toBeNull() 120 | expect(fetch).toHaveBeenCalledTimes(1) 121 | expect(fetch).toHaveBeenCalledWith( 122 | `https://api.fastly.com/tls/subscriptions/${expectedFastlySubscriptionId}`, 123 | { 124 | method: 'DELETE', 125 | headers: { 126 | Accept: 'application/vnd.api+json', 127 | 'Content-Type': 'application/vnd.api+json', 128 | 'Fastly-Key': `${expectedFastlyApiKey}`, 129 | }, 130 | } 131 | ) 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const index = require('../index') 4 | 5 | describe('fastly heroku cli', () => { 6 | it('has a purge command', async () => { 7 | expect(index.commands[0].command).toEqual('purge') 8 | }) 9 | 10 | it('has a tls command', async () => { 11 | expect(index.commands[1].command).toEqual('tls') 12 | }) 13 | 14 | it('has a verify command', async () => { 15 | expect(index.commands[2].command).toEqual('verify') 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const utils = require('../commands/utils') 4 | const hk = require('heroku-cli-util') 5 | var mockProcess = require('jest-mock-process') 6 | 7 | describe('interaction with utils functions', () => { 8 | const herokuErrorSpy = jest.spyOn(hk, 'error') 9 | const mockExit = mockProcess.mockProcessExit() 10 | const mockLog = mockProcess.mockConsoleLog() 11 | 12 | beforeEach(() => { 13 | mockLog.mockReset() 14 | herokuErrorSpy.mockReset() 15 | mockExit.mockReset() 16 | }) 17 | 18 | const testChallenges = [ 19 | { 20 | type: 'managed-dns', 21 | record_type: 'CNAME', 22 | record_name: '_acme-challenge.www.example.org', 23 | values: ['dvxzuc4govtr3juagj.fastly-validations.com'], 24 | }, 25 | { 26 | type: 'managed-http-cname', 27 | record_type: 'CNAME', 28 | record_name: 'www.example.org', 29 | values: ['j.sni.global.fastly.net'], 30 | }, 31 | { 32 | type: 'managed-http-a', 33 | record_type: 'A', 34 | record_name: 'www.example.org', 35 | values: [ 36 | '151.101.2.132', 37 | '151.101.66.132', 38 | '151.101.130.132', 39 | '151.101.194.132', 40 | ], 41 | }, 42 | ] 43 | 44 | it('confirm managed-dns challenge renders correctly', async () => { 45 | utils.displayChallenge(testChallenges, 'managed-dns') 46 | 47 | expect(mockLog).toHaveBeenCalledTimes(3) 48 | expect(mockLog).toHaveBeenCalledWith('DNS Record Type: CNAME') 49 | expect(mockLog).toHaveBeenCalledWith( 50 | 'DNS Record Name: _acme-challenge.www.example.org' 51 | ) 52 | expect(mockLog).toHaveBeenCalledWith( 53 | 'DNS Record value(s): dvxzuc4govtr3juagj.fastly-validations.com\n' 54 | ) 55 | }) 56 | 57 | it('confirm managed-http-cname challenge renders correctly', async () => { 58 | utils.displayChallenge(testChallenges, 'managed-http-cname') 59 | 60 | expect(mockLog).toHaveBeenCalledTimes(3) 61 | expect(mockLog).toHaveBeenCalledWith('DNS Record Type: CNAME') 62 | expect(mockLog).toHaveBeenCalledWith('DNS Record Name: www.example.org') 63 | expect(mockLog).toHaveBeenCalledWith( 64 | 'DNS Record value(s): j.sni.global.fastly.net\n' 65 | ) 66 | }) 67 | 68 | it('confirm managed-http-a challenge renders correctly', async () => { 69 | utils.displayChallenge(testChallenges, 'managed-http-a') 70 | 71 | expect(mockLog).toHaveBeenCalledTimes(3) 72 | expect(mockLog).toHaveBeenCalledWith('DNS Record Type: A') 73 | expect(mockLog).toHaveBeenCalledWith('DNS Record Name: www.example.org') 74 | expect(mockLog).toHaveBeenCalledWith( 75 | 'DNS Record value(s): 151.101.2.132, 151.101.66.132, 151.101.130.132, 151.101.194.132\n' 76 | ) 77 | }) 78 | 79 | it('confirm managed-http-a challenge renders correctly with fewer ip addresses', async () => { 80 | const challenges = [ 81 | { 82 | type: 'managed-http-a', 83 | record_type: 'A', 84 | record_name: 'www.example.org', 85 | values: ['151.101.2.132', '151.101.66.132'], 86 | }, 87 | ] 88 | 89 | utils.displayChallenge(challenges, 'managed-http-a') 90 | 91 | expect(mockLog).toHaveBeenCalledTimes(3) 92 | expect(mockLog).toHaveBeenCalledWith('DNS Record Type: A') 93 | expect(mockLog).toHaveBeenCalledWith('DNS Record Name: www.example.org') 94 | expect(mockLog).toHaveBeenCalledWith( 95 | 'DNS Record value(s): 151.101.2.132, 151.101.66.132\n' 96 | ) 97 | }) 98 | 99 | it('renders error message and exits when no API key is supplied', async () => { 100 | utils.validateAPIKey(null) 101 | 102 | expect(herokuErrorSpy).toHaveBeenCalledTimes(1) 103 | expect(herokuErrorSpy).toHaveBeenCalledWith( 104 | 'config var FASTLY_API_KEY not found! The Fastly add-on is required to configure TLS. Install Fastly at https://elements.heroku.com/addons/fastly' 105 | ) 106 | expect(mockExit).toBeCalledWith(1) 107 | }) 108 | 109 | it('does not render error or exits when a API key is supplied', async () => { 110 | utils.validateAPIKey('XXXXXXXXX') 111 | 112 | expect(herokuErrorSpy).not.toBeCalled() 113 | expect(mockExit).not.toBeCalled() 114 | }) 115 | 116 | it('renders error as expected', async () => { 117 | const errFunc = utils.renderFastlyError() 118 | 119 | errFunc({ name: 'test error', message: 'test error message' }) 120 | 121 | expect(herokuErrorSpy).toBeCalledTimes(1) 122 | expect(herokuErrorSpy).toBeCalledWith( 123 | 'Fastly Plugin execution error - test error - test error message' 124 | ) 125 | expect(mockExit).toBeCalledTimes(1) 126 | }) 127 | }) 128 | --------------------------------------------------------------------------------