diff --git a/bin/now-alias.js b/bin/now-alias.js index e5245c5..d08544a 100755 --- a/bin/now-alias.js +++ b/bin/now-alias.js @@ -18,11 +18,12 @@ const exit = require('../lib/utils/exit') const logo = require('../lib/utils/output/logo') const argv = minimist(process.argv.slice(2), { - string: ['config', 'token'], + string: ['config', 'token', 'rules'], boolean: ['help', 'debug'], alias: { help: 'h', config: 'c', + rules: 'r', debug: 'd', token: 't' } @@ -37,10 +38,11 @@ const help = () => { ${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 + -h, --help Output usage information + -c ${chalk.bold.underline('FILE')}, --config=${chalk.bold.underline('FILE')} Config file + -r ${chalk.bold.underline('RULES_FILE')}, --rules=${chalk.bold.underline('RULES_FILE')} Rules file + -d, --debug Debug mode [off] + -t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline('TOKEN')} Login token ${chalk.dim('Examples:')} @@ -67,6 +69,16 @@ const help = () => { ${chalk.dim('–')} The subcommand ${chalk.dim('`set`')} is the default and can be skipped. ${chalk.dim('–')} ${chalk.dim('`http(s)://`')} in the URLs is unneeded / ignored. + ${chalk.gray('–')} Add and modify path based aliases for ${chalk.underline('zeit.ninja')}: + + ${chalk.cyan(`$ now alias ${chalk.underline('zeit.ninja')} -r ${chalk.underline('rules.json')}`)} + + Export effective routing rules: + + ${chalk.cyan(`$ now alias ls aliasId --json > ${chalk.underline('rules.json')}`)} + + ${chalk.cyan(`$ now alias ls zeit.ninja`)} + ${chalk.gray('–')} Removing an alias: ${chalk.cyan('$ now alias rm aliasId')} @@ -117,7 +129,31 @@ async function run(token) { switch (subcommand) { case 'ls': case 'list': { - if (args.length !== 0) { + if (args.length === 1) { + const list = await alias.listAliases() + const item = list.find(e => e.uid === argv._[1] || e.alias === argv._[1]) + if (!item || !item.rules) { + error(`Could not match path alias for: ${argv._[1]}`) + return exit(1) + } + + if (argv.json) { + console.log(JSON.stringify({rules: item.rules}, null, 2)) + } else { + const header = [['', 'pathname', 'method', 'dest'].map(s => chalk.dim(s))] + const text = list.length === 0 ? null : table(header.concat(item.rules.map(rule => { + return [ + '', + rule.pathname ? rule.pathname : '', + rule.method ? rule.method : '*', + rule.dest + ] + })), {align: ['l', 'l', 'l', 'l'], hsep: ' '.repeat(2), stringLength: strlen}) + + console.log(text) + } + break + } else if (args.length !== 0) { error(`Invalid number of arguments. Usage: ${chalk.cyan('`now alias ls`')}`) return exit(1) } @@ -133,7 +169,14 @@ async function run(token) { const text = list.length === 0 ? null : table(header.concat(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('') + let _sourceUrl + if (urls.get(target)) { + _sourceUrl = chalk.underline(`https://${urls.get(target)}`) + } else if (_alias.rules) { + _sourceUrl = chalk.gray(`[${_alias.rules.length} custom rule${_alias.rules.length > 1 ? 's' : ''}]`) + } else { + _sourceUrl = chalk.gray('') + } const time = chalk.gray(ms(current - new Date(_alias.created)) + ' ago') return [ @@ -199,6 +242,10 @@ async function run(token) { } case 'add': case 'set': { + if (argv.rules) { + await updatePathAlias(alias, argv._[0], argv.rules) + break + } if (args.length !== 2) { error(`Invalid number of arguments. Usage: ${chalk.cyan('`now alias set `')}`) return exit(1) @@ -212,7 +259,9 @@ async function run(token) { break } - if (argv._.length === 2) { + if (argv.rules) { + await updatePathAlias(alias, argv._[0], argv.rules) + } else if (argv._.length === 2) { await alias.set(String(argv._[0]), String(argv._[1])) } else if (argv._.length >= 3) { error('Invalid number of arguments') @@ -287,3 +336,12 @@ function findAlias(alias, list) { return _alias } + +async function updatePathAlias(alias, aliasName, rules) { + const start = new Date() + const res = await alias.updatePathBasedroutes(String(aliasName), rules) + const elapsed = ms(new Date() - start) + if (!res.error) { + console.log(`${chalk.cyan('> Success!')} ${res.ruleCount} rules configured for ${chalk.underline(res.alias)} [${elapsed}]`) + } +} diff --git a/lib/alias.js b/lib/alias.js index 69c5184..fcada1b 100644 --- a/lib/alias.js +++ b/lib/alias.js @@ -1,9 +1,11 @@ // Packages +const {readFileSync} = require('fs') const publicSuffixList = require('psl') const minimist = require('minimist') const chalk = require('chalk') // Ours +const exit = require('./utils/exit') const copy = require('./copy') const toHost = require('./to-host') const resolve4 = require('./dns') @@ -93,146 +95,153 @@ module.exports = class Alias extends Now { return depl } - async set(deployment, alias) { - // make alias lowercase - alias = alias.toLowerCase() + async updatePathBasedroutes(alias, rules) { + await this.maybeSetUpDomain(alias) - // 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.`) - err.userError = true - throw err - } + return await this.upsertPathAlias(alias, rules) + } - // evaluate the alias - if (/\./.test(alias)) { - alias = toHost(alias) - } else { + async upsertPathAlias(alias, rules) { + return this.retry(async (bail, attempt) => { if (this._debug) { - console.log(`> [debug] suffixing \`.now.sh\` to alias ${alias}`) + console.time(`> [debug] /now/aliases #${attempt}`) } - alias = `${alias}.now.sh` - } + const rulesData = this.readRulesFile(rules) + const ruleCount = rulesData.rules.length + const res = await this._fetch(`/now/aliases`, { + method: 'POST', + body: {alias, rules: rulesData.rules} + }) - if (!domainRegex.test(alias)) { - const err = new Error(`Invalid alias "${alias}"`) - err.userError = true - throw err - } + const body = await res.json() + body.ruleCount = ruleCount + if (this._debug) { + console.timeEnd(`> [debug] /now/aliases #${attempt}`) + } - if (!/\.now\.sh$/.test(alias)) { - console.log(`> ${chalk.bold(chalk.underline(alias))} is a custom domain.`) - console.log(`> Verifying the DNS settings for ${chalk.bold(chalk.underline(alias))} (see ${chalk.underline('https://zeit.world')} for help)`) + // 409 conflict is returned if it already exists + if (res.status === 409) { + return {uid: body.error.uid} + } + if (res.status === 422) { + console.log(body.error.message) + return new Error(body.error.message) + } - const _domain = publicSuffixList.parse(alias).domain - const _domainInfo = await this.getDomain(_domain) - const domainInfo = _domainInfo && !_domainInfo.error ? _domainInfo : undefined - const {domain, nameservers} = domainInfo ? {domain: _domain} : await this.getNameservers(alias) - const usingZeitWorld = domainInfo ? !domainInfo.isExternal : isZeitWorld(nameservers) - let skipDNSVerification = false + // no retry on authorization problems + if (res.status === 403) { + const code = body.error.code - if (this._debug) { - if (domainInfo) { - console.log(`> [debug] Found domain ${domain} with verified:${domainInfo.verified}`) - } else { - console.log(`> [debug] Found domain ${domain} and nameservers ${nameservers}`) + if (code === 'custom_domain_needs_upgrade') { + const err = new Error(`Custom domains are only enabled for premium accounts. Please upgrade by running ${chalk.gray('`')}${chalk.cyan('now upgrade')}${chalk.gray('`')}.`) + err.userError = true + return bail(err) } - } - if (!usingZeitWorld && domainInfo) { - if (domainInfo.verified) { - skipDNSVerification = true - } else if (domainInfo.uid) { - const {verified, created} = await this.setupDomain(domain, {isExternal: true}) - if (!(created && verified)) { - const e = new Error(`> Failed to verify the ownership of ${domain}, please refer to 'now domain --help'.`) - e.userError = true - throw e - } - console.log(`${chalk.cyan('> Success!')} Domain ${chalk.bold(chalk.underline(domain))} verified`) + if (code === 'alias_in_use') { + const err = new Error(`The alias you are trying to configure (${chalk.underline(chalk.bold(alias))}) is already in use by a different account.`) + err.userError = true + return bail(err) } - } - try { - if (!skipDNSVerification) { - await this.verifyOwnership(alias) + if (code === 'forbidden') { + const err = new Error('The domain you are trying to use as an alias is already in use by a different account.') + err.userError = true + return bail(err) } - } 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 { - if (usingZeitWorld) { - console.log(`> Detected ${chalk.bold(chalk.underline('zeit.world'))} nameservers! Configuring records.`) - const record = alias.substr(0, alias.length - domain.length) - // lean up trailing and leading dots - const _record = record - .replace(/^\./, '') - .replace(/\.$/, '') - const _domain = domain - .replace(/^\./, '') - .replace(/\.$/, '') + return bail(new Error('Authorization error')) + } - if (_record === '') { - await this.setupRecord(_domain, '*') - } + // all other errors + if (body.error) { + const code = body.error.code - await this.setupRecord(_domain, _record) + if (code === 'cert_missing') { + console.log(`> Provisioning certificate for ${chalk.underline(chalk.bold(alias))}`) - this.recordSetup = true - console.log('> DNS Configured! Verifying propagation…') + 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 { - await this.retry(() => this.verifyOwnership(alias), {retries: 10, maxTimeout: 8000}) - } 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 - } + // try again, but now having provisioned the certificate - throw err + return this.upsertPathAlias(alias, rules) + } + + if (code === 'cert_expired') { + console.log(`> Renewing certificate for ${chalk.underline(chalk.bold(alias))}`) + + try { + await this.createCert(alias, {renew: true}) + } catch (err) { + return bail(err) } - } else { - throw err } + + return bail(new Error(body.error.message)) } - if (!usingZeitWorld && !skipDNSVerification) { - if (this._debug) { - console.log(`> [debug] Trying to register a non-ZeitWorld domain ${domain} for the current user`) - } + // the two expected succesful cods are 200 and 304 + if (res.status !== 200 && res.status !== 304) { + throw new Error('Unhandled error') + } - const {uid, verified, created} = await this.setupDomain(domain, {isExternal: true}) - if (!(created && verified)) { - const e = new Error(`> Failed to verify the ownership of ${domain}, please refer to 'now domain --help'.`) - e.userError = true - throw e + return body + }) + } + + readRulesFile(rules) { + try { + const rulesJson = readFileSync(rules, 'utf8') + return JSON.parse(rulesJson) + } catch (err) { + console.error(`Reading rules file ${rules} failed: ${err}`) + } + } + + async set(deployment, alias) { + 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.`) + err.userError = true + throw err + } + + const aliasDepl = (await this.listAliases()).find(e => e.alias === alias) + if (aliasDepl && aliasDepl.rules) { + if (isTTY) { + try { + const confirmation = (await new Promise(resolve => { + process.stdout.write(`Path alias excists with ${aliasDepl.rules.length} rule${aliasDepl.rules.length > 1 ? 's' : ''}.\n`) + process.stdout.write(`Are you sure you want to update ${alias} to be a normal alias?\n`) + + process.stdin.on('data', d => { + process.stdin.pause() + resolve(d.toString().trim()) + }).resume() + })).toLowerCase() + + if (confirmation !== 'y' && confirmation !== 'yes') { + console.log('\n> Aborted') + return exit(1) + } + } catch (err) { + console.log(err) } - console.log(`${chalk.cyan('> Success!')} Domain ${chalk.bold(chalk.underline(domain))} ${chalk.dim(`(${uid})`)} added`) + } else { + console.log(`Overwriting path alias with ${aliasDepl.rules.length} rule${aliasDepl.rules.length > 1 ? 's' : ''} to be a normal alias.`) } - - console.log(`> Verification ${chalk.bold('OK')}!`) } + await this.maybeSetUpDomain(alias) + const newAlias = await this.createAlias(depl, alias) if (!newAlias) { throw new Error(`Unexpected error occurred while setting up alias: ${JSON.stringify(newAlias)}`) @@ -391,6 +400,139 @@ module.exports = class Alias extends Now { }) } + async maybeSetUpDomain(alias) { + // make alias lowercase + alias = alias.toLowerCase() + + // trim leading and trailing dots + // for example: `google.com.` => `google.com` + alias = alias + .replace(/^\.+/, '') + .replace(/\.+$/, '') + // evaluate the alias + if (/\./.test(alias)) { + alias = toHost(alias) + } else { + if (this._debug) { + console.log(`> [debug] suffixing \`.now.sh\` to alias ${alias}`) + } + + alias = `${alias}.now.sh` + } + + 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 the DNS settings for ${chalk.bold(chalk.underline(alias))} (see ${chalk.underline('https://zeit.world')} for help)`) + + const _domain = publicSuffixList.parse(alias).domain + const _domainInfo = await this.getDomain(_domain) + const domainInfo = _domainInfo && !_domainInfo.error ? _domainInfo : undefined + const {domain, nameservers} = domainInfo ? {domain: _domain} : await this.getNameservers(alias) + const usingZeitWorld = domainInfo ? !domainInfo.isExternal : isZeitWorld(nameservers) + let skipDNSVerification = false + + if (this._debug) { + if (domainInfo) { + console.log(`> [debug] Found domain ${domain} with verified:${domainInfo.verified}`) + } else { + console.log(`> [debug] Found domain ${domain} and nameservers ${nameservers}`) + } + } + + if (!usingZeitWorld && domainInfo) { + if (domainInfo.verified) { + skipDNSVerification = true + } else if (domainInfo.uid) { + const {verified, created} = await this.setupDomain(domain, {isExternal: true}) + if (!(created && verified)) { + const e = new Error(`> Failed to verify the ownership of ${domain}, please refer to 'now domain --help'.`) + e.userError = true + throw e + } + console.log(`${chalk.cyan('> Success!')} Domain ${chalk.bold(chalk.underline(domain))} verified`) + } + } + + try { + if (!skipDNSVerification) { + 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 { + if (usingZeitWorld) { + console.log(`> Detected ${chalk.bold(chalk.underline('zeit.world'))} nameservers! Configuring records.`) + const record = alias.substr(0, alias.length - domain.length) + + // lean up trailing and leading dots + const _record = record + .replace(/^\./, '') + .replace(/\.$/, '') + const _domain = domain + .replace(/^\./, '') + .replace(/\.$/, '') + + if (_record === '') { + await this.setupRecord(_domain, '*') + } + + await this.setupRecord(_domain, _record) + + this.recordSetup = true + console.log('> DNS Configured! Verifying propagation…') + + try { + await this.retry(() => this.verifyOwnership(alias), {retries: 10, maxTimeout: 8000}) + } 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 + } + } + + if (!usingZeitWorld && !skipDNSVerification) { + if (this._debug) { + console.log(`> [debug] Trying to register a non-ZeitWorld domain ${domain} for the current user`) + } + + const {uid, verified, created} = await this.setupDomain(domain, {isExternal: true}) + if (!(created && verified)) { + const e = new Error(`> Failed to verify the ownership of ${domain}, please refer to 'now domain --help'.`) + e.userError = true + throw e + } + console.log(`${chalk.cyan('> Success!')} Domain ${chalk.bold(chalk.underline(domain))} ${chalk.dim(`(${uid})`)} added`) + } + + console.log(`> Verification ${chalk.bold('OK')}!`) + } + } + verifyOwnership(domain) { return this.retry(async bail => { const targets = await resolve4('alias.zeit.co')