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)
507 lines
18 KiB
Swift
507 lines
18 KiB
Swift
// AWSService.swift
|
|
// CxIDE — Core AWS client with Signature V4 authentication
|
|
//
|
|
// Implements AWS SigV4 request signing and provides the shared HTTP client
|
|
// for all AWS service integrations. Credentials are loaded from the
|
|
// encrypted CredentialStore — never stored in source.
|
|
|
|
import Foundation
|
|
import CryptoKit
|
|
|
|
// MARK: - AWS Service (Core)
|
|
|
|
final class AWSService: @unchecked Sendable {
|
|
static let shared = AWSService()
|
|
|
|
private let credentialStore: CredentialStore
|
|
|
|
init(credentialStore: CredentialStore = .shared) {
|
|
self.credentialStore = credentialStore
|
|
}
|
|
|
|
// MARK: - Credential Access
|
|
|
|
var accessKeyId: String? { credentialStore.get("AWS_ACCESS_KEY_ID") }
|
|
var secretAccessKey: String? { credentialStore.get("AWS_SECRET_ACCESS_KEY") }
|
|
var sessionToken: String? { credentialStore.get("AWS_SESSION_TOKEN") }
|
|
var defaultRegion: String { credentialStore.get("AWS_DEFAULT_REGION") ?? "us-east-1" }
|
|
|
|
func setCredentials(accessKeyId: String, secretAccessKey: String, region: String = "us-east-1") {
|
|
credentialStore.set("AWS_ACCESS_KEY_ID", value: accessKeyId)
|
|
credentialStore.set("AWS_SECRET_ACCESS_KEY", value: secretAccessKey)
|
|
credentialStore.set("AWS_DEFAULT_REGION", value: region)
|
|
}
|
|
|
|
var isAuthenticated: Bool {
|
|
guard let k = accessKeyId, let s = secretAccessKey else { return false }
|
|
return !k.isEmpty && !s.isEmpty
|
|
}
|
|
|
|
// MARK: - AWS Regions
|
|
|
|
enum Region: String, CaseIterable, Sendable {
|
|
case usEast1 = "us-east-1"
|
|
case usEast2 = "us-east-2"
|
|
case usWest1 = "us-west-1"
|
|
case usWest2 = "us-west-2"
|
|
case euWest1 = "eu-west-1"
|
|
case euWest2 = "eu-west-2"
|
|
case euCentral1 = "eu-central-1"
|
|
case apSoutheast1 = "ap-southeast-1"
|
|
case apSoutheast2 = "ap-southeast-2"
|
|
case apNortheast1 = "ap-northeast-1"
|
|
}
|
|
|
|
// MARK: - Signed Request
|
|
|
|
/// Execute an AWS API request with SigV4 authentication
|
|
func request(
|
|
service: String,
|
|
region: String? = nil,
|
|
method: String = "GET",
|
|
path: String = "/",
|
|
query: [String: String] = [:],
|
|
headers: [String: String] = [:],
|
|
body: Data? = nil,
|
|
contentType: String = "application/json"
|
|
) async throws -> (data: Data, statusCode: Int) {
|
|
let region = region ?? defaultRegion
|
|
guard let accessKey = accessKeyId, let secretKey = secretAccessKey else {
|
|
throw AWSError.notAuthenticated
|
|
}
|
|
|
|
let host = "\(service).\(region).amazonaws.com"
|
|
let endpoint: String
|
|
if service == "s3" {
|
|
endpoint = "https://s3.\(region).amazonaws.com"
|
|
} else {
|
|
endpoint = "https://\(host)"
|
|
}
|
|
|
|
// Build URL with query parameters
|
|
var urlComponents = URLComponents(string: endpoint + path)!
|
|
if !query.isEmpty {
|
|
urlComponents.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
|
|
}
|
|
guard let url = urlComponents.url else {
|
|
throw AWSError.invalidURL(endpoint + path)
|
|
}
|
|
|
|
// Timestamp
|
|
let now = Date()
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.timeZone = TimeZone(identifier: "UTC")
|
|
dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
|
|
let amzDate = dateFormatter.string(from: now)
|
|
dateFormatter.dateFormat = "yyyyMMdd"
|
|
let dateStamp = dateFormatter.string(from: now)
|
|
|
|
// Payload hash
|
|
let payload = body ?? Data()
|
|
let payloadHash = SHA256.hash(data: payload).hexString
|
|
|
|
// Canonical query string (sorted)
|
|
let canonicalQueryString: String
|
|
if let queryItems = urlComponents.queryItems, !queryItems.isEmpty {
|
|
canonicalQueryString = queryItems
|
|
.sorted { $0.name < $1.name }
|
|
.map { "\(percentEncode($0.name))=\(percentEncode($0.value ?? ""))" }
|
|
.joined(separator: "&")
|
|
} else {
|
|
canonicalQueryString = ""
|
|
}
|
|
|
|
// Build headers
|
|
var signHeaders: [String: String] = [
|
|
"host": host,
|
|
"x-amz-date": amzDate,
|
|
"x-amz-content-sha256": payloadHash,
|
|
]
|
|
if let token = sessionToken {
|
|
signHeaders["x-amz-security-token"] = token
|
|
}
|
|
for (k, v) in headers {
|
|
signHeaders[k.lowercased()] = v
|
|
}
|
|
|
|
let signedHeaderNames = signHeaders.keys.sorted().joined(separator: ";")
|
|
let canonicalHeaders = signHeaders.keys.sorted()
|
|
.map { "\($0):\(signHeaders[$0]!.trimmingCharacters(in: .whitespaces))\n" }
|
|
.joined()
|
|
|
|
// Canonical request
|
|
let canonicalRequest = [
|
|
method,
|
|
uriEncode(path),
|
|
canonicalQueryString,
|
|
canonicalHeaders,
|
|
signedHeaderNames,
|
|
payloadHash,
|
|
].joined(separator: "\n")
|
|
|
|
let canonicalRequestHash = SHA256.hash(data: Data(canonicalRequest.utf8)).hexString
|
|
|
|
// String to sign
|
|
let credentialScope = "\(dateStamp)/\(region)/\(service)/aws4_request"
|
|
let stringToSign = [
|
|
"AWS4-HMAC-SHA256",
|
|
amzDate,
|
|
credentialScope,
|
|
canonicalRequestHash,
|
|
].joined(separator: "\n")
|
|
|
|
// Signing key
|
|
let kDate = hmacSHA256(key: Data("AWS4\(secretKey)".utf8), data: Data(dateStamp.utf8))
|
|
let kRegion = hmacSHA256(key: kDate, data: Data(region.utf8))
|
|
let kService = hmacSHA256(key: kRegion, data: Data(service.utf8))
|
|
let kSigning = hmacSHA256(key: kService, data: Data("aws4_request".utf8))
|
|
|
|
// Signature
|
|
let signature = hmacSHA256(key: kSigning, data: Data(stringToSign.utf8)).hexString
|
|
|
|
// Authorization header
|
|
let authorization = "AWS4-HMAC-SHA256 Credential=\(accessKey)/\(credentialScope), SignedHeaders=\(signedHeaderNames), Signature=\(signature)"
|
|
|
|
// Build URLRequest
|
|
var urlRequest = URLRequest(url: url)
|
|
urlRequest.httpMethod = method
|
|
urlRequest.httpBody = body
|
|
urlRequest.timeoutInterval = 60
|
|
urlRequest.setValue(authorization, forHTTPHeaderField: "Authorization")
|
|
urlRequest.setValue(amzDate, forHTTPHeaderField: "x-amz-date")
|
|
urlRequest.setValue(payloadHash, forHTTPHeaderField: "x-amz-content-sha256")
|
|
urlRequest.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
|
if let token = sessionToken {
|
|
urlRequest.setValue(token, forHTTPHeaderField: "x-amz-security-token")
|
|
}
|
|
for (k, v) in headers {
|
|
urlRequest.setValue(v, forHTTPHeaderField: k)
|
|
}
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: urlRequest)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw AWSError.invalidResponse
|
|
}
|
|
|
|
if httpResponse.statusCode >= 400 {
|
|
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
throw AWSError.apiError(statusCode: httpResponse.statusCode, body: errorBody)
|
|
}
|
|
|
|
return (data, httpResponse.statusCode)
|
|
}
|
|
|
|
// MARK: - S3 Request (different host pattern)
|
|
|
|
/// Execute an S3 request (supports path-style and virtual-hosted-style)
|
|
func s3Request(
|
|
bucket: String? = nil,
|
|
key: String? = nil,
|
|
region: String? = nil,
|
|
method: String = "GET",
|
|
query: [String: String] = [:],
|
|
headers: [String: String] = [:],
|
|
body: Data? = nil,
|
|
contentType: String = "application/octet-stream"
|
|
) async throws -> (data: Data, statusCode: Int) {
|
|
let region = region ?? defaultRegion
|
|
guard let accessKey = accessKeyId, let secretKey = secretAccessKey else {
|
|
throw AWSError.notAuthenticated
|
|
}
|
|
|
|
// Build host and path (path-style addressing)
|
|
let host: String
|
|
let path: String
|
|
if let bucket = bucket {
|
|
host = "s3.\(region).amazonaws.com"
|
|
if let key = key {
|
|
path = "/\(bucket)/\(key)"
|
|
} else {
|
|
path = "/\(bucket)"
|
|
}
|
|
} else {
|
|
host = "s3.\(region).amazonaws.com"
|
|
path = "/"
|
|
}
|
|
|
|
let endpoint = "https://\(host)"
|
|
|
|
var urlComponents = URLComponents(string: endpoint + path)!
|
|
if !query.isEmpty {
|
|
urlComponents.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
|
|
}
|
|
guard let url = urlComponents.url else {
|
|
throw AWSError.invalidURL(endpoint + path)
|
|
}
|
|
|
|
let now = Date()
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.timeZone = TimeZone(identifier: "UTC")
|
|
dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
|
|
let amzDate = dateFormatter.string(from: now)
|
|
dateFormatter.dateFormat = "yyyyMMdd"
|
|
let dateStamp = dateFormatter.string(from: now)
|
|
|
|
let payload = body ?? Data()
|
|
let payloadHash = SHA256.hash(data: payload).hexString
|
|
|
|
let canonicalQueryString: String
|
|
if let qi = urlComponents.queryItems, !qi.isEmpty {
|
|
canonicalQueryString = qi.sorted { $0.name < $1.name }
|
|
.map { "\(percentEncode($0.name))=\(percentEncode($0.value ?? ""))" }
|
|
.joined(separator: "&")
|
|
} else {
|
|
canonicalQueryString = ""
|
|
}
|
|
|
|
var signHeaders: [String: String] = [
|
|
"host": host,
|
|
"x-amz-date": amzDate,
|
|
"x-amz-content-sha256": payloadHash,
|
|
]
|
|
if contentType != "application/octet-stream" || body != nil {
|
|
signHeaders["content-type"] = contentType
|
|
}
|
|
if let token = sessionToken {
|
|
signHeaders["x-amz-security-token"] = token
|
|
}
|
|
for (k, v) in headers {
|
|
signHeaders[k.lowercased()] = v
|
|
}
|
|
|
|
let signedHeaderNames = signHeaders.keys.sorted().joined(separator: ";")
|
|
let canonicalHeaders = signHeaders.keys.sorted()
|
|
.map { "\($0):\(signHeaders[$0]!.trimmingCharacters(in: .whitespaces))\n" }
|
|
.joined()
|
|
|
|
let canonicalRequest = [
|
|
method,
|
|
uriEncode(path),
|
|
canonicalQueryString,
|
|
canonicalHeaders,
|
|
signedHeaderNames,
|
|
payloadHash,
|
|
].joined(separator: "\n")
|
|
|
|
let canonicalRequestHash = SHA256.hash(data: Data(canonicalRequest.utf8)).hexString
|
|
let credentialScope = "\(dateStamp)/\(region)/s3/aws4_request"
|
|
let stringToSign = [
|
|
"AWS4-HMAC-SHA256",
|
|
amzDate,
|
|
credentialScope,
|
|
canonicalRequestHash,
|
|
].joined(separator: "\n")
|
|
|
|
let kDate = hmacSHA256(key: Data("AWS4\(secretKey)".utf8), data: Data(dateStamp.utf8))
|
|
let kRegion = hmacSHA256(key: kDate, data: Data(region.utf8))
|
|
let kService = hmacSHA256(key: kRegion, data: Data("s3".utf8))
|
|
let kSigning = hmacSHA256(key: kService, data: Data("aws4_request".utf8))
|
|
let signature = hmacSHA256(key: kSigning, data: Data(stringToSign.utf8)).hexString
|
|
|
|
let authorization = "AWS4-HMAC-SHA256 Credential=\(accessKey)/\(credentialScope), SignedHeaders=\(signedHeaderNames), Signature=\(signature)"
|
|
|
|
var urlRequest = URLRequest(url: url)
|
|
urlRequest.httpMethod = method
|
|
urlRequest.httpBody = body
|
|
urlRequest.timeoutInterval = 120
|
|
urlRequest.setValue(authorization, forHTTPHeaderField: "Authorization")
|
|
urlRequest.setValue(amzDate, forHTTPHeaderField: "x-amz-date")
|
|
urlRequest.setValue(payloadHash, forHTTPHeaderField: "x-amz-content-sha256")
|
|
if contentType != "application/octet-stream" || body != nil {
|
|
urlRequest.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
|
}
|
|
if let token = sessionToken {
|
|
urlRequest.setValue(token, forHTTPHeaderField: "x-amz-security-token")
|
|
}
|
|
for (k, v) in headers {
|
|
urlRequest.setValue(v, forHTTPHeaderField: k)
|
|
}
|
|
|
|
let (data, response) = try await URLSession.shared.data(for: urlRequest)
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw AWSError.invalidResponse
|
|
}
|
|
|
|
if httpResponse.statusCode >= 400 {
|
|
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
throw AWSError.apiError(statusCode: httpResponse.statusCode, body: errorBody)
|
|
}
|
|
|
|
return (data, httpResponse.statusCode)
|
|
}
|
|
|
|
// MARK: - SigV4 Helpers
|
|
|
|
private func hmacSHA256(key: Data, data: Data) -> Data {
|
|
let hmac = HMAC<SHA256>.authenticationCode(for: data, using: SymmetricKey(data: key))
|
|
return Data(hmac)
|
|
}
|
|
|
|
private func percentEncode(_ string: String) -> String {
|
|
var allowed = CharacterSet.alphanumerics
|
|
allowed.insert(charactersIn: "-._~")
|
|
return string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string
|
|
}
|
|
|
|
private func uriEncode(_ path: String) -> String {
|
|
// Encode each path segment individually (preserving /)
|
|
path.split(separator: "/", omittingEmptySubsequences: false)
|
|
.map { percentEncode(String($0)) }
|
|
.joined(separator: "/")
|
|
}
|
|
}
|
|
|
|
// MARK: - SHA256 Hex Extension
|
|
|
|
extension SHA256Digest {
|
|
var hexString: String {
|
|
self.map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
}
|
|
|
|
// MARK: - Data Hex Extension
|
|
|
|
extension Data {
|
|
var hexString: String {
|
|
self.map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
}
|
|
|
|
// MARK: - AWS Error
|
|
|
|
enum AWSError: LocalizedError, Sendable {
|
|
case notAuthenticated
|
|
case invalidURL(String)
|
|
case invalidResponse
|
|
case apiError(statusCode: Int, body: String)
|
|
case bucketNotFound(String)
|
|
case objectNotFound(String)
|
|
case bucketAlreadyExists(String)
|
|
case accessDenied(String)
|
|
case invalidXML(String)
|
|
case deploymentFailed(String)
|
|
case distributionNotFound(String)
|
|
case hostedZoneNotFound(String)
|
|
case functionNotFound(String)
|
|
case timeout
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notAuthenticated:
|
|
return "AWS credentials not configured. Use credential_set to store AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY."
|
|
case .invalidURL(let url):
|
|
return "Invalid AWS URL: \(url)"
|
|
case .invalidResponse:
|
|
return "Invalid response from AWS API"
|
|
case .apiError(let code, let body):
|
|
return "AWS API error (\(code)): \(body.prefix(500))"
|
|
case .bucketNotFound(let name):
|
|
return "S3 bucket not found: \(name)"
|
|
case .objectNotFound(let key):
|
|
return "S3 object not found: \(key)"
|
|
case .bucketAlreadyExists(let name):
|
|
return "S3 bucket already exists: \(name)"
|
|
case .accessDenied(let resource):
|
|
return "Access denied: \(resource)"
|
|
case .invalidXML(let detail):
|
|
return "Failed to parse AWS XML response: \(detail)"
|
|
case .deploymentFailed(let detail):
|
|
return "Deployment failed: \(detail)"
|
|
case .distributionNotFound(let id):
|
|
return "CloudFront distribution not found: \(id)"
|
|
case .hostedZoneNotFound(let name):
|
|
return "Route 53 hosted zone not found: \(name)"
|
|
case .functionNotFound(let name):
|
|
return "Lambda function not found: \(name)"
|
|
case .timeout:
|
|
return "AWS request timed out"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Simple XML Parser
|
|
|
|
/// Lightweight XML element for parsing AWS responses (no external dependencies)
|
|
final class AWSXMLElement {
|
|
let name: String
|
|
var text: String = ""
|
|
var children: [AWSXMLElement] = []
|
|
var attributes: [String: String] = [:]
|
|
|
|
init(name: String) { self.name = name }
|
|
|
|
func child(_ name: String) -> AWSXMLElement? {
|
|
children.first { $0.name == name }
|
|
}
|
|
|
|
func allChildren(_ name: String) -> [AWSXMLElement] {
|
|
children.filter { $0.name == name }
|
|
}
|
|
|
|
/// Recursively find first descendant with given name
|
|
func descendant(_ name: String) -> AWSXMLElement? {
|
|
for c in children {
|
|
if c.name == name { return c }
|
|
if let found = c.descendant(name) { return found }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func allDescendants(_ name: String) -> [AWSXMLElement] {
|
|
var result: [AWSXMLElement] = []
|
|
for c in children {
|
|
if c.name == name { result.append(c) }
|
|
result.append(contentsOf: c.allDescendants(name))
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
/// Minimal XML parser using Foundation's XMLParser
|
|
final class AWSXMLParser: NSObject, XMLParserDelegate {
|
|
private var root: AWSXMLElement?
|
|
private var stack: [AWSXMLElement] = []
|
|
private var currentText = ""
|
|
|
|
func parse(data: Data) throws -> AWSXMLElement {
|
|
let parser = XMLParser(data: data)
|
|
parser.delegate = self
|
|
guard parser.parse(), let root = root else {
|
|
throw AWSError.invalidXML("Failed to parse XML response")
|
|
}
|
|
return root
|
|
}
|
|
|
|
func parserDidStartDocument(_ parser: XMLParser) {
|
|
stack = []
|
|
root = nil
|
|
}
|
|
|
|
func parser(_ parser: XMLParser, didStartElement elementName: String,
|
|
namespaceURI: String?, qualifiedName: String?,
|
|
attributes: [String: String] = [:]) {
|
|
let element = AWSXMLElement(name: elementName)
|
|
element.attributes = attributes
|
|
stack.last?.children.append(element)
|
|
stack.append(element)
|
|
currentText = ""
|
|
}
|
|
|
|
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
|
currentText += string
|
|
}
|
|
|
|
func parser(_ parser: XMLParser, didEndElement elementName: String,
|
|
namespaceURI: String?, qualifiedName: String?) {
|
|
if let current = stack.last {
|
|
current.text = currentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
if stack.count == 1 {
|
|
root = stack.first
|
|
}
|
|
stack.removeLast()
|
|
currentText = stack.last.flatMap { _ in "" } ?? ""
|
|
}
|
|
}
|