feat(api): add Route 53 hosted zones and DNS records to the cloud explorer
CI / checks (pull_request) Successful in 25m40s
CI / checks (pull_request) Successful in 25m40s
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
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<CloudResource[]> {
|
||||
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<CloudResource | null> {
|
||||
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<CloudResource> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<string>) {}
|
||||
|
||||
async listItems(resourceId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
|
||||
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<string, unknown>): Promise<ChildItem> {
|
||||
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<void> {
|
||||
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<string, unknown>, 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<ResourceRecordSet, 'Name' | 'Type' | 'SetIdentifier'>): 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 ?? ''
|
||||
}
|
||||
Reference in New Issue
Block a user