// 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 = """
\(domain)
\(callerRef)
\(comment)
"""
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 { "\($0)" }
.joined()
let xml = """
UPSERT
\(name)
\(type)
\(ttl)
\(resourceRecords)
"""
_ = 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 = """
UPSERT
\(name)
\(type)
\(targetHostedZoneId)
\(targetDNSName)
\(evaluateHealth)
"""
_ = 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 { "\($0)" }
.joined()
let xml = """
DELETE
\(name)
\(type)
\(ttl)
\(resourceRecords)
"""
_ = 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
}