import {isIPv4, isIPv6} from 'node:net' import { ChangeResourceRecordSetsCommand, CreateHostedZoneCommand, DeleteHostedZoneCommand, GetHostedZoneCommand, ListHostedZonesCommand, ListResourceRecordSetsCommand, type HostedZone, type ResourceRecordSet, type Route53Client, type RRType, } from '@aws-sdk/client-route-53' import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections' import {DNS_RECORD_TYPES, DNS_ZONE_NAME_PATTERN, awsDnsSchema} from '../cloud-spi/dnsSchema' import {ConflictError, NotFoundError, ValidationError} from '../cloud-spi/errors' import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' /** Route 53 caps ListResourceRecordSets pages at 300 and ListHostedZones at 100. */ const RECORD_PAGE_LIMIT = 300 const ZONE_PAGE_LIMIT = 100 /** Route 53's TTL range. */ const MAX_TTL = 2_147_483_647 /** * Talks to Route 53 hosted zones, verified against Floci core (community, `latest`). * * Notes from probing the runtime: * - Zone IDs come back as `/hostedzone/Z…`; the bare ID is accepted everywhere, so * resources are keyed by it. * - A new zone holds the apex NS and SOA records, and `DeleteHostedZone` refuses * with `HostedZoneNotEmpty` (400) while any other record remains, as on AWS. * - A missing zone is `NoSuchHostedZone` with HTTP 404. * - The runtime stores record values without checking them (an A record of * `not-an-ip`, unquoted TXT text), so the AWS rules are applied here. * * Records are the zone's child items, keyed by name, type and set identifier, * which is what makes a record set unique in Route 53. */ export class AwsDnsAdapter implements CloudServiceAdapter { readonly cloud = 'aws' as const readonly service = 'dns' as const readonly items: ItemStoreAdapter constructor(private readonly route53: Route53Client, private readonly region = 'us-east-1') { this.items = new RecordStore(route53, (id) => this.zoneName(id)) } schema(): ServiceSchema { return awsDnsSchema() } async list(query: ResourceQuery = {}): Promise { const zones: HostedZone[] = [] let marker: string | undefined do { const res = await this.route53.send(new ListHostedZonesCommand({MaxItems: ZONE_PAGE_LIMIT, ...(marker ? {Marker: marker} : {})})) zones.push(...(res.HostedZones ?? [])) marker = res.IsTruncated ? res.NextMarker : undefined } while (marker) const search = query.search?.trim().toLowerCase() return zones.map((zone) => toResource(zone)) .filter((zone) => !search || zone.name.toLowerCase().includes(search) || zone.id.toLowerCase().includes(search)) } async get(id: string): Promise { try { const res = await this.route53.send(new GetHostedZoneCommand({Id: zoneId(id)})) return res.HostedZone ? toResource(res.HostedZone, res.DelegationSet?.NameServers, res.VPCs?.map((vpc) => `${vpc.VPCId} (${vpc.VPCRegion})`)) : null } catch (error) { if (errorName(error) === 'NoSuchHostedZone') return null throw error } } async create(input: CreateResourceInput): Promise { const name = typeof input.values.name === 'string' ? input.values.name.trim().toLowerCase() : '' if (!new RegExp(DNS_ZONE_NAME_PATTERN).test(name)) throw new ValidationError('name must be a domain name such as example.com') const comment = typeof input.values.comment === 'string' ? input.values.comment.trim() : '' const vpcId = typeof input.values.vpcId === 'string' ? input.values.vpcId.trim() : '' if (vpcId && !/^vpc-[0-9a-f]{8,17}$/.test(vpcId)) throw new ValidationError('vpcId must be a VPC ID such as vpc-0abc1234') const res = await this.route53.send(new CreateHostedZoneCommand({ Name: name, CallerReference: crypto.randomUUID(), HostedZoneConfig: {Comment: comment || undefined, PrivateZone: Boolean(vpcId)}, ...(vpcId ? {VPC: {VPCId: vpcId, VPCRegion: this.region as never}} : {}), })) if (!res.HostedZone) throw new ValidationError(`Route 53 did not return the hosted zone ${name}`) return toResource(res.HostedZone, res.DelegationSet?.NameServers, vpcId ? [`${vpcId} (${this.region})`] : undefined) } async delete(id: string): Promise { try { await this.route53.send(new DeleteHostedZoneCommand({Id: zoneId(id)})) } catch (error) { if (errorName(error) === 'HostedZoneNotEmpty') { throw new ConflictError('Delete the zone\'s records first: Route 53 only deletes a hosted zone that holds just its NS and SOA records') } throw error } } private async zoneName(id: string): Promise { const zone = await this.get(id) if (!zone) throw new NotFoundError(`Hosted zone ${id} was not found`) return zone.name } } class RecordStore implements ItemStoreAdapter { constructor(private readonly route53: Route53Client, private readonly zoneName: (id: string) => Promise) {} async listItems(resourceId: string, page: PageQuery = {}): Promise> { const start = decodeCursor(page.cursor) const res = await this.route53.send(new ListResourceRecordSetsCommand({ HostedZoneId: zoneId(resourceId), MaxItems: Math.min(page.limit ?? RECORD_PAGE_LIMIT, RECORD_PAGE_LIMIT), ...(start ? {StartRecordName: start.name, StartRecordType: start.type as RRType, StartRecordIdentifier: start.identifier} : {}), })) const zone = await this.zoneName(resourceId) return { items: (res.ResourceRecordSets ?? []).map((record) => toRecordItem(record, zone)), nextCursor: res.IsTruncated && res.NextRecordName ? encodeCursor({name: res.NextRecordName, type: res.NextRecordType ?? '', identifier: res.NextRecordIdentifier}) : null, } } /** Creates the record set, or replaces the one with the same name and type (UPSERT). */ async putItem(resourceId: string, body: Record): Promise { const zone = await this.zoneName(resourceId) const record = recordFromInput(body, zone) await this.route53.send(new ChangeResourceRecordSetsCommand({ HostedZoneId: zoneId(resourceId), ChangeBatch: {Changes: [{Action: 'UPSERT', ResourceRecordSet: record}]}, })) return toRecordItem(record, zone) } /** Route 53 deletes a record set only when given it exactly, so the stored set is read first. */ async deleteItem(resourceId: string, itemId: string): Promise { const key = parseRecordId(itemId) const zone = await this.zoneName(resourceId) if (key.name === zone && (key.type === 'NS' || key.type === 'SOA')) { throw new ConflictError(`The zone's own ${key.type} record cannot be deleted`) } const res = await this.route53.send(new ListResourceRecordSetsCommand({ HostedZoneId: zoneId(resourceId), StartRecordName: key.name, StartRecordType: key.type as RRType, ...(key.identifier ? {StartRecordIdentifier: key.identifier} : {}), MaxItems: 1, })) const stored = res.ResourceRecordSets?.find((record) => recordId(record) === itemId) if (!stored) throw new NotFoundError(`Record ${key.type} ${key.name} was not found`) await this.route53.send(new ChangeResourceRecordSetsCommand({ HostedZoneId: zoneId(resourceId), ChangeBatch: {Changes: [{Action: 'DELETE', ResourceRecordSet: stored}]}, })) } } function toResource(zone: HostedZone, nameServers?: string[], vpcs?: string[]): CloudResource { const id = zoneId(zone.Id ?? '') const privateZone = zone.Config?.PrivateZone ?? false return { id, name: withoutDot(zone.Name ?? id), cloud: 'aws', service: 'dns', type: 'hosted-zone', region: null, createdAt: null, status: null, metadata: { visibility: privateZone ? 'private' : 'public', recordCount: zone.ResourceRecordSetCount ?? null, comment: zone.Config?.Comment ?? null, ...(nameServers ? {nameServers} : {}), ...(vpcs ? {vpcs} : {}), }, } } function toRecordItem(record: ResourceRecordSet, zone: string): ChildItem { const name = withoutDot(unescapeName(record.Name ?? '')) return { id: recordId(record), collectionId: null, timestamp: null, body: { name, type: record.Type ?? '', ttl: record.TTL ?? null, values: (record.ResourceRecords ?? []).map((value) => value.Value ?? ''), alias: record.AliasTarget ? {dnsName: withoutDot(record.AliasTarget.DNSName ?? ''), hostedZoneId: record.AliasTarget.HostedZoneId ?? '', evaluateTargetHealth: record.AliasTarget.EvaluateTargetHealth ?? false} : null, setIdentifier: record.SetIdentifier ?? null, // The zone's own NS and SOA records, and routing-policy sets this console cannot edit faithfully. managed: (name === zone && (record.Type === 'NS' || record.Type === 'SOA')), editable: !record.AliasTarget && !record.SetIdentifier, }, metadata: {}, } } /** Validates a simple record the way Route 53 does and returns it as a record set. */ function recordFromInput(body: Record, zone: string): ResourceRecordSet { const type = typeof body.type === 'string' ? body.type.trim().toUpperCase() : '' if (!(DNS_RECORD_TYPES as readonly string[]).includes(type)) throw new ValidationError(`type must be one of ${DNS_RECORD_TYPES.join(', ')}`) const name = qualifiedName(typeof body.name === 'string' ? body.name : '', zone) const ttl = Number(body.ttl ?? 300) if (!Number.isInteger(ttl) || ttl < 0 || ttl > MAX_TTL) throw new ValidationError('ttl must be a whole number of seconds') const rawValues = Array.isArray(body.values) ? body.values : typeof body.values === 'string' ? body.values.split('\n') : [] const values = rawValues.map((value) => String(value).trim()).filter(Boolean) if (values.length === 0) throw new ValidationError('Add at least one value') if (type === 'CNAME' && values.length > 1) throw new ValidationError('A CNAME record has exactly one value') if (type === 'CNAME' && name === zone) throw new ValidationError('The zone apex cannot have a CNAME record; use an A or alias record') for (const value of values) { if (type === 'A' && !isIPv4(value)) throw new ValidationError(`${value} is not an IPv4 address`) if (type === 'AAAA' && !isIPv6(value)) throw new ValidationError(`${value} is not an IPv6 address`) if (type === 'MX' && !/^\d{1,5}\s+\S+$/.test(value)) throw new ValidationError(`MX values are a priority and a mail server, such as "10 mail.${zone}"`) } return { Name: name, Type: type as RRType, TTL: ttl, ResourceRecords: values.map((value) => ({Value: type === 'TXT' || type === 'SPF' ? quoted(value) : value})), } } /** "www", "www.example.com" and "@" all name a record in example.com. */ function qualifiedName(input: string, zone: string): string { const name = withoutDot(input.trim().toLowerCase()) if (!name || name === '@') return zone const full = name === zone || name.endsWith(`.${zone}`) ? name : `${name}.${zone}` if (!/^(\*\.)?([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?\.)*[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/.test(full) || full.length > 253) { throw new ValidationError(`${input} is not a valid record name`) } return full } /** Route 53 wants each TXT string quoted; text the user already quoted is kept as written. */ function quoted(value: string): string { if (value.startsWith('"') && value.endsWith('"') && value.length > 1) return value return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` } function recordId(record: Pick): string { return [withoutDot(unescapeName(record.Name ?? '')), record.Type ?? '', ...(record.SetIdentifier ? [record.SetIdentifier] : [])].join('|') } function parseRecordId(id: string): {name: string; type: string; identifier?: string} { const [name, type, ...rest] = id.split('|') if (!name || !type) throw new ValidationError('Record id must be name|type') return {name, type, ...(rest.length ? {identifier: rest.join('|')} : {})} } function encodeCursor(cursor: {name: string; type: string; identifier?: string}): string { return Buffer.from(JSON.stringify(cursor)).toString('base64url') } function decodeCursor(cursor: string | undefined): {name: string; type: string; identifier?: string} | null { if (!cursor) return null try { const value = JSON.parse(Buffer.from(cursor, 'base64url').toString()) as {name?: unknown; type?: unknown; identifier?: unknown} if (typeof value.name !== 'string' || typeof value.type !== 'string') throw new Error('bad cursor') return {name: value.name, type: value.type, ...(typeof value.identifier === 'string' ? {identifier: value.identifier} : {})} } catch { throw new ValidationError('cursor is not valid for this hosted zone') } } /** Route 53 returns `*` as `\052` and other special characters as octal escapes. */ function unescapeName(name: string): string { return name.replace(/\\(\d{3})/g, (_match, octal: string) => String.fromCharCode(parseInt(octal, 8))) } function zoneId(id: string): string { return id.replace(/^\/hostedzone\//, '') } function withoutDot(name: string): string { return name.endsWith('.') ? name.slice(0, -1) : name } function errorName(error: unknown): string { return (error as {name?: string} | null)?.name ?? '' }