// 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) { 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) { console.log('\n> 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) { aliasedDeployment = await this.findDeployment(aliasDepl.deploymentId); if ( aliasedDeployment && aliasedDeployment.scale.current > depl.scale.current && aliasedDeployment.scale.current >= aliasedDeployment.scale.min && aliasedDeployment.scale.current <= aliasedDeployment.scale.max ) { shouldScaleDown = true; console.log( `> Alias ${alias} points to ${chalk.bold(aliasedDeployment.url)} (${chalk.bold(aliasedDeployment.scale.current + ' instances')})` ); console.log( `> Scaling ${depl.url} to ${chalk.bold(aliasedDeployment.scale.current + ' instances')} atomically` ); 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 (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: aliasedDeployment.scale.min, max: aliasedDeployment.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); const canBePurchased = await domains.status(parsed.domain); const aliasParam = param(parsed.domain); stopSpinner(); if (canBePurchased) { const price = await pricePromise; info( `The domain ${aliasParam} is ${chalk.bold('available for purchase')}! ${elapsed()}` ); const confirmation = await promptBool( `Buy now for ${chalk.bold(`$${price}`)} (${chalk.bold((currentTeam && currentTeam.slug) || user.username || user.email)})?` ); eraseLines(1); if (!confirmation) { info('Aborted'); gracefulExit(); } elapsed = stamp(); stopSpinner = wait('Purchasing'); let domain; try { domain = await domains.buy(alias); } 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(alias); } catch (err) { if (this._debug) { console.log( '> [debug] Error while trying to setup the domain', err ); } } stopSpinner(); if (!domainInfo.verified) { let { tld } = publicSuffixList.parse(alias); tld = param(`.${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 } ); } };