You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

799 lines
23 KiB

// 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 }
);
}
};