diff --git a/README.md b/README.md index a41e4a2..5dab15c 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ cd packages/api && bun run scripts/service-matrix.ts | Networking | Networking | Yes (list) | No | No | | Networking | ELB / Load Balancing | Yes (list, create, update, delete, inspect) | No | No | | Networking | ELB Target Groups / Target Groups | Yes (list, create, delete, inspect) | No | No | +| Networking | Route 53 / DNS | Yes (list, create, delete, inspect) | No | No | | Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Integration | API Gateway | Yes (list, create, delete, inspect) | No | No | | Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No | diff --git a/packages/api/package.json b/packages/api/package.json index 47a4453..31fb8ee 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -36,6 +36,7 @@ "@aws-sdk/client-lightsail": "^3.1076.0", "@aws-sdk/client-rds": "^3.1076.0", "@aws-sdk/client-rds-data": "^3.1076.0", + "@aws-sdk/client-route-53": "^3.1146.0", "@aws-sdk/client-s3": "^3.1076.0", "@aws-sdk/client-secrets-manager": "^3.1076.0", "@aws-sdk/client-sesv2": "^3.1076.0", diff --git a/packages/api/src/adapter-aws/AwsDnsAdapter.test.ts b/packages/api/src/adapter-aws/AwsDnsAdapter.test.ts new file mode 100644 index 0000000..3152173 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsDnsAdapter.test.ts @@ -0,0 +1,174 @@ +import {describe, expect, test} from 'bun:test' +import { + ChangeResourceRecordSetsCommand, + CreateHostedZoneCommand, + DeleteHostedZoneCommand, + GetHostedZoneCommand, + ListHostedZonesCommand, + ListResourceRecordSetsCommand, + type ResourceRecordSet, + type Route53Client, +} from '@aws-sdk/client-route-53' +import {AwsDnsAdapter} from './AwsDnsAdapter' +import {ConflictError, ValidationError} from '../cloud-spi/errors' + +type SendResult = Record + +function stubRoute53(handler: (command: object) => SendResult | Promise) { + const sent: object[] = [] + const client = { + async send(command: object) { + sent.push(command) + return handler(command) + }, + } as unknown as Route53Client + return {client, sent} +} + +function awsError(name: string, status = 400): Error { + return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}}) +} + +const ZONE_ID = 'ZG9PAE0M9KRWJ4F' +const zone = {Id: `/hostedzone/${ZONE_ID}`, Name: 'example.test.', CallerReference: 'r', ResourceRecordSetCount: 3, Config: {Comment: 'Local zone', PrivateZone: false}} +const apex = [ + {Name: 'example.test.', Type: 'NS', TTL: 172800, ResourceRecords: [{Value: 'ns-1.awsdns-01.org.'}]}, + {Name: 'example.test.', Type: 'SOA', TTL: 900, ResourceRecords: [{Value: 'ns-1.awsdns-01.org awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400'}]}, +] + +function zoneStub(records: object[] = apex, more: (command: object) => SendResult | undefined = () => undefined) { + return stubRoute53((command) => { + const extra = more(command) + if (extra) return extra + if (command instanceof GetHostedZoneCommand) return {HostedZone: zone, DelegationSet: {NameServers: ['ns-1.awsdns-01.org']}} + if (command instanceof ListResourceRecordSetsCommand) return {ResourceRecordSets: records, IsTruncated: false} + return {} + }) +} + +describe('AwsDnsAdapter hosted zones', () => { + test('lists zones by bare ID and filters by name', async () => { + const {client} = stubRoute53((command) => { + if (command instanceof ListHostedZonesCommand) { + return {HostedZones: [zone, {...zone, Id: '/hostedzone/ZPRIVATE', Name: 'internal.test.', Config: {PrivateZone: true}}], IsTruncated: false} + } + return {} + }) + const adapter = new AwsDnsAdapter(client) + + const zones = await adapter.list() + expect(zones.map((resource) => [resource.id, resource.name, resource.metadata.visibility])).toEqual([[ZONE_ID, 'example.test', 'public'], ['ZPRIVATE', 'internal.test', 'private']]) + expect((await adapter.list({search: 'INTERNAL'})).map((resource) => resource.id)).toEqual(['ZPRIVATE']) + }) + + test('describes a zone with its name servers, and treats a missing zone as absent', async () => { + const {client} = stubRoute53((command) => { + if (command instanceof GetHostedZoneCommand && command.input.Id === ZONE_ID) return {HostedZone: zone, DelegationSet: {NameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']}} + throw awsError('NoSuchHostedZone', 404) + }) + const adapter = new AwsDnsAdapter(client) + + expect((await adapter.get(ZONE_ID))?.metadata).toEqual({visibility: 'public', recordCount: 3, comment: 'Local zone', nameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']}) + expect(await adapter.get('ZMISSING')).toBeNull() + }) + + test('creates a public zone, or a private one in a VPC', async () => { + const {client, sent} = stubRoute53((command) => command instanceof CreateHostedZoneCommand + ? {HostedZone: {...zone, Name: `${command.input.Name}.`, Config: command.input.HostedZoneConfig}, DelegationSet: {NameServers: ['ns-1.awsdns-01.org']}} + : {}) + const adapter = new AwsDnsAdapter(client) + + expect((await adapter.create({values: {name: ' Example.TEST ', comment: 'Local zone'}})).name).toBe('example.test') + await adapter.create({values: {name: 'internal.test', vpcId: 'vpc-0abc1234'}}) + + const [publicZone, privateZone] = sent.filter((command) => command instanceof CreateHostedZoneCommand) as CreateHostedZoneCommand[] + expect(publicZone.input).toMatchObject({Name: 'example.test', HostedZoneConfig: {Comment: 'Local zone', PrivateZone: false}}) + expect(publicZone.input.VPC).toBeUndefined() + expect(privateZone.input).toMatchObject({HostedZoneConfig: {PrivateZone: true}, VPC: {VPCId: 'vpc-0abc1234', VPCRegion: 'us-east-1'}}) + await expect(adapter.create({values: {name: 'not a domain'}})).rejects.toThrow(ValidationError) + await expect(adapter.create({values: {name: 'example.test', vpcId: 'subnet-1'}})).rejects.toThrow(ValidationError) + }) + + test('explains that a zone with records cannot be deleted', async () => { + const {client} = stubRoute53((command) => { + if (command instanceof DeleteHostedZoneCommand) throw awsError('HostedZoneNotEmpty') + return {} + }) + await expect(new AwsDnsAdapter(client).delete(ZONE_ID)).rejects.toThrow(ConflictError) + }) +}) + +describe('AwsDnsAdapter records', () => { + test('lists records keyed by name and type, marking the zone\'s own NS and SOA', async () => { + const {client} = zoneStub([ + ...apex, + {Name: '\\052.example.test.', Type: 'A', TTL: 60, ResourceRecords: [{Value: '10.0.0.9'}]}, + {Name: 'cdn.example.test.', Type: 'A', AliasTarget: {DNSName: 'd111.cloudfront.net.', HostedZoneId: 'Z2FDTNDATAQYW2', EvaluateTargetHealth: false}}, + {Name: 'api.example.test.', Type: 'A', SetIdentifier: 'blue', Weight: 10, TTL: 60, ResourceRecords: [{Value: '10.0.0.2'}]}, + ]) + const {items} = await new AwsDnsAdapter(client).items.listItems(ZONE_ID) + + expect(items.map((item) => item.id)).toEqual(['example.test|NS', 'example.test|SOA', '*.example.test|A', 'cdn.example.test|A', 'api.example.test|A|blue']) + expect(items[0].body).toMatchObject({managed: true, editable: true}) + expect(items[2].body).toMatchObject({name: '*.example.test', ttl: 60, values: ['10.0.0.9'], managed: false, editable: true}) + expect(items[3].body).toMatchObject({alias: {dnsName: 'd111.cloudfront.net', hostedZoneId: 'Z2FDTNDATAQYW2', evaluateTargetHealth: false}, editable: false}) + expect(items[4].body).toMatchObject({setIdentifier: 'blue', editable: false}) + }) + + test('pages with an opaque cursor built from Route 53\'s next record', async () => { + const {client, sent} = zoneStub(apex, (command) => command instanceof ListResourceRecordSetsCommand && !command.input.StartRecordName + ? {ResourceRecordSets: apex, IsTruncated: true, NextRecordName: 'www.example.test.', NextRecordType: 'A'} + : undefined) + const store = new AwsDnsAdapter(client).items + + const first = await store.listItems(ZONE_ID, {limit: 2}) + expect(first.nextCursor).toBeTruthy() + await store.listItems(ZONE_ID, {cursor: first.nextCursor!}) + const second = sent.filter((command) => command instanceof ListResourceRecordSetsCommand).at(-1) as ListResourceRecordSetsCommand + expect(second.input).toMatchObject({StartRecordName: 'www.example.test.', StartRecordType: 'A'}) + await expect(store.listItems(ZONE_ID, {cursor: 'not-a-cursor'})).rejects.toThrow(ValidationError) + }) + + test('upserts a record, naming it inside the zone and quoting TXT text', async () => { + const {client, sent} = zoneStub() + const store = new AwsDnsAdapter(client).items + + expect((await store.putItem!(ZONE_ID, {name: 'www', type: 'a', ttl: '120', values: ['10.0.0.1', '10.0.0.2']})).id).toBe('www.example.test|A') + await store.putItem!(ZONE_ID, {name: '@', type: 'TXT', values: 'v=spf1 -all\n"already quoted"'}) + await store.putItem!(ZONE_ID, {name: 'mail.example.test.', type: 'MX', values: ['10 mx.example.test']}) + + const changes = sent.filter((command) => command instanceof ChangeResourceRecordSetsCommand) + .map((command) => (command as ChangeResourceRecordSetsCommand).input.ChangeBatch!.Changes![0]) + expect(changes[0]).toEqual({Action: 'UPSERT', ResourceRecordSet: {Name: 'www.example.test', Type: 'A', TTL: 120, ResourceRecords: [{Value: '10.0.0.1'}, {Value: '10.0.0.2'}]}}) + expect(changes[1].ResourceRecordSet).toMatchObject({Name: 'example.test', Type: 'TXT', TTL: 300, ResourceRecords: [{Value: '"v=spf1 -all"'}, {Value: '"already quoted"'}]}) + expect(changes[2].ResourceRecordSet).toMatchObject({Name: 'mail.example.test', Type: 'MX'}) + }) + + test('applies Route 53\'s value rules before calling it', async () => { + const {client, sent} = zoneStub() + const store = new AwsDnsAdapter(client).items + + await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', values: ['not-an-ip']})).rejects.toThrow(/IPv4/) + await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'AAAA', values: ['10.0.0.1']})).rejects.toThrow(/IPv6/) + await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'CNAME', values: ['a.test', 'b.test']})).rejects.toThrow(/exactly one/) + await expect(store.putItem!(ZONE_ID, {name: '@', type: 'CNAME', values: ['a.test']})).rejects.toThrow(/apex/) + await expect(store.putItem!(ZONE_ID, {name: 'mail', type: 'MX', values: ['mx.example.test']})).rejects.toThrow(/priority/) + await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'BOGUS', values: ['x']})).rejects.toThrow(/type/) + await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', values: []})).rejects.toThrow(/value/) + await expect(store.putItem!(ZONE_ID, {name: 'www', type: 'A', ttl: -1, values: ['10.0.0.1']})).rejects.toThrow(/ttl/) + expect(sent.some((command) => command instanceof ChangeResourceRecordSetsCommand)).toBe(false) + }) + + test('deletes exactly the stored record set, and never the zone\'s own NS or SOA', async () => { + const stored: ResourceRecordSet = {Name: 'www.example.test.', Type: 'A', TTL: 120, ResourceRecords: [{Value: '10.0.0.1'}]} + const {client, sent} = zoneStub([stored]) + const store = new AwsDnsAdapter(client).items + + await store.deleteItem!(ZONE_ID, 'www.example.test|A') + const change = (sent.find((command) => command instanceof ChangeResourceRecordSetsCommand) as ChangeResourceRecordSetsCommand).input.ChangeBatch!.Changes![0] + expect(change).toEqual({Action: 'DELETE', ResourceRecordSet: stored}) + + await expect(store.deleteItem!(ZONE_ID, 'example.test|NS')).rejects.toThrow(ConflictError) + await expect(store.deleteItem!(ZONE_ID, 'gone.example.test|A')).rejects.toThrow(/not found/) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsDnsAdapter.ts b/packages/api/src/adapter-aws/AwsDnsAdapter.ts new file mode 100644 index 0000000..e630147 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsDnsAdapter.ts @@ -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 { + 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 ?? '' +} diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts index 054f448..e5d6801 100644 --- a/packages/api/src/aws.ts +++ b/packages/api/src/aws.ts @@ -31,6 +31,7 @@ import { CloudWatchClient } from "@aws-sdk/client-cloudwatch"; import { ElastiCacheClient } from "@aws-sdk/client-elasticache"; import { LightsailClient } from "@aws-sdk/client-lightsail"; import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider"; +import { Route53Client } from "@aws-sdk/client-route-53"; const endpoint = process.env.FLOCI_ENDPOINT; const region = process.env.AWS_REGION || "us-east-1"; @@ -93,6 +94,7 @@ export type AwsClients = { elasticache: ElastiCacheClient; lightsail: LightsailClient; cognito: CognitoIdentityProviderClient; + route53: Route53Client; }; export type AwsClientName = keyof AwsClients; @@ -139,6 +141,7 @@ function buildClients(accountId: string): AwsClients { elasticache: new ElastiCacheClient(base), lightsail: new LightsailClient(base), cognito: new CognitoIdentityProviderClient(base), + route53: new Route53Client(base), }; } diff --git a/packages/api/src/cloud-spi/dnsSchema.ts b/packages/api/src/cloud-spi/dnsSchema.ts new file mode 100644 index 0000000..b9355b7 --- /dev/null +++ b/packages/api/src/cloud-spi/dnsSchema.ts @@ -0,0 +1,64 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +/** A domain name of dot-separated labels, with or without the trailing dot. */ +export const DNS_ZONE_NAME_PATTERN = '^(?=.{1,253}\\.?$)([a-zA-Z0-9_](?:[a-zA-Z0-9_-]{0,61}[a-zA-Z0-9_])?\\.)*[a-zA-Z0-9_](?:[a-zA-Z0-9_-]{0,61}[a-zA-Z0-9_])?\\.?$' + +/** The record types Route 53 accepts in ChangeResourceRecordSets. */ +export const DNS_RECORD_TYPES = ['A', 'AAAA', 'CAA', 'CNAME', 'DS', 'HTTPS', 'MX', 'NAPTR', 'NS', 'PTR', 'SOA', 'SPF', 'SRV', 'SSHFP', 'SVCB', 'TLSA', 'TXT'] as const + +const zoneColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Domain'}, + {name: 'visibility', label: 'Type', path: 'metadata.visibility', format: 'badge'}, + {name: 'recordCount', label: 'Records', path: 'metadata.recordCount'}, + {name: 'comment', label: 'Description', path: 'metadata.comment', emptyText: '—'}, + {name: 'id', label: 'Hosted zone ID', format: 'code'}, +] + +const zoneFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}] + +export function awsDnsSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'dns', + displayName: 'Amazon Route 53 hosted zones', + fields: [ + { + name: 'name', + label: 'Domain name', + type: 'text', + required: true, + description: 'For example example.com. TheCloud does not resolve DNS: records are stored and returned by the Route 53 API.', + validation: {pattern: DNS_ZONE_NAME_PATTERN, message: 'Use a domain name such as example.com'}, + }, + {name: 'comment', label: 'Description', type: 'text', required: false}, + { + name: 'vpcId', + label: 'VPC ID (private zone)', + type: 'text', + required: false, + description: 'Leave empty for a public zone. With a VPC ID the zone is private and answers only inside that VPC.', + validation: {pattern: '^vpc-[0-9a-f]{8,17}$', message: 'Use a VPC ID such as vpc-0abc1234'}, + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List hosted zones', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create hosted zone', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete hosted zone', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect hosted zone', enabled: true, status: 'available', runtimeRequired: true}, + ], + itemActions: [ + {name: 'list', label: 'List records', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'put', label: 'Create or update a record', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete a record', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: zoneFilters, + columns: zoneColumns, + } +} + +export function dnsSchemaFor(cloud: CloudProvider): ServiceSchema | null { + return cloud === 'aws' ? awsDnsSchema() : null +} diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts index f21adf8..37efed8 100644 --- a/packages/api/src/cloud-spi/serviceCatalog.ts +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -239,6 +239,13 @@ export const SERVICE_CATALOG = { // AWS retains its dedicated page while Azure and GCP use Cloud Explorer. routeByCloud: {aws: '/secretsmanager'}, }, + dns: { + displayName: 'DNS', + displayNameByCloud: {aws: 'Route 53'}, + iconKey: 'dns', + group: 'Networking', + order: 40, + }, userpools: { displayName: 'User Directories', displayNameByCloud: {aws: 'Cognito'}, diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index 0d12799..ff5ec12 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -48,6 +48,7 @@ import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter' import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter' import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter' import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter' +import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter' import {awsClientsForAccount, resolveAccountId} from './aws' import {createEc2Service} from './services/ec2' import {createEksService} from './services/eks' @@ -98,6 +99,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AwsElastiCacheAdapter(clients.elasticache), new AwsLightsailAdapter(clients.lightsail), new AwsUserPoolsAdapter(clients.cognito), + new AwsDnsAdapter(clients.route53), new AzureStorageAdapter(), new AzureServiceBusAdapter(), new AzureDatabaseAdapter(), diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index 2cb5c9f..084ebbd 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -65,6 +65,7 @@ import { LoadBalancerPanel } from "@/components/LoadBalancerPanel"; import { TargetGroupPanel } from "@/components/TargetGroupPanel"; import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel"; import { CognitoUsersPanel } from "@/components/CognitoUsersPanel"; +import { Route53RecordsPanel } from "@/components/Route53RecordsPanel"; import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger"; import { CodeBuildPanel } from "@/components/CodeBuildPanel"; import { CodePipelinePanel } from "@/components/CodePipelinePanel"; @@ -979,6 +980,9 @@ export function DynamicResourceView({ {service === "userpools" && cloud === "aws" && ( )} + {service === "dns" && cloud === "aws" && ( + + )} {service === "compute" && ( ({ + deleteFlatItem: vi.fn(), + getCloudResource: vi.fn(), + listFlatItems: vi.fn(), + putFlatItem: vi.fn(), +})) + +vi.mock('@/api/cloudProxyClient', () => api) +vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'})) + +const zone: CloudResource = { + id: 'ZG9PAE0M9KRWJ4F', name: 'example.test', cloud: 'aws', service: 'dns', type: 'hosted-zone', + region: null, createdAt: null, metadata: {visibility: 'public', recordCount: 3}, +} + +function item(id: string, body: Record) { + return {id, collectionId: null, timestamp: null, metadata: {}, body: {ttl: 300, values: [], alias: null, setIdentifier: null, managed: false, editable: true, ...body}} +} + +const records = [ + item('example.test|NS', {name: 'example.test', type: 'NS', ttl: 172800, values: ['ns-1.awsdns-01.org.'], managed: true}), + item('www.example.test|A', {name: 'www.example.test', type: 'A', values: ['10.0.0.1']}), + item('example.test|TXT', {name: 'example.test', type: 'TXT', values: ['"v=spf1 -all"']}), + item('cdn.example.test|A', {name: 'cdn.example.test', type: 'A', ttl: null, alias: {dnsName: 'd111.cloudfront.net', hostedZoneId: 'Z2'}, editable: false}), +] + +function renderPanel() { + const client = new QueryClient({defaultOptions: {queries: {retry: false}}}) + return render() +} + +beforeEach(() => { + vi.clearAllMocks() + api.getCloudResource.mockResolvedValue({...zone, metadata: {...zone.metadata, nameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']}}) + api.listFlatItems.mockResolvedValue({items: records, nextCursor: null}) + api.putFlatItem.mockImplementation(async (_cloud, _service, _zone, body) => item('x', {name: `${body.name}.example.test`, type: body.type})) + api.deleteFlatItem.mockResolvedValue(undefined) +}) + +test('shows the name servers and records, keeping the zone defaults read-only', async () => { + renderPanel() + + expect(await screen.findByText('ns-2.awsdns-02.net')).toBeInTheDocument() + expect(await screen.findByText('10.0.0.1')).toBeInTheDocument() + expect(screen.getByText('Alias → d111.cloudfront.net')).toBeInTheDocument() + expect(screen.getByText('zone default')).toBeInTheDocument() + expect(screen.queryByRole('button', {name: 'Delete NS example.test'})).not.toBeInTheDocument() + expect(screen.queryByRole('button', {name: 'Edit A cdn.example.test'})).not.toBeInTheDocument() + expect(screen.getByRole('button', {name: 'Delete A cdn.example.test'})).toBeInTheDocument() +}) + +test('creates a record from a name inside the zone', async () => { + const user = userEvent.setup() + renderPanel() + + const form = await screen.findByRole('form', {name: 'Create record'}) + await user.type(within(form).getByLabelText(/Record name/), 'api') + await user.selectOptions(within(form).getByLabelText('Type'), 'CNAME') + await user.clear(within(form).getByLabelText(/TTL/)) + await user.type(within(form).getByLabelText(/TTL/), '60') + await user.type(within(form).getByLabelText(/Values/), 'app.example.net') + await user.click(within(form).getByRole('button', {name: 'Create record'})) + + expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'dns', zone.id, {name: 'api', type: 'CNAME', ttl: 60, values: ['app.example.net']}) + expect(await screen.findByText(/Created CNAME api.example.test/)).toBeInTheDocument() +}) + +test('edits a record with its name and type fixed, showing TXT text without quotes', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(await screen.findByRole('button', {name: 'Edit TXT example.test'})) + const form = screen.getByRole('form', {name: 'Edit record'}) + expect(within(form).getByLabelText(/Record name/)).toHaveValue('@') + expect(within(form).getByLabelText(/Record name/)).toBeDisabled() + expect(within(form).getByLabelText('Type')).toBeDisabled() + expect(within(form).getByLabelText(/Values/)).toHaveValue('v=spf1 -all') +}) + +test('deletes a record by its name and type', async () => { + const user = userEvent.setup() + renderPanel() + + await user.click(await screen.findByRole('button', {name: 'Delete A www.example.test'})) + expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'dns', zone.id, 'www.example.test|A') + expect(await screen.findByText('Deleted A www.example.test.')).toBeInTheDocument() +}) diff --git a/packages/frontend/src/components/Route53RecordsPanel.tsx b/packages/frontend/src/components/Route53RecordsPanel.tsx new file mode 100644 index 0000000..d89fc0e --- /dev/null +++ b/packages/frontend/src/components/Route53RecordsPanel.tsx @@ -0,0 +1,205 @@ +import {Pencil, RefreshCw, Trash2} from 'lucide-react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {useEffect, useState} from 'react' +import {deleteFlatItem, getCloudResource, listFlatItems, putFlatItem} from '@/api/cloudProxyClient' +import {CopyValueButton} from '@/components/CopyValueButton' +import {useAccountId} from '@/lib/accountStore' +import type {ChildItem, CloudResource} from '@/types/resource' + +type Props = { + resource?: CloudResource + runtimeReachable: boolean +} + +type DnsRecord = { + id: string + name: string + type: string + ttl: number | null + values: string[] + alias: {dnsName: string; hostedZoneId: string} | null + setIdentifier: string | null + managed: boolean + editable: boolean +} + +const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA', 'NS', 'PTR', 'SPF', 'DS', 'HTTPS', 'SVCB', 'NAPTR', 'SSHFP', 'TLSA'] +const VALUE_HINTS: Record = { + A: 'IPv4 addresses, one per line: 192.0.2.10', + AAAA: 'IPv6 addresses, one per line: 2001:db8::10', + CNAME: 'One host name: app.example.net', + TXT: 'Text, one string per line. Quotes are added for you.', + MX: 'Priority and mail server, one per line: 10 mail.example.com', + SRV: 'Priority, weight, port and target: 10 5 5060 sip.example.com', + CAA: 'Flags, tag and value: 0 issue "amazon.com"', +} +/** Every page of a zone is fetched; zones on a local runtime are small. */ +const MAX_PAGES = 20 + +const emptyDraft = {name: '', type: 'A', ttl: '300', values: ''} + +export function Route53RecordsPanel({resource, runtimeReachable}: Props) { + const accountId = useAccountId() + const queryClient = useQueryClient() + const [draft, setDraft] = useState(emptyDraft) + const [editing, setEditing] = useState(null) + const [notice, setNotice] = useState('') + const recordsKey = ['dns-records', accountId, resource?.id] + + const zoneQuery = useQuery({ + queryKey: ['dns-zone', accountId, resource?.id], + queryFn: ({signal}) => getCloudResource('aws', 'dns', resource?.id ?? '', signal), + enabled: Boolean(resource) && runtimeReachable, + }) + const recordsQuery = useQuery({ + queryKey: recordsKey, + queryFn: async ({signal}) => { + const items: ChildItem[] = [] + let cursor: string | undefined + for (let page = 0; page < MAX_PAGES; page++) { + const result = await listFlatItems('aws', 'dns', resource?.id ?? '', cursor, signal) + items.push(...result.items) + if (!result.nextCursor) break + cursor = result.nextCursor + } + return items.map(toRecord) + }, + enabled: Boolean(resource) && runtimeReachable, + }) + + useEffect(() => { + setDraft(emptyDraft) + setEditing(null) + setNotice('') + }, [resource?.id]) + + // The zone list shows each zone's record count. + const refresh = () => Promise.all([ + queryClient.invalidateQueries({queryKey: recordsKey}), + queryClient.invalidateQueries({queryKey: ['dns-zone', accountId, resource?.id]}), + queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'dns']}), + ]) + + const saveMutation = useMutation({ + mutationFn: () => putFlatItem('aws', 'dns', resource?.id ?? '', { + name: draft.name, + type: draft.type, + ttl: Number(draft.ttl), + values: draft.values.split('\n'), + }), + onSuccess: (saved) => { + setNotice(`${editing ? 'Saved' : 'Created'} ${String(saved.body.type)} ${String(saved.body.name)}.`) + setDraft(emptyDraft) + setEditing(null) + return refresh() + }, + }) + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteFlatItem('aws', 'dns', resource?.id ?? '', id), + onSuccess: (_result, id) => { + setNotice(`Deleted ${id.split('|').slice(0, 2).reverse().join(' ')}.`) + if (editing === id) { setEditing(null); setDraft(emptyDraft) } + return refresh() + }, + }) + + if (!resource) { + return

Select a hosted zone

Choose a hosted zone to see and change its DNS records.

+ } + + const zoneName = resource.name + const records = recordsQuery.data ?? [] + const nameServers = stringList(zoneQuery.data?.metadata.nameServers) + const pending = saveMutation.isPending || deleteMutation.isPending + const disabled = !runtimeReachable || pending + const error = zoneQuery.error ?? recordsQuery.error ?? saveMutation.error ?? deleteMutation.error + const edit = (record: DnsRecord) => { + setNotice('') + setEditing(record.id) + setDraft({name: relativeName(record.name, zoneName), type: record.type, ttl: String(record.ttl ?? 300), values: record.values.map((value) => record.type === 'TXT' || record.type === 'SPF' ? unquote(value) : value).join('\n')}) + } + + return
+ {nameServers.length > 0 && <> +
+

Name servers

Delegate {zoneName} to these at your registrar. TheCloud stores records but does not answer DNS queries.
+
+
    {nameServers.map((server) =>
  • {server}
  • )}
+ } + +
+

Records

{records.length} {records.length === 1 ? 'record' : 'records'} in {zoneName}
+ +
+ +
{ event.preventDefault(); setNotice(''); saveMutation.mutate() }}> + + + +