import assert from 'node:assert'; import BoxError from '../boxerror.js'; import { ConfiguredRetryStrategy } from '@smithy/util-retry'; import constants from '../constants.js'; import debugModule from 'debug'; import * as dig from '../dig.js'; import * as dns from '../dns.js'; import { Route53 } from '@aws-sdk/client-route-53'; import safe from 'safetydance'; import waitForDns from './waitfordns.js'; import * as _ from '../underscore.js'; const debug = debugModule('box:dns/route53'); export { removePrivateFields, injectPrivateFields, upsert, get, del, wait, verifyDomainConfig, }; function removePrivateFields(domainObject) { delete domainObject.config.secretAccessKey; return domainObject; } function injectPrivateFields(newConfig, currentConfig) { if (!Object.hasOwn(newConfig, 'secretAccessKey')) newConfig.secretAccessKey = currentConfig.secretAccessKey; } function createRoute53Client(domainConfig) { assert.strictEqual(typeof domainConfig, 'object'); const credentials = { accessKeyId: domainConfig.accessKeyId, secretAccessKey: domainConfig.secretAccessKey, }; const clientConfig = { region: domainConfig.region, credentials, // route53 has a limit of 5 req/sec/region - https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests retryStrategy: new ConfiguredRetryStrategy(20 /* max attempts */, (/* attempt */) => 3000 /* constant backoff */) }; return constants.TEST ? new globalThis.Route53Mock(clientConfig) : new Route53(clientConfig); } async function getZoneByName(domainConfig, zoneName) { assert.strictEqual(typeof domainConfig, 'object'); assert.strictEqual(typeof zoneName, 'string'); const route53 = createRoute53Client(domainConfig); // backward compat for 2.2, where we only required access to "listHostedZones" let listHostedZones; if (domainConfig.listHostedZonesByName) { listHostedZones = route53.listHostedZonesByName({ MaxItems: '1', DNSName: zoneName + '.' }); } else { listHostedZones = route53.listHostedZones({}); // currently, this route does not support > 100 zones } const [error, result] = await safe(listHostedZones); if (error && error.code === 'AccessDenied') throw new BoxError(BoxError.ACCESS_DENIED, error.message); if (error && error.code === 'InvalidClientTokenId') throw new BoxError(BoxError.ACCESS_DENIED, error.message); if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, error.message); const zone = result.HostedZones.filter(function (zone) { return zone.Name.slice(0, -1) === zoneName; // aws zone name contains a '.' at the end })[0]; if (!zone) throw new BoxError(BoxError.NOT_FOUND, 'no such zone'); return zone; } async function getHostedZone(domainConfig, zoneName) { assert.strictEqual(typeof domainConfig, 'object'); assert.strictEqual(typeof zoneName, 'string'); const zone = await getZoneByName(domainConfig, zoneName); const route53 = createRoute53Client(domainConfig); const [error, result] = await safe(route53.getHostedZone({ Id: zone.Id })); if (error && error.code === 'AccessDenied') throw new BoxError(BoxError.ACCESS_DENIED, error.message); if (error && error.code === 'InvalidClientTokenId') throw new BoxError(BoxError.ACCESS_DENIED, error.message); if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, error.message); return result; } async function upsert(domainObject, location, type, values) { assert.strictEqual(typeof domainObject, 'object'); assert.strictEqual(typeof location, 'string'); assert.strictEqual(typeof type, 'string'); assert(Array.isArray(values)); const domainConfig = domainObject.config, zoneName = domainObject.zoneName, fqdn = dns.fqdn(location, domainObject.domain); debug('add: %s for zone %s of type %s with values %j', fqdn, zoneName, type, values); const zone = await getZoneByName(domainConfig, zoneName); const records = values.map(function (v) { return { Value: v }; }); // for mx records, value is already of the ' ' format const params = { ChangeBatch: { Changes: [{ Action: 'UPSERT', ResourceRecordSet: { Type: type, Name: fqdn, ResourceRecords: records, TTL: 1 } }] }, HostedZoneId: zone.Id }; const route53 = createRoute53Client(domainConfig); const [error] = await safe(route53.changeResourceRecordSets(params)); if (error && error.code === 'AccessDenied') throw new BoxError(BoxError.ACCESS_DENIED, error.message); if (error && error.code === 'InvalidClientTokenId') throw new BoxError(BoxError.ACCESS_DENIED, error.message); if (error && error.code === 'PriorRequestNotComplete') throw new BoxError(BoxError.BUSY, error.message); if (error && error.code === 'InvalidChangeBatch') throw new BoxError(BoxError.BAD_FIELD, error.message); if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, error.message); } async function get(domainObject, location, type) { assert.strictEqual(typeof domainObject, 'object'); assert.strictEqual(typeof location, 'string'); assert.strictEqual(typeof type, 'string'); const domainConfig = domainObject.config, zoneName = domainObject.zoneName, fqdn = dns.fqdn(location, domainObject.domain); const zone = await getZoneByName(domainConfig, zoneName); const params = { HostedZoneId: zone.Id, MaxItems: '1', StartRecordName: fqdn + '.', StartRecordType: type }; const route53 = createRoute53Client(domainConfig); const [error, result] = await safe(route53.listResourceRecordSets(params)); if (error && error.code === 'AccessDenied') throw new BoxError(BoxError.ACCESS_DENIED, error.message); if (error && error.code === 'InvalidClientTokenId') throw new BoxError(BoxError.ACCESS_DENIED, error.message); if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, error.message); if (result.ResourceRecordSets.length === 0) return []; if (result.ResourceRecordSets[0].Name !== params.StartRecordName || result.ResourceRecordSets[0].Type !== params.StartRecordType) return []; const values = result.ResourceRecordSets[0].ResourceRecords.map(function (record) { return record.Value; }); return values; } async function del(domainObject, location, type, values) { assert.strictEqual(typeof domainObject, 'object'); assert.strictEqual(typeof location, 'string'); assert.strictEqual(typeof type, 'string'); assert(Array.isArray(values)); const domainConfig = domainObject.config, zoneName = domainObject.zoneName, fqdn = dns.fqdn(location, domainObject.domain); const zone = await getZoneByName(domainConfig, zoneName); const records = values.map(function (v) { return { Value: v }; }); const resourceRecordSet = { Name: fqdn, Type: type, ResourceRecords: records, TTL: 1 }; const params = { ChangeBatch: { Changes: [{ Action: 'DELETE', ResourceRecordSet: resourceRecordSet }] }, HostedZoneId: zone.Id }; const route53 = createRoute53Client(domainConfig); const [error] = await safe(route53.changeResourceRecordSets(params)); if (error && error.code === 'AccessDenied') throw new BoxError(BoxError.ACCESS_DENIED, error.message); if (error && error.code === 'InvalidClientTokenId') throw new BoxError(BoxError.ACCESS_DENIED, error.message); if (error && error.message && error.message.indexOf('it was not found') !== -1) { throw new BoxError(BoxError.NOT_FOUND, error.message); } else if (error && error.code === 'NoSuchHostedZone') { throw new BoxError(BoxError.NOT_FOUND, error.message); } else if (error && error.code === 'PriorRequestNotComplete') { throw new BoxError(BoxError.BUSY, error.message); } else if (error && error.code === 'InvalidChangeBatch') { throw new BoxError(BoxError.NOT_FOUND, error.message); } else if (error) { throw new BoxError(BoxError.EXTERNAL_ERROR, error.message); } } 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, zoneName = domainObject.zoneName; if (!domainConfig.accessKeyId || typeof domainConfig.accessKeyId !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'accessKeyId must be a non-empty string'); if (!domainConfig.secretAccessKey || typeof domainConfig.secretAccessKey !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'secretAccessKey must be a non-empty string'); if ('customNameservers' in domainConfig && typeof domainConfig.customNameservers !== 'boolean') throw new BoxError(BoxError.BAD_FIELD, 'customNameservers must be a boolean'); const credentials = { accessKeyId: domainConfig.accessKeyId, secretAccessKey: domainConfig.secretAccessKey, region: domainConfig.region || 'us-east-1', endpoint: domainConfig.endpoint || null, listHostedZonesByName: true, // new/updated creds require this perm customNameservers: !!domainConfig.customNameservers }; const ip = '127.0.0.1'; if (constants.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'); const zone = await getHostedZone(credentials, zoneName); if (!_.isEqual(zone.DelegationSet.NameServers.sort(), nameservers.sort())) { debug('verifyDomainConfig: %j and %j do not match', nameservers, zone.DelegationSet.NameServers); if (!domainConfig.customNameservers) throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to Route53'); } const location = 'cloudrontestdns'; const newDomainObject = Object.assign({ }, domainObject, { config: credentials }); await upsert(newDomainObject, location, 'A', [ ip ]); debug('verifyDomainConfig: Test A record added'); await get(newDomainObject, location, 'A'); debug('verifyDomainConfig: Can list record sets'); await del(newDomainObject, location, 'A', [ ip ]); debug('verifyDomainConfig: Test A record removed again'); return credentials; }