diff --git a/bin/now b/bin/now index 67e57a8..e723245 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', 'ln']); -const aliases = new Map([['ls', 'list'], ['rm', 'remove'], ['ln', 'alias']]); +const commands = new Set([defaultCommand, 'list', 'ls', 'rm', 'remove', 'alias', 'aliases', 'ln', 'domain', 'domains']); +const aliases = new Map([['ls', 'list'], ['rm', 'remove'], ['ln', 'alias'], ['aliases', 'alias'], ['domain', 'domains']]); let cmd = argv._[0]; let args = []; diff --git a/bin/now-alias b/bin/now-alias index d792425..d5ac19f 100755 --- a/bin/now-alias +++ b/bin/now-alias @@ -107,42 +107,35 @@ async function run (token) { switch (subcommand) { case 'list': case 'ls': - const list = await alias.list(); - const urls = new Map(list.map(l => [l.uid, l.url])); - - const target = null != args[0] ? String(args[0]) : null; - const aliases = await alias.ls(target); - const byTarget = new Map(); - if (target) { - byTarget.set(target, aliases); - } else { - aliases.forEach((_alias) => { - const _aliases = byTarget.get(_alias.deploymentId) || []; - byTarget.set(_alias.deploymentId, _aliases.concat(_alias)); - }); + if (0 !== args.length) { + error('Invalid number of arguments'); + return exit(1); } - const sorted = await sort([...byTarget]); - + const list = await alias.list(); + const urls = new Map(list.map(l => [l.uid, l.url])); + const aliases = await alias.ls(); + aliases.sort((a, b) => new Date(b.created) - new Date(a.created)); const current = new Date(); - const text = sorted.map(([target, _aliases]) => { - return table(_aliases.map((_alias) => { - const _url = chalk.underline(`https://${_alias.alias}`); - const _sourceUrl = urls.get(target) - ? chalk.underline(`https://${urls.get(target)}`) - : chalk.gray(''); - - const time = chalk.gray(ms(current - new Date(_alias.created)) + ' ago'); - return [ - // we default to `''` because some early aliases didn't - // have an uid associated - null == _alias.uid ? '' : _alias.uid, - _sourceUrl, - _url, - time - ]; - }), { align: ['l', 'r', 'l'], hsep: ' '.repeat(3) }); - }).join('\n\n'); + + const text = table(aliases.map((_alias) => { + const _url = chalk.underline(`https://${_alias.alias}`); + const target = _alias.deploymentId; + const _sourceUrl = urls.get(target) + ? chalk.underline(`https://${urls.get(target)}`) + : chalk.gray(''); + + const time = chalk.gray(ms(current - new Date(_alias.created)) + ' ago'); + return [ + '', + // we default to `''` because some early aliases didn't + // have an uid associated + null == _alias.uid ? '' : _alias.uid, + _sourceUrl, + _url, + time + ]; + }), { align: ['l', 'r', 'l'], hsep: ' '.repeat(3) }); if (text) console.log('\n' + text + '\n'); break; @@ -156,6 +149,11 @@ async function run (token) { throw err; } + if (1 !== args.length) { + error('Invalid number of arguments'); + return exit(1); + } + const _aliases = await alias.ls(); const _alias = findAlias(_target, _aliases); @@ -166,7 +164,7 @@ async function run (token) { } try { - const confirmation = (await readConfirmation(alias, _alias)).toLowerCase(); + const confirmation = (await readConfirmation(alias, _alias, _aliases)).toLowerCase(); if ('y' !== confirmation && 'yes' !== confirmation) { console.log('\n> Aborted'); process.exit(0); @@ -187,7 +185,7 @@ async function run (token) { case 'set': if (2 !== args.length) { error('Invalid number of arguments'); - return; + return exit(1); } await alias.set(String(args[0]), String(args[1])); break; @@ -209,36 +207,13 @@ async function run (token) { alias.close(); } -async function sort (aliases) { - let pkg; - try { - const json = await fs.readFile('package.json'); - pkg = JSON.parse(json); - } catch (err) { - pkg = {}; - } - - return aliases - .map(([target, _aliases]) => { - _aliases = _aliases.slice().sort((a, b) => { - return b.created - a.created; - }); - return [target, _aliases]; - }) - .sort(([targetA, aliasesA], [targetB, aliasesB]) => { - if (pkg.name === targetA) return -1; - if (pkg.name === targetB) return 1; - return aliasesB[0].created - aliasesA[0].created; - }); -} - function indent (text, n) { return text.split('\n').map((l) => ' '.repeat(n) + l).join('\n'); } -async function readConfirmation (alias, _alias) { - const list = await alias.list(); - const urls = new Map(list.map(l => [l.uid, l.url])); +async function readConfirmation (alias, _alias, list) { + const deploymentsList = await alias.list(); + const urls = new Map(deploymentsList.map(l => [l.uid, l.url])); return new Promise((resolve, reject) => { const time = chalk.gray(ms(new Date() - new Date(_alias.created)) + ' ago'); diff --git a/bin/now-deploy b/bin/now-deploy index 8e4debc..22174ff 100755 --- a/bin/now-deploy +++ b/bin/now-deploy @@ -35,7 +35,8 @@ const help = () => { deploy [path] performs a deployment ${chalk.bold('(default)')} ls | list [app] list deployments rm | remove [id] remove a deployment - ln | alias [id] [url] configures an alias / domain + ln | alias [id] [url] configures aliases for deployments + domains [name] manages your domain names help [cmd] displays complete help for [cmd] ${chalk.dim('Options:')} diff --git a/bin/now-domains b/bin/now-domains new file mode 100755 index 0000000..e989c94 --- /dev/null +++ b/bin/now-domains @@ -0,0 +1,244 @@ +#!/usr/bin/env node +import chalk from 'chalk'; +import minimist from 'minimist'; +import table from 'text-table'; +import ms from 'ms'; +import login from '../lib/login'; +import * as cfg from '../lib/cfg'; +import { error } from '../lib/error'; +import toHost from '../lib/to-host'; +import NowDomains from '../lib/domains'; + +const argv = minimist(process.argv.slice(2), { + boolean: ['help', 'debug'], + alias: { + help: 'h', + debug: 'd' + } +}); +const subcommand = argv._[0]; + +// options +const help = () => { + console.log(` + ${chalk.bold('𝚫 now domains')} + + ${chalk.dim('Options:')} + + -h, --help output usage information + -d, --debug debug mode [off] + + ${chalk.dim('Examples:')} + + ${chalk.gray('–')} Lists all your domains: + + ${chalk.cyan('$ now domains ls')} + + ${chalk.gray('–')} Adds a domain name: + + ${chalk.cyan(`$ now domains add ${chalk.underline('my-app.com')}`)} + + Make sure the domain's DNS nameservers are at least 2 of these: + + ${chalk.gray('–')} ${chalk.underline('california.zeit.world')} ${chalk.dim('173.255.215.107')} + ${chalk.gray('–')} ${chalk.underline('london.zeit.world')} ${chalk.dim('178.62.47.76')} + ${chalk.gray('–')} ${chalk.underline('newark.zeit.world')} ${chalk.dim('173.255.231.87')} + ${chalk.gray('–')} ${chalk.underline('amsterdam.zeit.world')} ${chalk.dim('188.226.197.55')} + ${chalk.gray('–')} ${chalk.underline('dallas.zeit.world')} ${chalk.dim('173.192.101.194')} + ${chalk.gray('–')} ${chalk.underline('paris.zeit.world')} ${chalk.dim('37.123.115.172')} + ${chalk.gray('–')} ${chalk.underline('singapore.zeit.world')} ${chalk.dim('119.81.97.170')} + ${chalk.gray('–')} ${chalk.underline('sydney.zeit.world')} ${chalk.dim('52.64.171.200')} + ${chalk.gray('–')} ${chalk.underline('frankfurt.zeit.world')} ${chalk.dim('91.109.245.139')} + ${chalk.gray('–')} ${chalk.underline('iowa.zeit.world')} ${chalk.dim('23.236.59.22')} + + ${chalk.yellow('NOTE:')} running ${chalk.dim('`now alias`')} will automatically register your domain + if it's configured with these nameservers (no need to ${chalk.dim('`domain add`')}). + + For more details head to ${chalk.underline('https://zeit.world')}. + + ${chalk.gray('–')} Removing a domain: + + ${chalk.cyan('$ now domain rm my-app.com')} + + or + + ${chalk.cyan('$ now domain rm domainId')} + + To get the list of domain ids, use ${chalk.dim('`now domains ls`')}. +`); +}; + +// options +const debug = argv.debug; +const apiUrl = argv.url || 'https://api.zeit.co'; + +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(config.token || login(apiUrl)) + .then(async (token) => { + try { + await run(token); + } catch (err) { + if (err.userError) { + error(err.message); + } else { + error(`Unknown error: ${err.stack}`); + } + exit(1); + } + }) + .catch((e) => { + error(`Authentication error – ${e.message}`); + exit(1); + }); +} + +async function run (token) { + const domain = new NowDomains(apiUrl, token, { debug }); + const args = argv._.slice(1); + + switch (subcommand) { + case 'ls': + case 'list': + if (0 !== args.length) { + error('Invalid number of arguments'); + return exit(1); + } + + const domains = await domain.ls(); + domains.sort((a, b) => new Date(b.created) - new Date(a.created)); + const current = new Date(); + const out = table(domains.map((domain) => { + const url = chalk.underline(`https://${domain.name}`); + const time = chalk.gray(ms(current - new Date(domain.created)) + ' ago'); + return [ + '', + domain.uid, + url, + time + ]; + }), { align: ['l', 'r', 'l'], hsep: ' '.repeat(3) }); + + if (out) console.log('\n' + out + '\n'); + break; + + case 'rm': + case 'remove': + if (1 !== args.length) { + error('Invalid number of arguments'); + return exit(1); + } + + const _target = String(args[0]); + if (!_target) { + const err = new Error('No domain specified'); + err.userError = true; + throw err; + } + + const _domains = await domain.ls(); + const _domain = findDomain(_target, _domains); + + if (!_domain) { + const err = new Error(`Domain not found by "${_target}". Run ${chalk.dim('`now domains ls`')} to see your domains.`); + err.userError = true; + throw err; + } + + try { + const confirmation = (await readConfirmation(domain, _domain, _domains)).toLowerCase(); + if ('y' !== confirmation && 'yes' !== confirmation) { + console.log('\n> Aborted'); + process.exit(0); + } + + const start = new Date(); + await domain.rm(_domain.name); + const elapsed = ms(new Date() - start); + console.log(`${chalk.cyan('> Success!')} Domain ${chalk.bold(_domain.uid)} removed [${elapsed}]`); + } catch (err) { + error(err); + exit(1); + } + break; + + case 'add': + case 'set': + if (1 !== args.length) { + error('Invalid number of arguments'); + return exit(1); + } + + const start = new Date(); + const name = String(args[0]); + const { uid, created } = await domain.add(name); + const elapsed = ms(new Date() - start); + if (created) { + console.log(`${chalk.cyan('> Success!')} Domain ${chalk.bold(chalk.underline(name))} ${chalk.dim(`(${uid})`)} added [${elapsed}]`); + } else { + console.log(`${chalk.cyan('> Success!')} Domain ${chalk.bold(chalk.underline(name))} ${chalk.dim(`(${uid})`)} already exists [${elapsed}]`); + } + break; + + default: + error('Please specify a valid subcommand: ls | add | rm'); + help(); + exit(1); + } + + domain.close(); +} + +function indent (text, n) { + return text.split('\n').map((l) => ' '.repeat(n) + l).join('\n'); +} + +async function readConfirmation (domain, _domain, list) { + const urls = new Map(list.map(l => [l.uid, l.url])); + + return new Promise((resolve, reject) => { + const time = chalk.gray(ms(new Date() - new Date(_domain.created)) + ' ago'); + const tbl = table( + [[_domain.uid, chalk.underline(`https://${_domain.name}`), time]], + { align: ['l', 'r', 'l'], hsep: ' '.repeat(6) } + ); + + process.stdout.write('> The following deployment will be removed permanently\n'); + 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(d.toString().trim()); + }).resume(); + }); +} + +function findDomain (val, list) { + return list.find((d) => { + if (d.uid === val) { + if (debug) console.log(`> [debug] matched domain ${d.uid} by uid`); + return true; + } + + // match prefix + if (d.name === toHost(val)) { + if (debug) console.log(`> [debug] matched domain ${d.uid} by name ${d.name}`); + return true; + } + + return false; + }); +} diff --git a/bin/now-remove b/bin/now-remove index 0c7f447..662f003 100755 --- a/bin/now-remove +++ b/bin/now-remove @@ -51,11 +51,11 @@ const hard = argv.hard || false; const config = cfg.read(); -function readConfirmation (app, aliases) { +function readConfirmation (depl, aliases) { return new Promise((resolve, reject) => { - const time = chalk.gray(ms(new Date() - app.created) + ' ago'); + const time = chalk.gray(ms(new Date() - depl.created) + ' ago'); const tbl = table( - [[app.uid, chalk.underline(`https://${app.url}`), time]], + [[depl.uid, chalk.underline(`https://${depl.url}`), time]], { align: ['l', 'r', 'l'], hsep: ' '.repeat(6) } ); @@ -95,12 +95,15 @@ async function remove (token) { const now = new Now(apiUrl, token, { debug }); const deployments = await now.list(); - const app = deployments.find((d) => d.uid === deploymentId); - + const depl = deployments.find((d) => d.uid === deploymentId); + if (null != deploymentId && !depl) { + error(`Could not find a deployment by ${chalk.bold(`"${deploymentId}"`)}. Run ${chalk.dim(`\`now ls\``)} to list.`); + return process.exit(1); + } const aliases = await now.listAliases(app.uid); try { - const confirmation = (await readConfirmation(app, aliases)).toLowerCase(); + const confirmation = (await readConfirmation(depl, aliases)).toLowerCase(); if ('y' !== confirmation && 'yes' !== confirmation) { console.log('\n> Aborted'); process.exit(0); diff --git a/lib/agent.js b/lib/agent.js index 60baa13..8eb1dbd 100644 --- a/lib/agent.js +++ b/lib/agent.js @@ -14,13 +14,14 @@ import { parse } from 'url'; */ export default class Agent { - constructor (url, { debug } = {}) { + constructor (url, { tls = true, debug } = {}) { this._url = url; - const { protocol, host } = parse(url); - this._protocol = protocol; - this._host = host; + const parsed = parse(url); + this._host = parsed.hostname; + this._port = parsed.port; + this._protocol = parsed.protocol; this._debug = debug; - this._initAgent(); + if (tls) this._initAgent(); } _initAgent () { @@ -28,7 +29,7 @@ export default class Agent { this._agent = http2.createAgent({ host: this._host, - port: 443 + port: this._port || 443 }).once('error', (err) => this._onError(err)); } @@ -48,7 +49,9 @@ export default class Agent { } const { body } = opts; - opts.agent = this._agent; + if (this._agent) { + opts.agent = this._agent; + } if (body && 'object' === typeof body && 'function' !== typeof body.pipe) { opts.headers['Content-Type'] = 'application/json'; @@ -64,6 +67,8 @@ export default class Agent { close () { if (this._debug) console.log('> [debug] closing agent'); - if (this._agent) return this._agent.close(); + if (this._agent) { + this._agent.close(); + } } } diff --git a/lib/alias.js b/lib/alias.js index 4b6078d..b988f7d 100644 --- a/lib/alias.js +++ b/lib/alias.js @@ -1,7 +1,12 @@ -import dns from 'dns'; -import Now from '../lib'; +import Now from './'; import toHost from './to-host'; import chalk from 'chalk'; +import isZeitWorld from './is-zeit-world'; +import _domainRegex from 'domain-regex'; +import { DOMAIN_VERIFICATION_ERROR } from './errors'; +import { resolve4 } from './dns'; + +const domainRegex = _domainRegex(); export default class Alias extends Now { @@ -67,8 +72,15 @@ export default class Alias extends Now { } async set (deployment, alias) { + // make alias lowercase alias = alias.toLowerCase(); + // trim leading and trailing dots + // for example: `google.com.` => `google.com` + alias = alias + .replace(/^\.+/, '') + .replace(/\.+$/, ''); + const depl = await this.findDeployment(deployment); if (!depl) { const err = new Error(`Deployment not found by "${deployment}". Run ${chalk.dim('`now ls`')} to see your deployments.`); @@ -84,16 +96,61 @@ export default class Alias extends Now { alias = toHost(alias); } + if (!domainRegex.test(alias)) { + const err = new Error(`Invalid alias "${alias}"`); + err.userError = true; + throw err; + } + if (!/\.now\.sh$/.test(alias)) { console.log(`> ${chalk.bold(chalk.underline(alias))} is a custom domain.`); - console.log(`> Verifying that ${chalk.bold(chalk.underline(alias))} has a ${chalk.cyan('`CNAME`')} or ${chalk.cyan('`ALIAS`')} record pointing to ${chalk.bold(chalk.underline('alias.zeit.co'))}.`); - await this.verifyOwnership(alias); + console.log(`> Verifying the DNS settings for ${chalk.bold(chalk.underline(alias))} (see ${chalk.underline('https://zeit.world')} for help)`); + + try { + await this.verifyOwnership(alias); + } catch (err) { + if (err.userError) { + // a user error would imply that verification failed + // in which case we attempt to correct the dns + // configuration (if we can!) + try { + const { domain, nameservers } = await this.getNameservers(alias); + if (this._debug) console.log(`> [debug] Found domain ${domain} and nameservers ${nameservers}`); + if (isZeitWorld(nameservers)) { + console.log(`> Detected ${chalk.bold(chalk.underline('zeit.world'))} nameservers! Configuring records.`); + const record = alias.substr(0, alias.length - domain.length); + await this.setupRecord(domain, record); + this.recordSetup = true; + console.log('> DNS Configured! Verifying propagation…'); + + try { + await this.retry(() => this.verifyOwnership(alias), { retries: 10, maxTimeout: 3000 }); + } catch (err2) { + const e = new Error('> We configured the DNS settings for your alias, but we were unable to ' + + 'verify that they\'ve propagated. Please try the alias again later.'); + e.userError = true; + throw e; + } + } else { + console.log(`> Resolved IP: ${err.ip ? `${chalk.underline(err.ip)} (unknown)` : chalk.dim('none')}`); + console.log(`> Nameservers: ${nameservers && nameservers.length ? nameservers.map(ns => chalk.underline(ns)).join(', ') : chalk.dim('none')}`); + throw err; + } + } catch (e) { + if (e.userError) throw e; + throw err; + } + } else { + throw err; + } + } + console.log(`> Verification ${chalk.bold('OK')}!`); } const { created, uid } = await this.createAlias(depl, alias); if (created) { - console.log(`${chalk.cyan('> Success!')} Alias created ${chalk.dim(`(${uid})`)}: ${chalk.bold(`https://${depl.url}`)} ${chalk.dim(`(${depl.uid})`)} now points to ${chalk.bold(chalk.underline(`https://${alias}`))}`); + console.log(`${chalk.cyan('> Success!')} Alias created ${chalk.dim(`(${uid})`)}: ${chalk.bold(chalk.underline(`https://${alias}`))} now points to ${chalk.bold(`https://${depl.url}`)} ${chalk.dim(`(${depl.uid})`)}`); } else { console.log(`${chalk.cyan('> Success!')} Alias already exists ${chalk.dim(`(${uid})`)}.`); } @@ -142,7 +199,15 @@ export default class Alias extends Now { if ('cert_missing' === code) { console.log(`> Provisioning certificate for ${chalk.underline(chalk.bold(alias))}`); - await this.createCert(alias); + + try { + await this.createCert(alias); + } catch (err) { + // we bail to avoid retrying the whole process + // of aliasing which would involve too many + // retries on certificate provisioning + return bail(err); + } // try again, but now having provisioned the certificate return this.createAlias(depl, alias); @@ -160,6 +225,43 @@ export default class Alias extends Now { }); } + async setupRecord (domain, name) { + await this.setupDomain(domain); + + // lean up trailing and leading dots + name = name.replace(/^\./, ''); + name = name.replace(/\.$/, ''); + domain = domain.replace(/^\./, ''); + domain = domain.replace(/\.$/, ''); + + if (this._debug) console.log(`> [debug] Setting up record "${name}" for "${domain}"`); + const type = '' === name ? 'ALIAS' : 'CNAME'; + return this.retry(async (bail, attempt) => { + if (this._debug) console.time(`> [debug] /domains/${domain}/records #${attempt}`); + const res = await this._fetch(`/domains/${domain}/records`, { + method: 'POST', + body: { + type, + name: '' === name ? name : '*', + value: 'alias.zeit.co' + } + }); + if (this._debug) console.timeEnd(`> [debug] /domains/${domain}/records #${attempt}`); + + if (403 === res.status) { + return bail(new Error('Unauthorized')); + } + + const body = await res.json(); + + if (200 !== res.status) { + throw new Error(body.error.message); + } + + return body; + }); + } + verifyOwnership (domain) { return this.retry(async (bail, attempt) => { const targets = await resolve4('alias.zeit.co'); @@ -168,16 +270,28 @@ export default class Alias extends Now { return bail(new Error('Unable to resolve alias.zeit.co')); } - const ips = await resolve4(domain); + let ips = []; + try { + ips = await resolve4(domain); + } catch (err) { + if ('ENODATA' === err.code || 'ENOTFOUND' === err.code) { + // not errors per se, just absence of records + if (this._debug) console.log(`> [debug] No records found for "${domain}"`); + } else { + throw err; + } + } + if (!ips.length) { - const err = new Error('The domain ${domain} A record in the DNS configuration is not returning any IPs.'); + const err = new Error(DOMAIN_VERIFICATION_ERROR); err.userError = true; return bail(err); } for (const ip of ips) { if (!~targets.indexOf(ip)) { - const err = new Error(`The domain ${domain} has an A record ${chalk.bold(ip)} that doesn\'t resolve to ${chalk.bold(chalk.underline('alias.zeit.co'))}. Please check your DNS settings.`); + const err = new Error(`The domain ${domain} has an A record ${chalk.bold(ip)} that doesn\'t resolve to ${chalk.bold(chalk.underline('alias.zeit.co'))}.\n> ` + DOMAIN_VERIFICATION_ERROR); + err.ip = ip; err.userError = true; return bail(err); } @@ -207,8 +321,15 @@ export default class Alias extends Now { const { code } = body.error; if ('verification_failed' === code) { - const err = new Error(`We couldn't verify ownership of the domain ${domain}. Make sure the appropriate \`ALIAS\` or \`CNAME\` records are configured and pointing to ${chalk.bold('alias.zeit.co')}.`); + 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); } @@ -218,20 +339,7 @@ export default class Alias extends Now { if (200 !== res.status && 304 !== res.status) { throw new Error('Unhandled error'); } - }); + }, { retries: 5, minTimeout: 30000, maxTimeout: 90000 }); } - retry (fn) { - return retry(fn, { retries: 5, randomize: true, onRetry: this._onRetry }); - } - -} - -function resolve4 (host) { - return new Promise((resolve, reject) => { - return dns.resolve4(host, (err, answer) => { - if (err) return reject(err); - resolve(answer); - }); - }); } diff --git a/lib/dns.js b/lib/dns.js new file mode 100644 index 0000000..11fb4ce --- /dev/null +++ b/lib/dns.js @@ -0,0 +1,10 @@ +import dns from 'dns'; + +export function resolve4 (host) { + return new Promise((resolve, reject) => { + return dns.resolve4(host, (err, answer) => { + if (err) return reject(err); + resolve(answer); + }); + }); +} diff --git a/lib/domains.js b/lib/domains.js new file mode 100644 index 0000000..0490d60 --- /dev/null +++ b/lib/domains.js @@ -0,0 +1,68 @@ +import Now from '../lib'; +import isZeitWorld from './is-zeit-world'; +import _domainRegex from 'domain-regex'; +import chalk from 'chalk'; +import { DNS_VERIFICATION_ERROR } from './errors'; + +const domainRegex = _domainRegex(); + +export default class Domains extends Now { + + async ls () { + return this.retry(async (bail, attempt) => { + if (this._debug) console.time(`> [debug] #${attempt} GET /domains`); + const res = await this._fetch('/domains'); + if (this._debug) console.timeEnd(`> [debug] #${attempt} GET /domains`); + const body = await res.json(); + return body.domains; + }); + } + + async rm (name) { + return this.retry(async (bail, attempt) => { + if (this._debug) console.time(`> [debug] #${attempt} DELETE /domains/${name}`); + const res = await this._fetch(`/domains/${name}`, { method: 'DELETE' }); + if (this._debug) console.timeEnd(`> [debug] #${attempt} DELETE /domains/${name}`); + + if (403 === res.status) { + return bail(new Error('Unauthorized')); + } + + if (res.status !== 200) { + const body = await res.json(); + throw new Error(body.error.message); + } + }); + } + + async add (domain) { + if (!domainRegex.test(domain)) { + const err = new Error(`The supplied value ${chalk.bold(`"${domain}"`)} is not a valid domain.`); + err.userError = true; + throw err; + } + + let ns; + + try { + console.log('> Verifying nameservers…'); + const res = await this.getNameservers(domain); + ns = res.nameservers; + } catch (err) { + const err2 = new Error(`Unable to fetch nameservers for ${chalk.underline(chalk.bold(domain))}.`); + err2.userError = true; + throw err2; + } + + if (isZeitWorld(ns)) { + console.log(`> Verification ${chalk.bold('OK')}!`); + return this.setupDomain(domain); + } else { + if (this._debug) console.log(`> [debug] Supplied domain "${domain}" has non-zeit nameservers`); + const err3 = new Error(DNS_VERIFICATION_ERROR); + err3.userError = true; + throw err3; + } + } + +} diff --git a/lib/errors.js b/lib/errors.js new file mode 100644 index 0000000..646b0c4 --- /dev/null +++ b/lib/errors.js @@ -0,0 +1,11 @@ +import chalk from 'chalk'; + +export const DNS_VERIFICATION_ERROR = `Please make sure that your nameservers point to ${chalk.underline('zeit.world')}. +> Examples: (full list at ${chalk.underline('https://zeit.world')}) +> ${chalk.gray('-')} ${chalk.underline('california.zeit.world')} ${chalk.dim('173.255.215.107')} +> ${chalk.gray('-')} ${chalk.underline('newark.zeit.world')} ${chalk.dim('173.255.231.87')} +> ${chalk.gray('-')} ${chalk.underline('london.zeit.world')} ${chalk.dim('178.62.47.76')} +> ${chalk.gray('-')} ${chalk.underline('singapore.zeit.world')} ${chalk.dim('119.81.97.170')}`; + +export const DOMAIN_VERIFICATION_ERROR = DNS_VERIFICATION_ERROR + +`\n> Alternatively, ensure it resolves to ${chalk.underline('alias.zeit.co')} via ${chalk.dim('CNAME')} / ${chalk.dim('ALIAS')}.`; diff --git a/lib/index.js b/lib/index.js index 6d6fa00..e0acd72 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6,7 +6,7 @@ import hash from './hash'; import retry from 'async-retry'; import Agent from './agent'; import EventEmitter from 'events'; -import { basename, resolve } from 'path'; +import { basename, resolve as resolvePath } from 'path'; import { stat, readFile } from 'fs-promise'; import resumer from 'resumer'; import splitArray from 'split-array'; @@ -40,7 +40,7 @@ export default class Now extends EventEmitter { } let pkg; try { - pkg = await readFile(resolve(path, 'package.json')); + pkg = await readFile(resolvePath(path, 'package.json')); pkg = JSON.parse(pkg); } catch (err) { const e = Error(`Failed to read JSON in "${path}/package.json"`); @@ -112,7 +112,7 @@ export default class Now extends EventEmitter { } return res.json(); - }, { retries: 3, minTimeout: 2500, onRetry: this._onRetry }); + }); // we report about files whose sizes are too big let missingVersion = false; @@ -199,7 +199,7 @@ export default class Now extends EventEmitter { } this.emit('upload', file); - }, { retries: 5, randomize: true, onRetry: this._onRetry }))) + }, { retries: 3, randomize: true, onRetry: this._onRetry }))) .then(() => parts.length ? uploadChunk() : this.emit('complete')) .catch((err) => this.emit('error', err)); }; @@ -243,6 +243,72 @@ export default class Now extends EventEmitter { }); } + getNameservers (domain, { fallback = false } = {}) { + try { + return this.retry(async (bail, attempt) => { + if (this._debug) console.time(`> [debug] #${attempt} GET /whois-ns${fallback ? '-fallback' : ''}`); + const res = await this._fetch(`/whois-ns${fallback ? '-fallback' : ''}?domain=${encodeURIComponent(domain)}`); + if (this._debug) console.timeEnd(`> [debug] #${attempt} GET /whois-ns${fallback ? '-fallback' : ''}`); + const body = await res.json(); + if (200 === res.status) { + if (!body.nameservers && !fallback) { + // if the nameservers are `null` it's likely + // that our whois service failed to parse it + return this.getNameservers(domain, { fallback: true }); + } + + return body; + } else { + throw new Error(`Whois error (${res.status}): ${body.error.message}`); + } + }); + } catch (err) { + if (fallback) throw err; + return this.getNameservers(domain, { fallback: true }); + } + } + + // _ensures_ the domain is setup (idempotent) + setupDomain (name) { + return this.retry(async (bail, attempt) => { + if (this._debug) console.time(`> [debug] #${attempt} POST /domains`); + const res = await this._fetch('/domains', { + method: 'POST', + body: { name } + }); + if (this._debug) console.timeEnd(`> [debug] #${attempt} POST /domains`); + + if (403 === res.status) { + const body = await res.json(); + const code = body.error.code; + let err; + + if ('custom_domain_needs_upgrade' === code) { + err = new Error(`Custom domains are only enabled for premium accounts. Please upgrade at ${chalk.underline('https://zeit.co/account')}.`); + } else { + err = new Error(`Not authorized to access domain ${name}`); + } + + err.userError = true; + return bail(err); + } + + const body = await res.json(); + + // domain already exists + if (409 === res.status) { + if (this._debug) console.log('> [debug] Domain already exists (noop)'); + return { uid: body.error.uid }; + } + + if (200 !== res.status) { + throw new Error(body.error.message); + } + + return body; + }); + } + async remove (deploymentId, { hard }) { const data = { deploymentId, hard }; @@ -270,10 +336,10 @@ export default class Now extends EventEmitter { return true; } - retry (fn) { + retry (fn, { retries = 3, maxTimeout = Infinity } = {}) { return retry(fn, { - retries: 5, - randomize: true, + retries, + maxTimeout, onRetry: this._onRetry }); } diff --git a/lib/is-zeit-world.js b/lib/is-zeit-world.js new file mode 100644 index 0000000..e5b6702 --- /dev/null +++ b/lib/is-zeit-world.js @@ -0,0 +1,27 @@ +/** + * List of `zeit.world` nameservers + */ + +const nameservers = new Set([ + 'california.zeit.world', + 'london.zeit.world', + 'newark.zeit.world', + 'sydney.zeit.world', + 'iowa.zeit.world', + 'dallas.zeit.world', + 'amsterdam.zeit.world', + 'paris.zeit.world', + 'frankfurt.zeit.world', + 'singapore.zeit.world' +]); + +/** + * Given an array of nameservers (ie: as returned + * by `resolveNs` from Node, assert that they're + * zeit.world's. + */ +export default function isZeitWorld (ns) { + return ns.length > 1 && ns.every((host) => { + return nameservers.has(host); + }); +} diff --git a/package.json b/package.json index 7ac2524..2b7a81b 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "spdy": "3.3.3", "email-validator": "1.0.4", "email-prompt": "0.1.8", - "async-retry": "0.2.1" + "async-retry": "0.2.1", + "domain-regex": "0.0.1" }, "devDependencies": { "alpha-sort": "1.0.2", @@ -53,8 +54,8 @@ "gulp-ext": "1.0.0", "gulp-task-listing": "1.0.1", "gulp-uglify": "1.5.3", - "eslint": "2.11.0", - "eslint-plugin-promise": "1.3.1", + "eslint": "2.12.0", + "eslint-plugin-promise": "1.3.2", "estraverse-fb": "1.3.1" }, "scripts": {