1af1db7ffc
- AWSService: SigV4 request signing, XML parser, credential management - AWSS3Service: bucket/object CRUD, website hosting, deploy pipeline - AWSRoute53Service: hosted zones, DNS records, S3/CloudFront aliases - AWSCloudFrontService: distributions, cache invalidation, ACM certs - AWSLambdaService: functions, invoke, zip deploy - AWSTools: 15 MCP agent tools for all AWS operations - CredentialStore: AWS credential convenience properties - AWSAndWebsiteTests: 72 tests covering models, XML parsing, errors, credentials, templates, deploy providers (165 total tests passing)
347 lines
12 KiB
Swift
347 lines
12 KiB
Swift
// AWSRoute53Service.swift
|
|
// CxIDE — AWS Route 53 DNS management
|
|
//
|
|
// Provides hosted zone management, DNS record CRUD, and domain routing
|
|
// for websites deployed to S3, CloudFront, or other AWS services.
|
|
|
|
import Foundation
|
|
|
|
// MARK: - Route 53 Service
|
|
|
|
final class AWSRoute53Service: @unchecked Sendable {
|
|
static let shared = AWSRoute53Service()
|
|
|
|
private let aws: AWSService
|
|
|
|
init(aws: AWSService = .shared) {
|
|
self.aws = aws
|
|
}
|
|
|
|
// Route 53 uses us-east-1 globally
|
|
private let route53Region = "us-east-1"
|
|
|
|
// MARK: - Hosted Zones
|
|
|
|
/// List all hosted zones
|
|
func listHostedZones() async throws -> [Route53HostedZone] {
|
|
let (data, _) = try await aws.request(
|
|
service: "route53",
|
|
region: route53Region,
|
|
method: "GET",
|
|
path: "/2013-04-01/hostedzone"
|
|
)
|
|
let xml = try AWSXMLParser().parse(data: data)
|
|
return xml.allDescendants("HostedZone").compactMap { el -> Route53HostedZone? in
|
|
guard let id = el.child("Id")?.text,
|
|
let name = el.child("Name")?.text else { return nil }
|
|
let count = Int(el.child("ResourceRecordSetCount")?.text ?? "0") ?? 0
|
|
let comment = el.descendant("Comment")?.text
|
|
let isPrivate = el.descendant("PrivateZone")?.text == "true"
|
|
// ID comes as "/hostedzone/XXXXX", extract just the ID
|
|
let cleanId = id.replacingOccurrences(of: "/hostedzone/", with: "")
|
|
return Route53HostedZone(
|
|
id: cleanId, name: name, recordCount: count,
|
|
comment: comment, isPrivate: isPrivate
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Create a hosted zone for a domain
|
|
func createHostedZone(domain: String, comment: String = "Created by CxIDE") async throws -> Route53HostedZone {
|
|
let callerRef = UUID().uuidString
|
|
let xml = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<CreateHostedZoneRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
|
|
<Name>\(domain)</Name>
|
|
<CallerReference>\(callerRef)</CallerReference>
|
|
<HostedZoneConfig>
|
|
<Comment>\(comment)</Comment>
|
|
</HostedZoneConfig>
|
|
</CreateHostedZoneRequest>
|
|
"""
|
|
|
|
let (data, _) = try await aws.request(
|
|
service: "route53",
|
|
region: route53Region,
|
|
method: "POST",
|
|
path: "/2013-04-01/hostedzone",
|
|
body: Data(xml.utf8),
|
|
contentType: "application/xml"
|
|
)
|
|
|
|
let responseXml = try AWSXMLParser().parse(data: data)
|
|
guard let zone = responseXml.descendant("HostedZone"),
|
|
let id = zone.child("Id")?.text,
|
|
let name = zone.child("Name")?.text else {
|
|
throw AWSError.invalidXML("Failed to parse CreateHostedZone response")
|
|
}
|
|
|
|
let cleanId = id.replacingOccurrences(of: "/hostedzone/", with: "")
|
|
|
|
// Extract nameservers
|
|
let nsRecords = responseXml.allDescendants("Nameserver").map { $0.text }
|
|
|
|
return Route53HostedZone(
|
|
id: cleanId, name: name, recordCount: 2,
|
|
comment: comment, isPrivate: false, nameservers: nsRecords
|
|
)
|
|
}
|
|
|
|
/// Delete a hosted zone
|
|
func deleteHostedZone(id: String) async throws {
|
|
_ = try await aws.request(
|
|
service: "route53",
|
|
region: route53Region,
|
|
method: "DELETE",
|
|
path: "/2013-04-01/hostedzone/\(id)"
|
|
)
|
|
}
|
|
|
|
// MARK: - DNS Records
|
|
|
|
/// List DNS records in a hosted zone
|
|
func listRecordSets(zoneId: String) async throws -> [Route53RecordSet] {
|
|
let (data, _) = try await aws.request(
|
|
service: "route53",
|
|
region: route53Region,
|
|
method: "GET",
|
|
path: "/2013-04-01/hostedzone/\(zoneId)/rrset"
|
|
)
|
|
let xml = try AWSXMLParser().parse(data: data)
|
|
return xml.allDescendants("ResourceRecordSet").compactMap { el -> Route53RecordSet? in
|
|
guard let name = el.child("Name")?.text,
|
|
let typeStr = el.child("Type")?.text else { return nil }
|
|
let ttl = Int(el.child("TTL")?.text ?? "300") ?? 300
|
|
let values = el.allDescendants("Value").map { $0.text }
|
|
|
|
// Alias record
|
|
let aliasTarget = el.child("AliasTarget")
|
|
let alias: Route53AliasTarget?
|
|
if let at = aliasTarget,
|
|
let hostedZone = at.child("HostedZoneId")?.text,
|
|
let dnsName = at.child("DNSName")?.text {
|
|
alias = Route53AliasTarget(
|
|
hostedZoneId: hostedZone,
|
|
dnsName: dnsName,
|
|
evaluateTargetHealth: at.child("EvaluateTargetHealth")?.text == "true"
|
|
)
|
|
} else {
|
|
alias = nil
|
|
}
|
|
|
|
return Route53RecordSet(
|
|
name: name, type: typeStr, ttl: ttl,
|
|
values: values, aliasTarget: alias
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Create or update DNS records (UPSERT)
|
|
func upsertRecord(
|
|
zoneId: String,
|
|
name: String,
|
|
type: String,
|
|
values: [String],
|
|
ttl: Int = 300
|
|
) async throws {
|
|
let resourceRecords = values
|
|
.map { "<ResourceRecord><Value>\($0)</Value></ResourceRecord>" }
|
|
.joined()
|
|
|
|
let xml = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
|
|
<ChangeBatch>
|
|
<Changes>
|
|
<Change>
|
|
<Action>UPSERT</Action>
|
|
<ResourceRecordSet>
|
|
<Name>\(name)</Name>
|
|
<Type>\(type)</Type>
|
|
<TTL>\(ttl)</TTL>
|
|
<ResourceRecords>\(resourceRecords)</ResourceRecords>
|
|
</ResourceRecordSet>
|
|
</Change>
|
|
</Changes>
|
|
</ChangeBatch>
|
|
</ChangeResourceRecordSetsRequest>
|
|
"""
|
|
|
|
_ = try await aws.request(
|
|
service: "route53",
|
|
region: route53Region,
|
|
method: "POST",
|
|
path: "/2013-04-01/hostedzone/\(zoneId)/rrset",
|
|
body: Data(xml.utf8),
|
|
contentType: "application/xml"
|
|
)
|
|
}
|
|
|
|
/// Create an alias record (for S3, CloudFront, etc.)
|
|
func upsertAliasRecord(
|
|
zoneId: String,
|
|
name: String,
|
|
type: String,
|
|
targetHostedZoneId: String,
|
|
targetDNSName: String,
|
|
evaluateHealth: Bool = false
|
|
) async throws {
|
|
let xml = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
|
|
<ChangeBatch>
|
|
<Changes>
|
|
<Change>
|
|
<Action>UPSERT</Action>
|
|
<ResourceRecordSet>
|
|
<Name>\(name)</Name>
|
|
<Type>\(type)</Type>
|
|
<AliasTarget>
|
|
<HostedZoneId>\(targetHostedZoneId)</HostedZoneId>
|
|
<DNSName>\(targetDNSName)</DNSName>
|
|
<EvaluateTargetHealth>\(evaluateHealth)</EvaluateTargetHealth>
|
|
</AliasTarget>
|
|
</ResourceRecordSet>
|
|
</Change>
|
|
</Changes>
|
|
</ChangeBatch>
|
|
</ChangeResourceRecordSetsRequest>
|
|
"""
|
|
|
|
_ = try await aws.request(
|
|
service: "route53",
|
|
region: route53Region,
|
|
method: "POST",
|
|
path: "/2013-04-01/hostedzone/\(zoneId)/rrset",
|
|
body: Data(xml.utf8),
|
|
contentType: "application/xml"
|
|
)
|
|
}
|
|
|
|
/// Delete a DNS record
|
|
func deleteRecord(
|
|
zoneId: String,
|
|
name: String,
|
|
type: String,
|
|
values: [String],
|
|
ttl: Int = 300
|
|
) async throws {
|
|
let resourceRecords = values
|
|
.map { "<ResourceRecord><Value>\($0)</Value></ResourceRecord>" }
|
|
.joined()
|
|
|
|
let xml = """
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
|
|
<ChangeBatch>
|
|
<Changes>
|
|
<Change>
|
|
<Action>DELETE</Action>
|
|
<ResourceRecordSet>
|
|
<Name>\(name)</Name>
|
|
<Type>\(type)</Type>
|
|
<TTL>\(ttl)</TTL>
|
|
<ResourceRecords>\(resourceRecords)</ResourceRecords>
|
|
</ResourceRecordSet>
|
|
</Change>
|
|
</Changes>
|
|
</ChangeBatch>
|
|
</ChangeResourceRecordSetsRequest>
|
|
"""
|
|
|
|
_ = try await aws.request(
|
|
service: "route53",
|
|
region: route53Region,
|
|
method: "POST",
|
|
path: "/2013-04-01/hostedzone/\(zoneId)/rrset",
|
|
body: Data(xml.utf8),
|
|
contentType: "application/xml"
|
|
)
|
|
}
|
|
|
|
// MARK: - Convenience: Find hosted zone by domain
|
|
|
|
/// Find a hosted zone by domain name
|
|
func findHostedZone(domain: String) async throws -> Route53HostedZone? {
|
|
let zones = try await listHostedZones()
|
|
let normalized = domain.hasSuffix(".") ? domain : "\(domain)."
|
|
return zones.first { $0.name == normalized }
|
|
}
|
|
|
|
// MARK: - Convenience: Point domain to S3 Website
|
|
|
|
/// Configure Route 53 to point a domain to an S3 website bucket
|
|
func pointToS3Website(zoneId: String, domain: String, bucket: String, region: String) async throws {
|
|
// S3 website hosted zone IDs per region
|
|
let s3HostedZoneId = s3WebsiteHostedZoneId(region: region)
|
|
|
|
try await upsertAliasRecord(
|
|
zoneId: zoneId,
|
|
name: domain,
|
|
type: "A",
|
|
targetHostedZoneId: s3HostedZoneId,
|
|
targetDNSName: "s3-website-\(region).amazonaws.com"
|
|
)
|
|
}
|
|
|
|
/// Configure Route 53 to point a domain to a CloudFront distribution
|
|
func pointToCloudFront(zoneId: String, domain: String, distributionDomain: String) async throws {
|
|
try await upsertAliasRecord(
|
|
zoneId: zoneId,
|
|
name: domain,
|
|
type: "A",
|
|
targetHostedZoneId: "Z2FDTNDATAQYW2", // CloudFront global hosted zone ID
|
|
targetDNSName: distributionDomain
|
|
)
|
|
}
|
|
|
|
// S3 website endpoint hosted zone IDs by region
|
|
private func s3WebsiteHostedZoneId(region: String) -> String {
|
|
let mapping: [String: String] = [
|
|
"us-east-1": "Z3AQBSTGFYJSTF",
|
|
"us-east-2": "Z2O1EMRO9K5GLX",
|
|
"us-west-1": "Z2F56UZL2M1ACD",
|
|
"us-west-2": "Z3BJ6K6RIION7M",
|
|
"eu-west-1": "Z1BKCTXD74EZPE",
|
|
"eu-west-2": "Z3GKZC51ZF0DB4",
|
|
"eu-central-1": "Z21DNDUVLTQW6Q",
|
|
"ap-southeast-1": "Z3O0J2DXBE1FTB",
|
|
"ap-southeast-2": "Z1WCIBER0Y3ULH",
|
|
"ap-northeast-1": "Z2M4EHUR26P7ZW",
|
|
]
|
|
return mapping[region] ?? mapping["us-east-1"]!
|
|
}
|
|
}
|
|
|
|
// MARK: - Route 53 Models
|
|
|
|
struct Route53HostedZone: Sendable {
|
|
let id: String
|
|
let name: String
|
|
let recordCount: Int
|
|
let comment: String?
|
|
let isPrivate: Bool
|
|
var nameservers: [String] = []
|
|
|
|
var domainName: String {
|
|
// Remove trailing dot
|
|
name.hasSuffix(".") ? String(name.dropLast()) : name
|
|
}
|
|
}
|
|
|
|
struct Route53RecordSet: Sendable {
|
|
let name: String
|
|
let type: String
|
|
let ttl: Int
|
|
let values: [String]
|
|
let aliasTarget: Route53AliasTarget?
|
|
|
|
var isAlias: Bool { aliasTarget != nil }
|
|
}
|
|
|
|
struct Route53AliasTarget: Sendable {
|
|
let hostedZoneId: String
|
|
let dnsName: String
|
|
let evaluateTargetHealth: Bool
|
|
}
|