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

219 lines (183 loc) 7.33 kB
import { Change, ChangeStatus, 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"; import { DnsAddressRecordSet, DnsZoneRecordClient, DnsZoneRecordSets, } from "../types/dns-zone-record-client.mjs"; const NO_REGION = "REGION"; export class Route53UpdateClient implements DnsZoneRecordClient { private readonly logger = loggerForModuleUrl(import.meta.url); private readonly client: Route53; constructor() { this.client = new Route53({ region: NO_REGION, }); } getZoneRecords = async (dnsRecordsToMatch: string[]): Promise<DnsZoneRecordSets[]> => { 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: unknown) { this.logger.error(formatError(error)); return []; } }; updateZoneRecords = async (zoneRecords: DnsZoneRecordSets[]): Promise<Map<string, boolean>> => { this.logger.verbose(`Update records for zones: ${zoneRecords.length}`); const pendingRequest = zoneRecords.map(async z => { try { return await this.updateRecordsForZone(z); } catch (error: unknown) { 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<string, boolean>() ); }; getZonesForDnsRecords = async (dnsRecordsToMatch: string[]): Promise<Map<string, string[]>> => { 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<string, string[]>()); }; private getRecordsForZone = async ( zoneId: string, dnsRecords: string[] ): Promise<DnsZoneRecordSets> => { 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") ) as DnsAddressRecordSet[]; 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 }; }; private updateRecordsForZone = async ( zoneRecords: DnsZoneRecordSets ): Promise<{ zoneId: string; hasSucceeded: boolean }> => { const updates: Change[] = 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 as ChangeStatus | undefined ); return { zoneId: zoneRecords.zoneId, hasSucceeded, }; }; private getChangeStatusUntilInSync = async ( changeId: string, status: ChangeStatus = "PENDING", startTime = Date.now() ): Promise<boolean> => { 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 as ChangeStatus | undefined, startTime ); }; }