diff --git a/bin/now b/bin/now index 08a3d66..d542a36 100755 --- a/bin/now +++ b/bin/now @@ -19,8 +19,8 @@ const exit = (code) => { }; const defaultCommand = 'deploy'; -const commands = new Set([defaultCommand, 'list', 'ls', 'rm', 'remove', 'alias', 'aliases', 'ln', 'domain', 'domains', 'secret', 'secrets']); -const aliases = new Map([['ls', 'list'], ['rm', 'remove'], ['ln', 'alias'], ['aliases', 'alias'], ['domain', 'domains'], ['secret', 'secrets']]); +const commands = new Set([defaultCommand, 'list', 'ls', 'rm', 'remove', 'alias', 'aliases', 'ln', 'domain', 'domains', 'cert', 'certs', 'secret', 'secrets']); +const aliases = new Map([['ls', 'list'], ['rm', 'remove'], ['ln', 'alias'], ['aliases', 'alias'], ['domain', 'domains'], ['cert', 'certs'], ['secret', 'secrets']]); let cmd = argv._[0]; let args = []; diff --git a/bin/now-certs b/bin/now-certs new file mode 100755 index 0000000..9226b28 --- /dev/null +++ b/bin/now-certs @@ -0,0 +1,235 @@ +#!/usr/bin/env node +import chalk from 'chalk'; +import table from 'text-table'; +import minimist from 'minimist'; +import * as cfg from '../lib/cfg'; +import { handleError, error } from '../lib/error'; +import NowCerts from '../lib/certs'; +import path from 'path'; +import fs from 'fs-promise'; +import ms from 'ms'; + +const argv = minimist(process.argv.slice(2), { + string: ['config', 'token', 'crt', 'key', 'ca'], + boolean: ['help', 'debug'], + alias: { + help: 'h', + config: 'c', + debug: 'd', + token: 't' + } +}); +const subcommand = argv._[0]; + +// options +const help = () => { + console.log(` + ${chalk.bold('𝚫 now certs')} + + ${chalk.dim('Note:')} + + This command is intended for advanced use only, normally ${chalk.bold('now')} manages your certificates automatically. + + ${chalk.dim('Options:')} + + -h, --help output usage information + -c ${chalk.bold.underline('FILE')}, --config=${chalk.bold.underline('FILE')} config file + -d, --debug debug mode [off] + -t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline('TOKEN')} login token + --crt ${chalk.bold.underline('FILE')} certificate file + --key ${chalk.bold.underline('FILE')} certificate key file + --ca ${chalk.bold.underline('FILE')} CA certificate chain file + + ${chalk.dim('Examples:')} + + ${chalk.gray('–')} Listing all your certificates: + + ${chalk.cyan('$ now certs ls')} + + ${chalk.gray('–')} Creating a new certificate: + + ${chalk.cyan('$ now certs create domain.com')} + + ${chalk.gray('–')} Renewing an existing certificate issued with ${chalk.bold('now')}: + + ${chalk.cyan('$ now certs renew domain.com')} + + ${chalk.gray('–')} Replacing an existing certificate with a user-supplied certificate: + + ${chalk.cyan('$ now certs replace --crt domain.crt --key domain.key --ca ca_chain.crt domain.com')} +`); +}; + +// options +const debug = argv.debug; +const apiUrl = argv.url || 'https://api.zeit.co'; +if (argv.config) cfg.setConfigFile(argv.config); + +const exit = (code) => { + // we give stdout some time to flush out + // because there's a node bug where + // stdout writes are asynchronous + // https://github.com/nodejs/node/issues/6456 + setTimeout(() => process.exit(code || 0), 100); +}; + +if (argv.help || !subcommand) { + help(); + exit(0); +} else { + const config = cfg.read(); + + Promise.resolve(argv.token || config.token || login(apiUrl)) + .then(async (token) => { + try { + await run(token); + } catch (err) { + handleError(err); + exit(1); + } + }) + .catch((e) => { + error(`Authentication error – ${e.message}`); + exit(1); + }); +} + +async function run (token) { + const certs = new NowCerts(apiUrl, token, { debug }); + const args = argv._.slice(1); + const start = Date.now(); + + if ('ls' === subcommand || 'list' === subcommand) { + if (0 !== args.length) { + error(`Invalid number of arguments. Usage: ${chalk.cyan('`now certs ls`')}`); + return exit(1); + } + const list = await certs.ls(); + const elapsed = ms(new Date() - start); + console.log(`> ${list.length} certificate${list.length > 1 ? 's' : ''} found ${chalk.gray(`[${elapsed}]`)}`); + const cur = Date.now(); + list.sort((a, b) => { + return a.cn.localeCompare(b.cn); + }); + const out = table(list.map((cert) => { + const cn = chalk.bold(cert.cn); + const time = chalk.gray(ms(cur - new Date(cert.created)) + ' ago'); + return [ + '', + cert.uid ? cert.uid : 'unknown', + cn, + time + ]; + }), { align: ['l', 'r', 'l'], hsep: ' '.repeat(2) }); + if (out) console.log('\n' + out + '\n'); + } else if ('create' === subcommand) { + if (1 !== args.length) { + error(`Invalid number of arguments. Usage: ${chalk.cyan('`now certs create `')}`); + return exit(1); + } + const cn = args[0]; + const cert = await certs.create(cn); + const elapsed = ms(new Date() - start); + console.log(`${chalk.cyan('> Success!')} Certificate ${chalk.bold(cn)} ${chalk.gray(`(${cert.uid})`)} issued ${chalk.gray(`[${elapsed}]`)}`); + } else if ('renew' === subcommand) { + if (1 !== args.length) { + error(`Invalid number of arguments. Usage: ${chalk.cyan('`now certs renew `')}`); + return exit(1); + } + + const cert = await getCertIdCn(certs, args[0]); + const yes = await readConfirmation(cert, 'The following certificate will be renewed\n'); + if (!yes) { + error('User abort'); + return exit(0); + } + + await certs.renew(cert.cn); + const elapsed = ms(new Date() - start); + console.log(`${chalk.cyan('> Success!')} Certificate ${chalk.bold(cert.cn)} ${chalk.gray(`(${cert.uid})`)} renewed ${chalk.gray(`[${elapsed}]`)}`); + } else if ('replace' === subcommand) { + if (!argv.crt || !argv.key) { + error(`Invalid number of arguments. Usage: ${chalk.cyan('`now certs replace --crt DOMAIN.CRT --key DOMAIN.KEY [--ca CA.CRT] `')}`); + return exit(1); + } + + const crt = readX509File(argv.crt); + const key = readX509File(argv.key); + const ca = argv.ca ? readX509File(argv.ca) : ''; + + const cert = await getCertIdCn(certs, args[0]); + const yes = await readConfirmation(cert, 'The following certificate will be replaced permanently\n'); + if (!yes) { + error('User abort'); + return exit(0); + } + + await certs.replace(cert.cn, crt, key, ca); + const elapsed = ms(new Date() - start); + console.log(`${chalk.cyan('> Success!')} Certificate ${chalk.bold(cert.cn)} ${chalk.gray(`(${cert.uid})`)} replaced ${chalk.gray(`[${elapsed}]`)}`); + } else if ('rm' === subcommand || 'remove' === subcommand) { + if (1 !== args.length) { + error(`Invalid number of arguments. Usage: ${chalk.cyan('`now certs rm `')}`); + return exit(1); + } + + const cert = await getCertIdCn(certs, args[0]); + const yes = await readConfirmation(cert, 'The following certificate will be removed permanently\n'); + if (!yes) { + error('User abort'); + return exit(0); + } + + await certs.delete(cert.cn); + const elapsed = ms(new Date() - start); + console.log(`${chalk.cyan('> Success!')} Certificate ${chalk.bold(cert.cn)} ${chalk.gray(`(${cert.uid})`)} removed ${chalk.gray(`[${elapsed}]`)}`); + } else { + error('Please specify a valid subcommand: ls | create | renew | replace | rm'); + help(); + exit(1); + } + return certs.close(); +} + +process.on('uncaughtException', (err) => { + handleError(err); + exit(1); +}); + +function readConfirmation (cert, msg) { + return new Promise((resolve, reject) => { + const time = chalk.gray(ms(new Date() - new Date(cert.created)) + ' ago'); + const tbl = table( + [[cert.uid, chalk.bold(cert.cn), time]], + { align: ['l', 'r', 'l'], hsep: ' '.repeat(6) } + ); + + process.stdout.write(`> ${msg}`); + process.stdout.write(' ' + tbl + '\n'); + + process.stdout.write(`${chalk.bold.red('> Are you sure?')} ${chalk.gray('[yN] ')}`); + + process.stdin.on('data', (d) => { + process.stdin.pause(); + resolve('y' === d.toString().trim().toLowerCase()); + }).resume(); + }); +} + +function readX509File (file) { + return fs.readFileSync(path.resolve(file), 'utf8'); +} + +async function getCertIdCn (certs, idOrCn) { + const list = await certs.ls(); + const thecert = list.filter((cert) => { + return cert.uid === idOrCn || cert.cn === idOrCn; + })[0]; + + if (!thecert) { + error(`No certificate found by id or cn "${idOrCn}"`); + return exit(1); + } + + return thecert; +} diff --git a/lib/alias.js b/lib/alias.js index 5f29265..0459ba3 100644 --- a/lib/alias.js +++ b/lib/alias.js @@ -324,48 +324,4 @@ export default class Alias extends Now { } }); } - - createCert (domain) { - return this.retry(async (bail, attempt) => { - if (this._debug) console.time(`> [debug] /now/certs #${attempt}`); - const res = await this._fetch('/now/certs', { - method: 'POST', - body: { - domains: [domain] - } - }); - - if (304 === res.status) { - console.log('> Certificate already issued.'); - return; - } - - const body = await res.json(); - if (this._debug) console.timeEnd(`> [debug] /now/certs #${attempt}`); - - if (body.error) { - const { code } = body.error; - - if ('verification_failed' === code) { - const err = new Error('The certificate issuer failed to verify ownership of the domain. ' + - 'This likely has to do with DNS propagation and caching issues. Please retry later!'); - err.userError = true; - // retry - throw err; - } else if ('rate_limited' === code) { - const err = new Error(body.error.message); - err.userError = true; - // dont retry - return bail(err); - } - - throw new Error(body.error.message); - } - - if (200 !== res.status && 304 !== res.status) { - throw new Error('Unhandled error'); - } - }, { retries: 5, minTimeout: 30000, maxTimeout: 90000 }); - } - } diff --git a/lib/certs.js b/lib/certs.js new file mode 100644 index 0000000..7d25bc8 --- /dev/null +++ b/lib/certs.js @@ -0,0 +1,82 @@ +import Now from '../lib'; + +export default class Certs extends Now { + + ls () { + return this.retry(async (bail, attempt) => { + if (this._debug) console.time(`> [debug] #${attempt} GET now/certs`); + const res = await this._fetch('/now/certs'); + if (this._debug) console.timeEnd(`> [debug] #${attempt} GET now/certs`); + const body = await res.json(); + return body.certs; + }); + } + + create (cn) { + return this.createCert(cn); + } + + renew (cn) { + return this.createCert(cn, { renew: true }); + } + + replace (cn, crt, key, ca) { + return this.retry(async (bail, attempt) => { + if (this._debug) console.time(`> [debug] #${attempt} PUT now/certs`); + const res = await this._fetch('/now/certs', { + method: 'PUT', + body: { + domains: [cn], + ca: ca, + cert: crt, + key: key + } + }); + if (this._debug) console.timeEnd(`> [debug] #${attempt} PUT now/certs`); + + if (403 === res.status) { + return bail(new Error('Unauthorized')); + } + + const body = await res.json(); + + if (res.status !== 200) { + if (404 === res.status || 400 === res.status) { + const err = new Error(body.error.message); + err.userError = true; + return bail(err); + } else { + throw new Error(body.error.message); + } + } + + return body; + }); + } + + delete (cn) { + return this.retry(async (bail, attempt) => { + if (this._debug) console.time(`> [debug] #${attempt} DELETE now/certs/${cn}`); + const res = await this._fetch(`/now/certs/${cn}`, { method: 'DELETE' }); + if (this._debug) console.timeEnd(`> [debug] #${attempt} DELETE now/certs/${cn}`); + + if (403 === res.status) { + return bail(new Error('Unauthorized')); + } + + const body = await res.json(); + + if (res.status !== 200) { + if (404 === res.status || 400 === res.status) { + const err = new Error(body.error.message); + err.userError = true; + return bail(err); + } else { + throw new Error(body.error.message); + } + } + + return body; + }); + } +} diff --git a/lib/index.js b/lib/index.js index efa93b2..f75c869 100644 --- a/lib/index.js +++ b/lib/index.js @@ -440,6 +440,71 @@ export default class Now extends EventEmitter { }); } + createCert (domain, { renew } = {}) { + return this.retry(async (bail, attempt) => { + if (this._debug) console.time(`> [debug] /now/certs #${attempt}`); + const res = await this._fetch('/now/certs', { + method: 'POST', + body: { + domains: [domain], + renew + } + }); + + if (304 === res.status) { + console.log('> Certificate already issued.'); + return; + } + + const body = await res.json(); + if (this._debug) console.timeEnd(`> [debug] /now/certs #${attempt}`); + + if (body.error) { + const { code } = body.error; + + if ('verification_failed' === code) { + const err = new Error('The certificate issuer failed to verify ownership of the domain. ' + + 'This likely has to do with DNS propagation and caching issues. Please retry later!'); + err.userError = true; + // retry + throw err; + } else if ('rate_limited' === code) { + const err = new Error(body.error.message); + err.userError = true; + // dont retry + return bail(err); + } + + throw new Error(body.error.message); + } + + if (200 !== res.status && 304 !== res.status) { + throw new Error('Unhandled error'); + } + return body; + }, { retries: 5, minTimeout: 30000, maxTimeout: 90000 }); + } + + deleteCert (domain) { + return this.retry(async (bail, attempt) => { + if (this._debug) console.time(`> [debug] /now/certs #${attempt}`); + const res = await this._fetch(`/now/certs/${domain}`, { + method: 'DELETE' + }); + + if (200 !== res.status) { + const err = new Error(body.error.message); + + err.userError = false; + if (400 === res.status || 404 === res.status) { + return bail(err); + } else { + throw err; + } + } + }); + } + async remove (deploymentId, { hard }) { const data = { deploymentId, hard };