@linkedmink/node-route53-dynamic-dns
Version:
Background process that updates AWS Route 53 DNS address records whenever the public IP of the hosting environment changes
137 lines • 6.98 kB
JavaScript
import { Route53 } from "@aws-sdk/client-route-53";
import { setTimeout } from "node:timers/promises";
import { CHANGE_INSYNC_INTERVAL_MS, CHANGE_INSYNC_LIMIT_MS } from "../constants/behavior.mjs";
import { LogLevel } from "../constants/logging.mjs";
import { loggerForModuleUrl, logWhenEnabled } from "../environment/logger.mjs";
import { formatError } from "../functions/format.mjs";
const NO_REGION = "REGION";
export class Route53UpdateClient {
logger = loggerForModuleUrl(import.meta.url);
client;
constructor() {
this.client = new Route53({
region: NO_REGION,
});
}
getZoneRecords = async (dnsRecordsToMatch) => {
this.logger.verbose(`Find matching records for DNS records: ${dnsRecordsToMatch.toString()}`);
try {
const zonesToUpdate = await this.getZonesForDnsRecords(dnsRecordsToMatch);
const pendingRequest = Array.from(zonesToUpdate).map(([zoneId, dnsRecords]) => {
return this.getRecordsForZone(zoneId, dnsRecords);
});
const matchedZoneRecords = await Promise.all(pendingRequest);
this.logger.verbose(`Found matching zones for DNS records: count=${matchedZoneRecords.length}, records=${dnsRecordsToMatch.toString()}`);
logWhenEnabled(this.logger, LogLevel.debug, () => `Zones and records found: ${JSON.stringify(matchedZoneRecords, null, 2)}`);
return matchedZoneRecords;
}
catch (error) {
this.logger.error(formatError(error));
return [];
}
};
updateZoneRecords = async (zoneRecords) => {
this.logger.verbose(`Update records for zones: ${zoneRecords.length}`);
const pendingRequest = zoneRecords.map(async (z) => {
try {
return await this.updateRecordsForZone(z);
}
catch (error) {
this.logger.error(formatError(error));
return { zoneId: z.zoneId, hasSucceeded: false };
}
});
const statuses = await Promise.all(pendingRequest);
logWhenEnabled(this.logger, LogLevel.debug, () => `Zones and records updated: ${JSON.stringify(statuses, null, 2)}`);
return statuses.reduce((statusMap, next) => statusMap.set(next.zoneId, next.hasSucceeded), new Map());
};
getZonesForDnsRecords = async (dnsRecordsToMatch) => {
this.logger.http("listHostedZones request");
const response = await this.client.listHostedZones({});
if (!response.HostedZones) {
throw new Error("listHostedZones no HostedZones in response");
}
const zones = response.HostedZones.filter(z => z.Id && z.Name).map(z => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: z.Id,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
name: z.Name,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
isInZoneRegEx: new RegExp(`(${z.Name.replace(".", "\\.")})$`),
}));
logWhenEnabled(this.logger, LogLevel.debug, () => {
const zoneNames = zones.map(z => `id=${z.id}, name=${z.name}`).join("; ");
return `Found zones: ${zoneNames}`;
});
const dnsRecords = dnsRecordsToMatch.map(h => h.trim());
return dnsRecords.reduce((zoneIdToDnsRecords, nextDnsRecord) => {
const zoneToUpdate = zones.find(z => z.isInZoneRegEx.test(nextDnsRecord));
if (!zoneToUpdate) {
this.logger.warn(`No zone matches the host name, it will be skipped: ${nextDnsRecord}`);
return zoneIdToDnsRecords;
}
const dnsRecords = zoneIdToDnsRecords.get(zoneToUpdate.id);
if (dnsRecords) {
dnsRecords.push(nextDnsRecord);
}
else {
zoneIdToDnsRecords.set(zoneToUpdate.id, [nextDnsRecord]);
}
return zoneIdToDnsRecords;
}, new Map());
};
getRecordsForZone = async (zoneId, dnsRecords) => {
this.logger.http(`listResourceRecordSets request for zone: ${zoneId}`);
const response = await this.client.listResourceRecordSets({ HostedZoneId: zoneId });
if (!response.ResourceRecordSets) {
throw new Error("listResourceRecordSets no ResourceRecordSets in response");
}
const dnsRecordSet = new Set(dnsRecords);
const records = response.ResourceRecordSets.filter(rs => rs.Name && dnsRecordSet.has(rs.Name) && (rs.Type === "A" || rs.Type === "AAAA"));
logWhenEnabled(this.logger, LogLevel.debug, () => {
const recordsString = JSON.stringify(records, null, 2);
return `Found records for zone: zoneId=${zoneId}, ${recordsString}`;
});
const foundDnsRecords = new Set(records.map(r => r.Name));
const missingDnsRecords = dnsRecords.filter(n => !foundDnsRecords.has(n));
if (missingDnsRecords.length > 0) {
this.logger.warn(`No host records were found in zone, they will be skipped: zoneId=${zoneId}, missing=${missingDnsRecords.toString()}`);
}
return { zoneId, records };
};
updateRecordsForZone = async (zoneRecords) => {
const updates = zoneRecords.records.map(r => ({
Action: "UPSERT",
ResourceRecordSet: r,
}));
this.logger.http(`changeResourceRecordSets request for zone: ${zoneRecords.zoneId}`);
const response = await this.client.changeResourceRecordSets({
HostedZoneId: zoneRecords.zoneId,
ChangeBatch: { Changes: updates },
});
const changeId = response.ChangeInfo?.Id;
if (!changeId) {
throw new Error("changeResourceRecordSets no ChangeInfo.Id in response");
}
logWhenEnabled(this.logger, LogLevel.debug, () => `Records updates for zone pending: changeId=${changeId}, ${JSON.stringify(zoneRecords, null, 2)}`);
const hasSucceeded = await this.getChangeStatusUntilInSync(changeId, response.ChangeInfo?.Status);
return {
zoneId: zoneRecords.zoneId,
hasSucceeded,
};
};
getChangeStatusUntilInSync = async (changeId, status = "PENDING", startTime = Date.now()) => {
if (status === "INSYNC") {
return true;
}
else if (Date.now() - startTime > CHANGE_INSYNC_LIMIT_MS) {
this.logger.warn(`Change not in sync after time limit: id=${changeId}, limit=${CHANGE_INSYNC_LIMIT_MS}`);
return false;
}
await setTimeout(CHANGE_INSYNC_INTERVAL_MS);
this.logger.debug(`getChange request for ID: ${changeId}`);
const response = await this.client.getChange({ Id: changeId });
return this.getChangeStatusUntilInSync(changeId, response.ChangeInfo?.Status, startTime);
};
}
//# sourceMappingURL=route53-update-client.mjs.map