UNPKG

@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
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