'use strict'; exports = module.exports = { removePrivateFields, injectPrivateFields, upsert, get, del, verifyDomainConfig, wait }; const assert = require('assert'), BoxError = require('../boxerror.js'), constants = require('../constants.js'), debug = require('debug')('box:dns/namecheap'), dig = require('../dig.js'), dns = require('../dns.js'), safe = require('safetydance'), superagent = require('superagent'), sysinfo = require('../sysinfo.js'), util = require('util'), waitForDns = require('./waitfordns.js'), xml2js = require('xml2js'); const ENDPOINT = 'https://api.namecheap.com/xml.response'; function removePrivateFields(domainObject) { domainObject.config.token = constants.SECRET_PLACEHOLDER; return domainObject; } function injectPrivateFields(newConfig, currentConfig) { if (newConfig.token === constants.SECRET_PLACEHOLDER) newConfig.token = currentConfig.token; } async function getQuery(domainConfig) { assert.strictEqual(typeof domainConfig, 'object'); const ip = await sysinfo.getServerIPv4(); // only supports ipv4 return { ApiUser: domainConfig.username, ApiKey: domainConfig.token, UserName: domainConfig.username, ClientIp: ip }; } async function getZone(domainConfig, zoneName) { assert.strictEqual(typeof domainConfig, 'object'); assert.strictEqual(typeof zoneName, 'string'); const query = await getQuery(domainConfig); query.Command = 'namecheap.domains.dns.getHosts'; query.SLD = zoneName.split('.')[0]; query.TLD = zoneName.split('.')[1]; const [error, response] = await safe(superagent.get(ENDPOINT).query(query).ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); const parser = new xml2js.Parser(); const [parserError, result] = await safe(util.promisify(parser.parseString)(response.text)); if (parserError) throw new BoxError(BoxError.EXTERNAL_ERROR, parserError); const tmp = result.ApiResponse; if (tmp['$'].Status !== 'OK') { const errorMessage = safe.query(tmp, 'Errors[0].Error[0]._', 'Invalid response'); if (errorMessage === 'API Key is invalid or API access has not been enabled') throw new BoxError(BoxError.ACCESS_DENIED, errorMessage); throw new BoxError(BoxError.EXTERNAL_ERROR, errorMessage); } const host = safe.query(tmp, 'CommandResponse[0].DomainDNSGetHostsResult[0].host'); if (!host) throw new BoxError(BoxError.EXTERNAL_ERROR, `Invalid response: ${JSON.stringify(tmp)}`); if (!Array.isArray(host)) throw new BoxError(BoxError.EXTERNAL_ERROR, `host is not an array: ${JSON.stringify(tmp)}`); const hosts = host.map(h => h['$']); return hosts; } async function setZone(domainConfig, zoneName, hosts) { assert.strictEqual(typeof domainConfig, 'object'); assert.strictEqual(typeof zoneName, 'string'); assert(Array.isArray(hosts)); const query = await getQuery(domainConfig); query.Command = 'namecheap.domains.dns.setHosts'; query.SLD = zoneName.split('.')[0]; query.TLD = zoneName.split('.')[1]; // Map to query params https://www.namecheap.com/support/api/methods/domains-dns/set-hosts.aspx hosts.forEach(function (host, i) { const n = i+1; // api starts with 1 not 0 query['TTL' + n] = '300'; // keep it low query['HostName' + n] = host.HostName || host.Name; query['RecordType' + n] = host.RecordType || host.Type; query['Address' + n] = host.Address; if (host.Type === 'MX') { query['EmailType' + n] = 'MX'; if (host.MXPref) query['MXPref' + n] = host.MXPref; } }); // namecheap recommends sending as POSTDATA with > 10 records const qs = new URLSearchParams(query).toString(); const [error, response] = await safe(superagent.post(ENDPOINT).set('Content-Type', 'application/x-www-form-urlencoded').send(qs).ok(() => true)); if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, error); const parser = new xml2js.Parser(); const [parserError, result] = await safe(util.promisify(parser.parseString)(response.text)); if (parserError) throw new BoxError(BoxError.EXTERNAL_ERROR, parserError.message); const tmp = result.ApiResponse; if (tmp['$'].Status !== 'OK') { const errorMessage = safe.query(tmp, 'Errors[0].Error[0]._', 'Invalid response'); if (errorMessage === 'API Key is invalid or API access has not been enabled') throw new BoxError(BoxError.ACCESS_DENIED, errorMessage); throw new BoxError(BoxError.EXTERNAL_ERROR, errorMessage); } if (!tmp.CommandResponse[0]) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response'); if (!tmp.CommandResponse[0].DomainDNSSetHostsResult[0]) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response'); if (tmp.CommandResponse[0].DomainDNSSetHostsResult[0]['$'].IsSuccess !== 'true') throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response'); } async function upsert(domainObject, subdomain, type, values) { assert.strictEqual(typeof domainObject, 'object'); assert.strictEqual(typeof subdomain, 'string'); assert.strictEqual(typeof type, 'string'); assert(Array.isArray(values)); const domainConfig = domainObject.config; const zoneName = domainObject.zoneName; subdomain = dns.getName(domainObject, subdomain, type) || '@'; debug('upsert: %s for zone %s of type %s with values %j', subdomain, zoneName, type, values); const result = await getZone(domainConfig, zoneName); // Array to keep track of records that need to be inserted let toInsert = []; for (let i = 0; i < values.length; i++) { let curValue = values[i]; let wasUpdate = false; for (let j = 0; j < result.length; j++) { let curHost = result[j]; if (curHost.Type === type && curHost.Name === subdomain) { // Updating an already existing host wasUpdate = true; if (type === 'MX') { curHost.MXPref = curValue.split(' ')[0]; curHost.Address = curValue.split(' ')[1]; } else { curHost.Address = curValue; } } } // We don't have this host at all yet, let's push to toInsert array if (!wasUpdate) { let newRecord = { RecordType: type, HostName: subdomain, Address: curValue }; // Special case for MX records if (type === 'MX') { newRecord.MXPref = curValue.split(' ')[0]; newRecord.Address = curValue.split(' ')[1]; } toInsert.push(newRecord); } } const hosts = result.concat(toInsert); return await setZone(domainConfig, zoneName, hosts); } async function get(domainObject, subdomain, type) { assert.strictEqual(typeof domainObject, 'object'); assert.strictEqual(typeof subdomain, 'string'); assert.strictEqual(typeof type, 'string'); const domainConfig = domainObject.config; const zoneName = domainObject.zoneName; subdomain = dns.getName(domainObject, subdomain, type) || '@'; const result = await getZone(domainConfig, zoneName); // We need to filter hosts to ones with this subdomain and type const actualHosts = result.filter((host) => host.Type === type && host.Name === subdomain); const tmp = actualHosts.map(function (record) { return record.Address; }); return tmp; } async function del(domainObject, subdomain, type, values) { assert.strictEqual(typeof domainObject, 'object'); assert.strictEqual(typeof subdomain, 'string'); assert.strictEqual(typeof type, 'string'); assert(Array.isArray(values)); const domainConfig = domainObject.config; const zoneName = domainObject.zoneName; subdomain = dns.getName(domainObject, subdomain, type) || '@'; debug('del: %s for zone %s of type %s with values %j', subdomain, zoneName, type, values); let result = await getZone(domainConfig, zoneName); if (result.length === 0) return; const originalLength = result.length; for (let i = 0; i < values.length; i++) { let curValue = values[i]; result = result.filter(curHost => curHost.Type !== type || curHost.Name !== subdomain || curHost.Address !== curValue); } if (result.length !== originalLength) return await setZone(domainConfig, zoneName, result); } async function wait(domainObject, subdomain, type, value, options) { assert.strictEqual(typeof domainObject, 'object'); assert.strictEqual(typeof subdomain, 'string'); assert.strictEqual(typeof type, 'string'); assert.strictEqual(typeof value, 'string'); assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 } const fqdn = dns.fqdn(subdomain, domainObject.domain); await waitForDns(fqdn, domainObject.zoneName, type, value, options); } async function verifyDomainConfig(domainObject) { assert.strictEqual(typeof domainObject, 'object'); const domainConfig = domainObject.config; const zoneName = domainObject.zoneName; const ip = '127.0.0.1'; if (!domainConfig.username || typeof domainConfig.username !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'username must be a non-empty string'); if (!domainConfig.token || typeof domainConfig.token !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'token must be a non-empty string'); const credentials = { username: domainConfig.username, token: domainConfig.token }; if (process.env.BOX_ENV === 'test') return credentials; // this shouldn't be here const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 })); if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain'); if (error || !nameservers) throw new BoxError(BoxError.BAD_FIELD, error ? error.message : 'Unable to get nameservers'); if (nameservers.some(function (n) { return n.toLowerCase().indexOf('.registrar-servers.com') === -1; })) { debug('verifyDomainConfig: %j does not contains NC NS', nameservers); throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to NameCheap'); } const testSubdomain = 'cloudrontestdns'; await upsert(domainObject, testSubdomain, 'A', [ip]); debug('verifyDomainConfig: Test A record added'); await del(domainObject, testSubdomain, 'A', [ip]); debug('verifyDomainConfig: Test A record removed again'); return credentials; }