12e073e8cf
mostly because code is being autogenerated by all the AI stuff using this prefix. it's also used in the stack trace.
273 lines
11 KiB
JavaScript
273 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
removePrivateFields,
|
|
injectPrivateFields,
|
|
upsert,
|
|
get,
|
|
del,
|
|
wait,
|
|
verifyDomainConfig,
|
|
};
|
|
|
|
const assert = require('node:assert'),
|
|
BoxError = require('../boxerror.js'),
|
|
{ ConfiguredRetryStrategy } = require('@smithy/util-retry'),
|
|
constants = require('../constants.js'),
|
|
debug = require('debug')('box:dns/route53'),
|
|
dig = require('../dig.js'),
|
|
dns = require('../dns.js'),
|
|
{ Route53 } = require('@aws-sdk/client-route-53'),
|
|
safe = require('safetydance'),
|
|
waitForDns = require('./waitfordns.js'),
|
|
_ = require('../underscore.js');
|
|
|
|
function removePrivateFields(domainObject) {
|
|
domainObject.config.secretAccessKey = constants.SECRET_PLACEHOLDER;
|
|
return domainObject;
|
|
}
|
|
|
|
function injectPrivateFields(newConfig, currentConfig) {
|
|
if (newConfig.secretAccessKey === constants.SECRET_PLACEHOLDER) 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 '<priority> <server>' 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;
|
|
}
|