// Packages const { readFileSync } = require('fs') const publicSuffixList = require('psl') const minimist = require('minimist') const ms = require('ms') const chalk = require('chalk') const { write: copy } = require('clipboardy') // Ours const promptBool = require('../lib/utils/input/prompt-bool') const info = require('../lib/utils/output/info') const param = require('../lib/utils/output/param') const wait = require('../lib/utils/output/wait') const success = require('../lib/utils/output/success') const uid = require('../lib/utils/output/uid') const eraseLines = require('../lib/utils/output/erase-lines') const stamp = require('../lib/utils/output/stamp') const error = require('../lib/utils/output/error') const treatBuyError = require('../lib/utils/domains/treat-buy-error') const scaleInfo = require('./scale-info') const { DOMAIN_VERIFICATION_ERROR } = require('./errors') const isZeitWorld = require('./is-zeit-world') const resolve4 = require('./dns') const toHost = require('./to-host') const exit = require('./utils/exit') const Now = require('./') const argv = minimist(process.argv.slice(2), { boolean: ['no-clipboard'], alias: { 'no-clipboard': 'C' } }) const isTTY = process.stdout.isTTY const clipboard = !argv['no-clipboard'] const domainRegex = /^((?=[a-z0-9-]{1,63}\.)(xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,63}$/ module.exports = class Alias extends Now { async ls(deployment) { if (deployment) { const target = await this.findDeployment(deployment) if (!target) { const err = new Error( `Aliases not found by "${deployment}". Run ${chalk.dim('`now alias ls`')} to see your aliases.` ) err.userError = true throw err } return this.listAliases(target.uid) } return this.listAliases() } async rm(_alias) { return this.retry(async bail => { const res = await this._fetch(`/now/aliases/${_alias.uid}`, { method: 'DELETE' }) if (res.status === 403) { return bail(new Error('Unauthorized')) } if (res.status !== 200) { const err = new Error('Deletion failed. Try again later.') throw err } }) } async findDeployment(deployment) { const list = await this.list() let key let val if (/\./.test(deployment)) { val = toHost(deployment) key = 'url' } else { val = deployment key = 'uid' } const depl = list.find(d => { if (d[key] === val) { if (this._debug) { console.log(`> [debug] matched deployment ${d.uid} by ${key} ${val}`) } return true } // Match prefix if (`${val}.now.sh` === d.url) { if (this._debug) { console.log(`> [debug] matched deployment ${d.uid} by url ${d.url}`) } return true } return false }) return depl } async updatePathBasedroutes(alias, rules, domains) { alias = await this.maybeSetUpDomain(alias, domains) return this.upsertPathAlias(alias, rules) } async upsertPathAlias(alias, rules) { return this.retry(async (bail, attempt) => { if (this._debug) { console.time(`> [debug] /now/aliases #${attempt}`) } const rulesData = this.readRulesFile(rules) const ruleCount = rulesData.rules.length const res = await this._fetch(`/now/aliases`, { method: 'POST', body: { alias, rules: rulesData.rules } }) const body = await res.json() body.ruleCount = ruleCount if (this._debug) { console.timeEnd(`> [debug] /now/aliases #${attempt}`) } // 409 conflict is returned if it already exists if (res.status === 409) { return { uid: body.error.uid } } if (res.status === 422) { return body } // No retry on authorization problems if (res.status === 403) { const code = body.error.code 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 (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) } 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) } return bail(new Error('Authorization error')) } // All other errors if (body.error) { const code = body.error.code if (code === 'cert_missing') { console.log( `> Provisioning certificate for ${chalk.underline(chalk.bold(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.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) } } return bail(new Error(body.error.message)) } // The two expected succesful cods are 200 and 304 if (res.status !== 200 && res.status !== 304) { throw new Error('Unhandled error') } 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, domains, currentTeam, user) { alias = alias.replace(/^https:\/\//i, '') if (alias.indexOf('.') === -1) { // `.now.sh` domain is implied if just the subdomain is given alias += '.now.sh' } 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 msg = `> Path alias exists with ${aliasDepl.rules.length} rule${aliasDepl.rules.length > 1 ? 's' : ''}.\n` + `> Are you sure you want to update ${alias} to be a normal alias?\n` const confirmation = await promptBool(msg, { trailing: '\n' }) if (!confirmation) { info('Aborted') return exit(1) } } catch (err) { console.log(err) } } else { console.log( `Overwriting path alias with ${aliasDepl.rules.length} rule${aliasDepl.rules.length > 1 ? 's' : ''} to be a normal alias.` ) } } let aliasedDeployment = null let shouldScaleDown = false if (aliasDepl && depl.scale) { aliasedDeployment = await this.findDeployment(aliasDepl.deploymentId) if ( aliasedDeployment && aliasedDeployment.scale && aliasedDeployment.scale.current >= depl.scale.current && (aliasedDeployment.scale.min > depl.scale.min || aliasedDeployment.scale.max > depl.scale.max) ) { shouldScaleDown = true console.log( `> Alias ${alias} points to ${chalk.bold(aliasedDeployment.url)} (${chalk.bold(aliasedDeployment.scale.current + ' instances')})` ) // Test if we need to change the scale or just update the rules console.log( `> Scaling ${depl.url} to ${chalk.bold(aliasedDeployment.scale.current + ' instances')} atomically` ) if (depl.scale.current !== aliasedDeployment.scale.current) { if (depl.scale.max < 1) { if (this._debug) { console.log( 'Updating max scale to 1 so that deployment may be unfrozen.' ) } await this.setScale(depl.uid, { min: depl.scale.min, max: Math.max(aliasedDeployment.scale.max, 1) }) } if (depl.scale.current < 1) { if (this._debug) { console.log(`> Deployment ${depl.url} is frozen, unfreezing...`) } await this.unfreeze(depl) if (this._debug) { console.log( `> Deployment is now unfrozen, scaling it to match current instance count` ) } } // Scale it to current limit if (depl.scale.current !== aliasedDeployment.scale.current) { if (this._debug) { console.log(`> Scaling deployment to match current scale.`) } await this.setScale(depl.uid, { min: aliasedDeployment.scale.current, max: aliasedDeployment.scale.current }) } await scaleInfo(this, depl.url) if (this._debug) { console.log(`> Updating scaling rules for deployment.`) } } await this.setScale(depl.uid, { min: Math.max(aliasedDeployment.scale.min, depl.scale.min), max: Math.max(aliasedDeployment.scale.max, depl.scale.max) }) } } alias = await this.maybeSetUpDomain(alias, domains, currentTeam, user) const aliasTime = Date.now() const newAlias = await this.createAlias(depl, alias) if (!newAlias) { throw new Error( `Unexpected error occurred while setting up alias: ${JSON.stringify(newAlias)}` ) } const { created, uid } = newAlias if (created) { const output = `${chalk.cyan('> Success!')} ${alias} now points to ${chalk.bold(depl.url)}! ${chalk.grey('[' + ms(Date.now() - aliasTime) + ']')}` if (isTTY && clipboard) { try { await copy(depl.url) } catch (err) { } finally { console.log(output) } } else { console.log(output) } } else { console.log( `${chalk.cyan('> Success!')} Alias already exists ${chalk.dim(`(${uid})`)}.` ) } if (aliasedDeployment && shouldScaleDown) { const scaleDown = Date.now() await this.setScale(aliasedDeployment.uid, { min: 0, max: 1 }) console.log( `> Scaled ${chalk.gray(aliasedDeployment.url)} down to 1 instance ${chalk.gray('[' + ms(Date.now() - scaleDown) + ']')}` ) } } createAlias(depl, alias) { return this.retry(async (bail, attempt) => { if (this._debug) { console.time( `> [debug] /now/deployments/${depl.uid}/aliases #${attempt}` ) } const res = await this._fetch(`/now/deployments/${depl.uid}/aliases`, { method: 'POST', body: { alias } }) const body = await res.json() if (this._debug) { console.timeEnd( `> [debug] /now/deployments/${depl.uid}/aliases #${attempt}` ) } // 409 conflict is returned if it already exists if (res.status === 409) { return { uid: body.error.uid } } // No retry on authorization problems if (res.status === 403) { const code = body.error.code 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 (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) } 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) } return bail(new Error('Authorization error')) } // All other errors if (body.error) { const code = body.error.code if (code === 'deployment_not_found') { return bail(new Error('Deployment not found')) } if (code === 'cert_missing') { console.log( `> Provisioning certificate for ${chalk.underline(chalk.bold(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) } 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) } } return bail(new Error(body.error.message)) } // The two expected succesful cods are 200 and 304 if (res.status !== 200 && res.status !== 304) { throw new Error('Unhandled error') } return body }) } async setupRecord(domain, name) { await this.setupDomain(domain) 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 (res.status === 403) { return bail(new Error('Unauthorized')) } const body = await res.json() if (res.status !== 200) { throw new Error(body.error.message) } return body }) } async maybeSetUpDomain(alias, domains, currentTeam, user) { const gracefulExit = () => { this.close() domains.close() // eslint-disable-next-line unicorn/no-process-exit process.exit() } // 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.`) let stopSpinner = wait('Fetching domain info') let elapsed = stamp() const parsed = publicSuffixList.parse(alias) const pricePromise = domains.price(parsed.domain).catch(() => { // Can be safely ignored }) const canBePurchased = await domains.status(parsed.domain) const aliasParam = param(parsed.domain) let price let period stopSpinner() if (canBePurchased) { try { const json = await pricePromise price = json.price period = json.period } catch (err) { // Can be safely ignored } } if (canBePurchased && price && period) { const periodMsg = `${period}yr${period > 1 ? 's' : ''}` info( `The domain ${aliasParam} is ${chalk.italic('available')} to buy under ${chalk.bold((currentTeam && currentTeam.slug) || user.username || user.email)}! ${elapsed()}` ) const confirmation = await promptBool( `Buy now for ${chalk.bold(`$${price}`)} (${periodMsg})?` ) eraseLines(1) if (!confirmation) { info('Aborted') gracefulExit() } elapsed = stamp() stopSpinner = wait('Purchasing') let domain try { domain = await domains.buy(parsed.domain) } catch (err) { stopSpinner() treatBuyError(err) gracefulExit() } stopSpinner() success(`Domain purchased and created ${uid(domain.uid)} ${elapsed()}`) stopSpinner = wait('Verifying nameservers') let domainInfo try { domainInfo = await this.setupDomain(parsed.domain) } catch (err) { if (this._debug) { console.log('> [debug] Error while trying to setup the domain', err) } } stopSpinner() if (!domainInfo.verified) { const tld = param(`.${parsed.tld}`) error( 'The nameservers are pending propagation. Please try again shortly' ) info( `The ${tld} servers might take some extra time to reflect changes` ) gracefulExit() } } 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')}!`) } return alias } verifyOwnership(domain) { return this.retry( async bail => { const targets = await resolve4('alias.zeit.co') if (targets.length <= 0) { return bail(new Error('Unable to resolve alias.zeit.co')) } let ips = [] try { ips = await resolve4(domain) } catch (err) { if ( err.code === 'ENODATA' || err.code === 'ESERVFAIL' || err.code === 'ENOTFOUND' ) { // Not errors per se, just absence of records if (this._debug) { console.log(`> [debug] No records found for "${domain}"`) } const err = new Error(DOMAIN_VERIFICATION_ERROR) err.userError = true return bail(err) } throw err } if (ips.length <= 0) { const err = new Error(DOMAIN_VERIFICATION_ERROR) err.userError = true return bail(err) } for (const ip of ips) { if (targets.indexOf(ip) === -1) { 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) } } }, { retries: 5 } ) } }