// 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 }