diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..d40689c --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +.build/ +.swiftpm/ +DerivedData/ +*.xcodeproj +*.xcworkspace +xcuserdata/ +*.profraw diff --git a/macos/Package.swift b/macos/Package.swift new file mode 100644 index 0000000..20f8bb3 --- /dev/null +++ b/macos/Package.swift @@ -0,0 +1,42 @@ +// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "ReverseAPI", + platforms: [.macOS(.v14)], + products: [ + .library(name: "ReverseAPIProxy", targets: ["ReverseAPIProxy"]), + .executable(name: "rae-proxy", targets: ["rae-proxy"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), + .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.27.0"), + .package(url: "https://github.com/apple/swift-certificates.git", from: "1.5.0"), + .package(url: "https://github.com/apple/swift-crypto.git", from: "3.7.0"), + ], + targets: [ + .target( + name: "ReverseAPIProxy", + dependencies: [ + .product(name: "NIO", package: "swift-nio"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOFoundationCompat", package: "swift-nio"), + .product(name: "NIOConcurrencyHelpers", package: "swift-nio"), + .product(name: "NIOSSL", package: "swift-nio-ssl"), + .product(name: "X509", package: "swift-certificates"), + .product(name: "Crypto", package: "swift-crypto"), + .product(name: "_CryptoExtras", package: "swift-crypto"), + ] + ), + .executableTarget( + name: "rae-proxy", + dependencies: ["ReverseAPIProxy"] + ), + .testTarget( + name: "ReverseAPIProxyTests", + dependencies: ["ReverseAPIProxy"] + ), + ] +) diff --git a/macos/Sources/ReverseAPIProxy/CA/CertificateAuthority.swift b/macos/Sources/ReverseAPIProxy/CA/CertificateAuthority.swift new file mode 100644 index 0000000..9756661 --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/CA/CertificateAuthority.swift @@ -0,0 +1,66 @@ +import Foundation +import Crypto +import X509 +import SwiftASN1 + +public struct RootCertificate: Sendable { + public let certificate: Certificate + public let privateKey: Certificate.PrivateKey + + public func derBytes() throws -> [UInt8] { + var serializer = DER.Serializer() + try serializer.serialize(certificate) + return serializer.serializedBytes + } + + public func pem() throws -> String { + let pemDoc = PEMDocument(type: "CERTIFICATE", derBytes: try derBytes()) + return pemDoc.pemString + } + + public func privateKeyPEM() throws -> String { + try privateKey.serializeAsPEM().pemString + } +} + +public enum CertificateAuthority { + public static func generateRoot(commonName: String = "ReverseAPI Local Root") throws -> RootCertificate { + let signingKey = P256.Signing.PrivateKey() + let privateKey = Certificate.PrivateKey(signingKey) + + let name = try DistinguishedName { + CommonName(commonName) + OrganizationName("ReverseAPI") + } + + let now = Date() + let notValidAfter = now.addingTimeInterval(10 * 365 * 24 * 60 * 60) + + let extensions = try Certificate.Extensions { + Critical(BasicConstraints.isCertificateAuthority(maxPathLength: 0)) + Critical(KeyUsage(keyCertSign: true, cRLSign: true)) + SubjectKeyIdentifier(hash: privateKey.publicKey) + } + + let certificate = try Certificate( + version: .v3, + serialNumber: Certificate.SerialNumber(), + publicKey: privateKey.publicKey, + notValidBefore: now.addingTimeInterval(-60), + notValidAfter: notValidAfter, + issuer: name, + subject: name, + signatureAlgorithm: .ecdsaWithSHA256, + extensions: extensions, + issuerPrivateKey: privateKey + ) + + return RootCertificate(certificate: certificate, privateKey: privateKey) + } + + public static func loadRoot(certificatePEM: String, privateKeyPEM: String) throws -> RootCertificate { + let certificate = try Certificate(pemEncoded: certificatePEM) + let privateKey = try Certificate.PrivateKey(pemEncoded: privateKeyPEM) + return RootCertificate(certificate: certificate, privateKey: privateKey) + } +} diff --git a/macos/Sources/ReverseAPIProxy/CA/LeafCertificateFactory.swift b/macos/Sources/ReverseAPIProxy/CA/LeafCertificateFactory.swift new file mode 100644 index 0000000..a30682d --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/CA/LeafCertificateFactory.swift @@ -0,0 +1,144 @@ +import Foundation +import Crypto +import X509 +import SwiftASN1 +import NIOSSL + +public actor LeafCertificateFactory { + public struct Materials: Sendable { + public let certificate: NIOSSLCertificate + public let privateKey: NIOSSLPrivateKey + public let rootCertificate: NIOSSLCertificate + } + + public static let defaultCacheLimit = 256 + + private let root: RootCertificate + private let rootSSL: NIOSSLCertificate + private let rootKeyIdentifier: ArraySlice + private let cacheLimit: Int + private var cache: [String: Materials] = [:] + private var order: [String] = [] + + public init(root: RootCertificate, cacheLimit: Int = LeafCertificateFactory.defaultCacheLimit) throws { + self.root = root + var serializer = DER.Serializer() + try serializer.serialize(root.certificate) + self.rootSSL = try NIOSSLCertificate(bytes: serializer.serializedBytes, format: .der) + self.rootKeyIdentifier = SubjectKeyIdentifier(hash: root.privateKey.publicKey).keyIdentifier + self.cacheLimit = max(1, cacheLimit) + } + + public func materials(for host: String) throws -> Materials { + if let cached = cache[host] { + touch(host) + return cached + } + let minted = try mint(host: host) + insert(host: host, materials: minted) + return minted + } + + public func cacheCount() -> Int { + cache.count + } + + private func insert(host: String, materials: Materials) { + cache[host] = materials + order.removeAll(where: { $0 == host }) + order.append(host) + while order.count > cacheLimit { + let evict = order.removeFirst() + cache.removeValue(forKey: evict) + } + } + + private func touch(_ host: String) { + if let idx = order.firstIndex(of: host) { + order.remove(at: idx) + order.append(host) + } + } + + private func mint(host: String) throws -> Materials { + let leafSigning = P256.Signing.PrivateKey() + let leafPrivateKey = Certificate.PrivateKey(leafSigning) + + let subject = try DistinguishedName { + CommonName(host) + OrganizationName("ReverseAPI") + } + + let now = Date() + let notValidAfter = now.addingTimeInterval(397 * 24 * 60 * 60) + + let extensions = try Certificate.Extensions { + Critical(BasicConstraints.notCertificateAuthority) + Critical(KeyUsage(digitalSignature: true, keyEncipherment: true)) + try ExtendedKeyUsage([.serverAuth, .clientAuth]) + subjectAlternativeNames(for: host) + SubjectKeyIdentifier(hash: leafPrivateKey.publicKey) + AuthorityKeyIdentifier(keyIdentifier: rootKeyIdentifier) + } + + let certificate = try Certificate( + version: .v3, + serialNumber: Certificate.SerialNumber(), + publicKey: leafPrivateKey.publicKey, + notValidBefore: now.addingTimeInterval(-60), + notValidAfter: notValidAfter, + issuer: root.certificate.subject, + subject: subject, + signatureAlgorithm: .ecdsaWithSHA256, + extensions: extensions, + issuerPrivateKey: root.privateKey + ) + + var certSerializer = DER.Serializer() + try certSerializer.serialize(certificate) + let certBytes = certSerializer.serializedBytes + let nioCert = try NIOSSLCertificate(bytes: certBytes, format: .der) + + let keyPEM = try leafPrivateKey.serializeAsPEM().pemString + let nioKey = try NIOSSLPrivateKey(bytes: Array(keyPEM.utf8), format: .pem) + + return Materials(certificate: nioCert, privateKey: nioKey, rootCertificate: rootSSL) + } + + private func subjectAlternativeNames(for host: String) -> SubjectAlternativeNames { + if let bytes = ipv4Bytes(host) { + return SubjectAlternativeNames([.ipAddress(ASN1OctetString(contentBytes: ArraySlice(bytes)))]) + } + if let bytes = ipv6Bytes(host) { + return SubjectAlternativeNames([.ipAddress(ASN1OctetString(contentBytes: ArraySlice(bytes)))]) + } + return SubjectAlternativeNames([.dnsName(host)]) + } +} + +private func ipv4Bytes(_ host: String) -> [UInt8]? { + let parts = host.split(separator: ".") + guard parts.count == 4 else { return nil } + var bytes = [UInt8]() + for part in parts { + guard let value = UInt8(part) else { return nil } + bytes.append(value) + } + return bytes +} + +private func ipv6Bytes(_ host: String) -> [UInt8]? { + guard host.contains(":") else { return nil } + var hints = addrinfo() + hints.ai_family = AF_INET6 + hints.ai_flags = AI_NUMERICHOST + var result: UnsafeMutablePointer? + let status = getaddrinfo(host, nil, &hints, &result) + guard status == 0, let info = result else { return nil } + defer { freeaddrinfo(info) } + guard let sockaddr = info.pointee.ai_addr else { return nil } + return sockaddr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { ptr in + var addr = ptr.pointee.sin6_addr + return withUnsafeBytes(of: &addr) { Array($0) } + } +} diff --git a/macos/Sources/ReverseAPIProxy/CA/RootCertificateStore.swift b/macos/Sources/ReverseAPIProxy/CA/RootCertificateStore.swift new file mode 100644 index 0000000..35f7130 --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/CA/RootCertificateStore.swift @@ -0,0 +1,51 @@ +import Foundation + +public struct RootCertificateStore: Sendable { + public let directory: URL + public let certificateURL: URL + public let privateKeyURL: URL + + public init(directory: URL) { + self.directory = directory + self.certificateURL = directory.appendingPathComponent("reverseapi-root.pem") + self.privateKeyURL = directory.appendingPathComponent("reverseapi-root-key.pem") + } + + public static func defaultDirectory(fileManager: FileManager = .default) throws -> URL { + guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + throw CocoaError(.fileNoSuchFile) + } + return appSupport.appendingPathComponent("ReverseAPI", isDirectory: true) + } + + public static func `default`() throws -> RootCertificateStore { + try RootCertificateStore(directory: defaultDirectory()) + } + + public func loadOrCreate(commonName: String = "ReverseAPI Local Root") throws -> RootCertificate { + let fileManager = FileManager.default + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + + if fileManager.fileExists(atPath: certificateURL.path), fileManager.fileExists(atPath: privateKeyURL.path) { + let certificatePEM = try String(contentsOf: certificateURL, encoding: .utf8) + let privateKeyPEM = try String(contentsOf: privateKeyURL, encoding: .utf8) + return try CertificateAuthority.loadRoot(certificatePEM: certificatePEM, privateKeyPEM: privateKeyPEM) + } + + let root = try CertificateAuthority.generateRoot(commonName: commonName) + try save(root) + return root + } + + public func save(_ root: RootCertificate) throws { + let fileManager = FileManager.default + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + + try root.pem().write(to: certificateURL, atomically: true, encoding: .utf8) + try root.privateKeyPEM().write(to: privateKeyURL, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o644], ofItemAtPath: certificateURL.path) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: privateKeyURL.path) + } +} diff --git a/macos/Sources/ReverseAPIProxy/Capture/CapturedFlow.swift b/macos/Sources/ReverseAPIProxy/Capture/CapturedFlow.swift new file mode 100644 index 0000000..db5b610 --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/Capture/CapturedFlow.swift @@ -0,0 +1,71 @@ +import Foundation + +public struct HTTPHeader: Sendable, Hashable { + public var name: String + public var value: String + + public init(_ name: String, _ value: String) { + self.name = name + self.value = value + } +} + +public struct CapturedFlow: Sendable, Identifiable { + public enum Scheme: String, Sendable { + case http + case https + } + + public let id: UUID + public let scheme: Scheme + public let method: String + public let host: String + public let port: Int + public let path: String + public var requestHeaders: [HTTPHeader] + public var requestBody: Data + public var responseStatus: Int? + public var responseHeaders: [HTTPHeader] + public var responseBody: Data + public let startedAt: Date + public var finishedAt: Date? + public var error: String? + + public init( + id: UUID = UUID(), + scheme: Scheme, + method: String, + host: String, + port: Int, + path: String, + requestHeaders: [HTTPHeader] = [], + startedAt: Date = Date() + ) { + self.id = id + self.scheme = scheme + self.method = method + self.host = host + self.port = port + self.path = path + self.requestHeaders = requestHeaders + self.requestBody = Data() + self.responseStatus = nil + self.responseHeaders = [] + self.responseBody = Data() + self.startedAt = startedAt + self.finishedAt = nil + self.error = nil + } + + public var url: String { + let bracketed = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host + let portSegment: String + switch (scheme, port) { + case (.http, 80), (.https, 443): + portSegment = "" + default: + portSegment = ":\(port)" + } + return "\(scheme.rawValue)://\(bracketed)\(portSegment)\(path)" + } +} diff --git a/macos/Sources/ReverseAPIProxy/Capture/FlowBus.swift b/macos/Sources/ReverseAPIProxy/Capture/FlowBus.swift new file mode 100644 index 0000000..21b658f --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/Capture/FlowBus.swift @@ -0,0 +1,45 @@ +import Foundation + +public enum FlowEvent: Sendable { + case started(CapturedFlow) + case updated(CapturedFlow) + case finished(CapturedFlow) +} + +public actor FlowBus { + public typealias Stream = AsyncStream + + public static let defaultBufferLimit = 1024 + + private let bufferLimit: Int + private var subscribers: [UUID: Stream.Continuation] = [:] + + public init(bufferLimit: Int = FlowBus.defaultBufferLimit) { + self.bufferLimit = max(1, bufferLimit) + } + + public func subscribe() -> Stream { + let policy: Stream.Continuation.BufferingPolicy = .bufferingNewest(bufferLimit) + let (stream, continuation) = Stream.makeStream(bufferingPolicy: policy) + let token = UUID() + subscribers[token] = continuation + continuation.onTermination = { [weak self] _ in + Task { await self?.unsubscribe(token) } + } + return stream + } + + public func emit(_ event: FlowEvent) { + for continuation in subscribers.values { + continuation.yield(event) + } + } + + public func subscriberCount() -> Int { + subscribers.count + } + + private func unsubscribe(_ token: UUID) { + subscribers.removeValue(forKey: token) + } +} diff --git a/macos/Sources/ReverseAPIProxy/Proxy/HostPort.swift b/macos/Sources/ReverseAPIProxy/Proxy/HostPort.swift new file mode 100644 index 0000000..c43ffa3 --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/Proxy/HostPort.swift @@ -0,0 +1,65 @@ +import Foundation + +public struct HostPort: Sendable, Hashable { + public static let validPortRange = 1...65535 + + public let host: String + public let port: Int + + public init(host: String, port: Int) { + self.host = host + self.port = port + } + + public static func parseAuthority(_ string: String, defaultPort: Int = 443) -> HostPort? { + if string.hasPrefix("[") { + guard let close = string.firstIndex(of: "]") else { return nil } + let host = String(string[string.index(after: string.startIndex).. (HostPort, String, CapturedFlow.Scheme)? { + guard let scheme = ["http://", "https://"].first(where: { uri.hasPrefix($0) }) else { + return nil + } + let captured: CapturedFlow.Scheme = scheme == "https://" ? .https : .http + let defaultPort = captured == .https ? 443 : 80 + let withoutScheme = uri.dropFirst(scheme.count) + + let delimiters = [ + withoutScheme.firstIndex(of: "/"), + withoutScheme.firstIndex(of: "?"), + withoutScheme.firstIndex(of: "#"), + ].compactMap { $0 } + let authorityEnd = delimiters.min() ?? withoutScheme.endIndex + let authority = String(withoutScheme[.. maxBodyBytes { + phase = .rejected + return + } + phase = .buffering(inflight) + } + + private func onEnd(channelContext: ChannelHandlerContext, trailers: HTTPHeaders?) { + if case .rejected = phase { + phase = .idle + respondError(channelContext: channelContext, status: .payloadTooLarge) + return + } + guard case .buffering(var inflight) = phase else { return } + inflight.appendTrailers(trailers) + phase = .idle + dispatch(channelContext: channelContext, inflight: inflight) + } + + private func dispatch(channelContext: ChannelHandlerContext, inflight: InflightRequest) { + let proxyContext = self.context + let channel = channelContext.channel + let eventLoop = channelContext.eventLoop + + var headersForUpstream = inflight.head.headers + sanitizeRequestHeaders(&headersForUpstream) + let flow = makeFlow(from: inflight) + + Task { + await proxyContext.bus.emit(.started(flow)) + do { + let response = try await proxyContext.upstream.send( + scheme: inflight.scheme, + host: inflight.host, + port: inflight.port, + method: inflight.head.method, + uri: inflight.path, + headers: headersForUpstream, + body: inflight.body + ) + var captured = flow + captured.responseStatus = Int(response.status.code) + captured.responseHeaders = response.headers.map { HTTPHeader($0.name, $0.value) } + captured.responseBody = Data(buffer: response.body) + captured.finishedAt = Date() + await proxyContext.bus.emit(.finished(captured)) + + eventLoop.execute { + var head = HTTPResponseHead(version: response.version, status: response.status, headers: response.headers) + head.headers.replaceOrAdd(name: "Connection", value: "close") + head.headers.remove(name: "Transfer-Encoding") + head.headers.replaceOrAdd(name: "Content-Length", value: String(response.body.readableBytes)) + + channel.write(HTTPServerResponsePart.head(head), promise: nil) + if response.body.readableBytes > 0 { + channel.write(HTTPServerResponsePart.body(.byteBuffer(response.body)), promise: nil) + } + channel.writeAndFlush(HTTPServerResponsePart.end(nil)).whenComplete { _ in + channel.close(promise: nil) + } + } + } catch { + var failed = flow + failed.error = "\(error)" + failed.finishedAt = Date() + await proxyContext.bus.emit(.finished(failed)) + proxyContext.logger.error("upstream \(inflight.host):\(inflight.port) failed: \(error)") + eventLoop.execute { channel.close(promise: nil) } + } + } + } + + private func beginBump(channelContext: ChannelHandlerContext, head: HTTPRequestHead) { + guard let target = HostPort.parseAuthority(head.uri) else { + respondError(channelContext: channelContext, status: .badRequest) + return + } + phase = .bumping + let proxyContext = self.context + let channel = channelContext.channel + let eventLoop = channelContext.eventLoop + + Task { + do { + let tlsContext = try await proxyContext.tlsContexts.serverContext(for: target.host) + try await eventLoop.submit { + var okHead = HTTPResponseHead(version: .http1_1, status: .ok) + okHead.headers.add(name: "Content-Length", value: "0") + channel.write(HTTPServerResponsePart.head(okHead), promise: nil) + channel.writeAndFlush(HTTPServerResponsePart.end(nil), promise: nil) + + let pipeline = channel.pipeline.syncOperations + _ = pipeline.removeHandler(self) + _ = pipeline.removeHandler(name: PipelineNames.encoder) + _ = pipeline.removeHandler(name: PipelineNames.decoder) + + let sslHandler = NIOSSLServerHandler(context: tlsContext) + try pipeline.addHandler(sslHandler, name: PipelineNames.tls, position: .first) + try pipeline.addHandler( + ByteToMessageHandler(HTTPRequestDecoder(leftOverBytesStrategy: .forwardBytes)), + name: PipelineNames.decoder + ) + try pipeline.addHandler(HTTPResponseEncoder(), name: PipelineNames.encoder) + try pipeline.addHandler( + ProxyHandler(context: proxyContext, mode: .bumped(host: target.host, port: target.port)), + name: PipelineNames.proxy + ) + }.get() + } catch { + proxyContext.logger.error("bump install failed for \(target.host): \(error)") + eventLoop.execute { channel.close(promise: nil) } + } + } + } + + private func respondError(channelContext: ChannelHandlerContext, status: HTTPResponseStatus) { + var headers = HTTPHeaders() + headers.add(name: "Content-Length", value: "0") + headers.add(name: "Connection", value: "close") + let head = HTTPResponseHead(version: .http1_1, status: status, headers: headers) + channelContext.write(wrapOutboundOut(.head(head)), promise: nil) + channelContext.writeAndFlush(wrapOutboundOut(.end(nil))).whenComplete { _ in + channelContext.close(promise: nil) + } + } + + private func sanitizeRequestHeaders(_ headers: inout HTTPHeaders) { + headers.remove(name: "Proxy-Connection") + headers.remove(name: "Proxy-Authorization") + headers.replaceOrAdd(name: "Connection", value: "close") + } + + private func makeFlow(from inflight: InflightRequest) -> CapturedFlow { + var flow = CapturedFlow( + scheme: inflight.scheme, + method: inflight.head.method.rawValue, + host: inflight.host, + port: inflight.port, + path: inflight.path + ) + flow.requestHeaders = inflight.head.headers.map { HTTPHeader($0.name, $0.value) } + flow.requestBody = Data(buffer: inflight.body) + return flow + } +} + +enum PipelineNames { + static let tls = "tls" + static let decoder = "http-request-decoder" + static let encoder = "http-response-encoder" + static let proxy = "proxy-handler" +} + +struct InflightRequest { + let head: HTTPRequestHead + let scheme: CapturedFlow.Scheme + let host: String + let port: Int + let path: String + var body: ByteBuffer = ByteBufferAllocator().buffer(capacity: 0) + var trailers: HTTPHeaders? + + mutating func appendBody(_ buffer: ByteBuffer) { + var b = buffer + body.writeBuffer(&b) + } + + mutating func appendTrailers(_ headers: HTTPHeaders?) { + trailers = headers + } +} diff --git a/macos/Sources/ReverseAPIProxy/Proxy/TLSContextFactory.swift b/macos/Sources/ReverseAPIProxy/Proxy/TLSContextFactory.swift new file mode 100644 index 0000000..9ab06a8 --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/Proxy/TLSContextFactory.swift @@ -0,0 +1,39 @@ +import Foundation +import NIOSSL + +actor TLSContextFactory { + private let leafFactory: LeafCertificateFactory + private var cache: [String: NIOSSLContext] = [:] + private var inflight: [String: Task] = [:] + + init(leafFactory: LeafCertificateFactory) { + self.leafFactory = leafFactory + } + + func serverContext(for host: String) async throws -> NIOSSLContext { + if let cached = cache[host] { return cached } + if let pending = inflight[host] { + return try await pending.value + } + let factory = leafFactory + let task = Task { + let materials = try await factory.materials(for: host) + var config = TLSConfiguration.makeServerConfiguration( + certificateChain: [.certificate(materials.certificate)], + privateKey: .privateKey(materials.privateKey) + ) + config.minimumTLSVersion = .tlsv12 + config.applicationProtocols = ["http/1.1"] + return try NIOSSLContext(configuration: config) + } + inflight[host] = task + defer { inflight.removeValue(forKey: host) } + let context = try await task.value + cache[host] = context + return context + } + + func cachedHosts() -> [String] { + Array(cache.keys) + } +} diff --git a/macos/Sources/ReverseAPIProxy/Proxy/UpstreamPump.swift b/macos/Sources/ReverseAPIProxy/Proxy/UpstreamPump.swift new file mode 100644 index 0000000..d54fa1f --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/Proxy/UpstreamPump.swift @@ -0,0 +1,188 @@ +import Foundation +import NIOConcurrencyHelpers +import NIOCore +import NIOPosix +import NIOHTTP1 +import NIOSSL + +struct UpstreamResponse: Sendable { + let status: HTTPResponseStatus + let version: HTTPVersion + let headers: HTTPHeaders + let body: ByteBuffer +} + +enum UpstreamError: Error { + case connectionClosed + case missingResponse + case unexpected(String) +} + +actor UpstreamPump { + private let group: EventLoopGroup + private let logger: AppLogger + + init(group: EventLoopGroup, logger: AppLogger) { + self.group = group + self.logger = logger + } + + func send( + scheme: CapturedFlow.Scheme, + host: String, + port: Int, + method: HTTPMethod, + uri: String, + headers: HTTPHeaders, + body: ByteBuffer + ) async throws -> UpstreamResponse { + let collector = ResponseCollector() + let resultTask = Task { try await collector.awaitResponse() } + + let bootstrap = ClientBootstrap(group: group) + .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + + let channel: Channel + do { + channel = try await bootstrap.connect(host: host, port: port).get() + } catch { + collector.cancel(with: error) + resultTask.cancel() + throw error + } + + do { + if scheme == .https { + try await channel.eventLoop.submit { + var clientConfig = TLSConfiguration.makeClientConfiguration() + clientConfig.applicationProtocols = ["http/1.1"] + let sslContext = try NIOSSLContext(configuration: clientConfig) + let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: host) + try channel.pipeline.syncOperations.addHandler(sslHandler, position: .first) + }.get() + } + + try await channel.pipeline.addHTTPClientHandlers().get() + try await channel.pipeline.addHandler(collector).get() + + var requestHeaders = headers + requestHeaders.replaceOrAdd(name: "Host", value: hostHeaderValue(host: host, port: port, scheme: scheme)) + requestHeaders.replaceOrAdd(name: "Connection", value: "close") + requestHeaders.remove(name: "Proxy-Connection") + + let head = HTTPRequestHead( + version: .http1_1, + method: method, + uri: uri, + headers: requestHeaders + ) + + channel.write(HTTPClientRequestPart.head(head), promise: nil) + if body.readableBytes > 0 { + channel.write(HTTPClientRequestPart.body(.byteBuffer(body)), promise: nil) + } + try await channel.writeAndFlush(HTTPClientRequestPart.end(nil)).get() + } catch { + collector.cancel(with: error) + try? await channel.close().get() + throw error + } + + do { + let response = try await resultTask.value + try? await channel.close().get() + return response + } catch { + try? await channel.close().get() + throw error + } + } + + private func hostHeaderValue(host: String, port: Int, scheme: CapturedFlow.Scheme) -> String { + let bracketed = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host + switch (scheme, port) { + case (.http, 80), (.https, 443): return bracketed + default: return "\(bracketed):\(port)" + } + } +} + +private final class ResponseCollector: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = HTTPClientResponsePart + + private struct State { + var head: HTTPResponseHead? + var body: ByteBuffer = ByteBufferAllocator().buffer(capacity: 0) + var continuation: CheckedContinuation? + var result: Result? + var settled = false + } + + private let lock = NIOLockedValueBox(State()) + + func awaitResponse() async throws -> UpstreamResponse { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let pending: Result? = lock.withLockedValue { state in + if state.settled, let result = state.result { + return result + } + state.continuation = continuation + return nil + } + if let pending { + switch pending { + case .success(let value): continuation.resume(returning: value) + case .failure(let error): continuation.resume(throwing: error) + } + } + } + } + + func cancel(with error: Error) { + finish(.failure(error)) + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch unwrapInboundIn(data) { + case .head(let head): + lock.withLockedValue { state in + state.head = head + } + case .body(let buffer): + lock.withLockedValue { state in + var copy = buffer + state.body.writeBuffer(©) + } + case .end: + let response = lock.withLockedValue { state -> UpstreamResponse in + let head = state.head ?? HTTPResponseHead(version: .http1_1, status: .internalServerError) + return UpstreamResponse(status: head.status, version: head.version, headers: head.headers, body: state.body) + } + finish(.success(response)) + } + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + finish(.failure(error)) + context.close(promise: nil) + } + + func channelInactive(context: ChannelHandlerContext) { + finish(.failure(UpstreamError.connectionClosed)) + } + + private func finish(_ result: Result) { + let pending: CheckedContinuation? = lock.withLockedValue { state in + if state.settled { return nil } + state.settled = true + state.result = result + let cont = state.continuation + state.continuation = nil + return cont + } + switch result { + case .success(let value): pending?.resume(returning: value) + case .failure(let error): pending?.resume(throwing: error) + } + } +} diff --git a/macos/Sources/ReverseAPIProxy/ProxyEngine.swift b/macos/Sources/ReverseAPIProxy/ProxyEngine.swift new file mode 100644 index 0000000..6a2511d --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/ProxyEngine.swift @@ -0,0 +1,70 @@ +import Foundation +import NIOCore +import NIOPosix +import NIOHTTP1 + +public final class ProxyEngine: @unchecked Sendable { + public let port: Int + public let bus: FlowBus + public let root: RootCertificate + + private let leafFactory: LeafCertificateFactory + private let tlsContexts: TLSContextFactory + private let upstream: UpstreamPump + private let logger = AppLogger("proxy.engine") + private let group: MultiThreadedEventLoopGroup + + private var serverChannel: Channel? + + public init(root: RootCertificate, port: Int = 8888, bus: FlowBus = FlowBus()) throws { + self.root = root + self.port = port + self.bus = bus + self.leafFactory = try LeafCertificateFactory(root: root) + self.tlsContexts = TLSContextFactory(leafFactory: leafFactory) + self.group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + self.upstream = UpstreamPump(group: group, logger: AppLogger("proxy.upstream")) + } + + public var isRunning: Bool { serverChannel != nil } + + public func start(host: String = "127.0.0.1") async throws { + guard serverChannel == nil else { return } + let proxyContext = ProxyContext(tlsContexts: tlsContexts, upstream: upstream, bus: bus, logger: AppLogger("proxy.handler")) + + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.backlog, value: 256) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelOption(ChannelOptions.allowRemoteHalfClosure, value: true) + .childChannelInitializer { channel in + let decoder = ByteToMessageHandler(HTTPRequestDecoder(leftOverBytesStrategy: .forwardBytes)) + let encoder = HTTPResponseEncoder() + let proxy = ProxyHandler(context: proxyContext, mode: .entry) + do { + try channel.pipeline.syncOperations.addHandler(decoder, name: PipelineNames.decoder) + try channel.pipeline.syncOperations.addHandler(encoder, name: PipelineNames.encoder) + try channel.pipeline.syncOperations.addHandler(proxy, name: PipelineNames.proxy) + return channel.eventLoop.makeSucceededVoidFuture() + } catch { + return channel.eventLoop.makeFailedFuture(error) + } + } + + let channel = try await bootstrap.bind(host: host, port: port).get() + serverChannel = channel + logger.info("proxy listening on \(host):\(port)") + } + + public func stop() async throws { + guard let channel = serverChannel else { return } + defer { serverChannel = nil } + do { + try await channel.close().get() + } catch ChannelError.alreadyClosed { + logger.info("proxy channel already closed") + } + try await group.shutdownGracefully() + logger.info("proxy stopped") + } +} diff --git a/macos/Sources/ReverseAPIProxy/Support/AppLogger.swift b/macos/Sources/ReverseAPIProxy/Support/AppLogger.swift new file mode 100644 index 0000000..f1cef59 --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/Support/AppLogger.swift @@ -0,0 +1,30 @@ +import Foundation +import os + +public struct AppLogger: Sendable { + private let logger: Logger + + public init(_ category: String) { + self.logger = Logger(subsystem: "app.reverseapi", category: category) + } + + public func debug(_ message: @autoclosure () -> String) { + let text = message() + logger.debug("\(text, privacy: .public)") + } + + public func info(_ message: @autoclosure () -> String) { + let text = message() + logger.info("\(text, privacy: .public)") + } + + public func warn(_ message: @autoclosure () -> String) { + let text = message() + logger.warning("\(text, privacy: .public)") + } + + public func error(_ message: @autoclosure () -> String) { + let text = message() + logger.error("\(text, privacy: .public)") + } +} diff --git a/macos/Sources/rae-proxy/main.swift b/macos/Sources/rae-proxy/main.swift new file mode 100644 index 0000000..46b74de --- /dev/null +++ b/macos/Sources/rae-proxy/main.swift @@ -0,0 +1,412 @@ +import Foundation +import ReverseAPIProxy +import Darwin + +@main +struct RAEProxyCLI { + static func main() async { + let logger = AppLogger("cli") + do { + let options = try CLIOptions.parse() + let store = RootCertificateStore(directory: try options.dataDirectory()) + let root = try store.loadOrCreate() + + let engine = try ProxyEngine(root: root, port: options.port) + try await engine.start() + let systemProxy = options.systemProxy ? SystemProxyManager(port: options.port) : nil + try systemProxy?.apply() + if let systemProxy { + installTerminationHandlers { + systemProxy.restore() + } + } + defer { + systemProxy?.restore() + } + + print("Proxy listening on 127.0.0.1:\(options.port)") + if options.systemProxy { + print("System HTTP/HTTPS proxy enabled. It will be restored on exit.") + } else { + print("System proxy disabled. Configure clients manually or omit --no-system-proxy.") + } + print("CA stored at:", store.certificateURL.path) + if !options.trustCA { + print("HTTPS body capture requires trusting the CA. Use --trust-ca if you want full HTTPS interception.") + } + print("Curl smoke test:", "curl -k -x http://127.0.0.1:\(options.port) https://example.com") + print("Press Ctrl-C to stop.") + fflush(stdout) + + if options.trustCA { + Task.detached { + trustRoot(at: store.certificateURL) + } + } + if options.launchChrome { + launchChrome(port: options.port, dataDirectory: store.directory) + } else { + print("Chrome launch disabled. Pass --launch-chrome to open an isolated capture profile.") + } + + let bus = engine.bus + Task { + for await event in await bus.subscribe() { + switch event { + case .started(let flow): + print("→ \(flow.method) \(flow.url)") + case .updated: + break + case .finished(let flow): + let status = flow.responseStatus.map(String.init) ?? "ERR" + let bytes = flow.responseBody.count + if let error = flow.error { + print("✗ \(flow.method) \(flow.url) — \(error)") + } else { + print("← \(status) \(flow.method) \(flow.url) (\(bytes) B)") + } + } + } + } + + try await Task.sleep(for: .seconds(60 * 60 * 24 * 365)) + try await engine.stop() + } catch { + logger.error("fatal: \(error)") + fputs("fatal: \(error)\n", stderr) + exit(1) + } + } + + private static func trustRoot(at certificateURL: URL) { + #if os(macOS) + let home = FileManager.default.homeDirectoryForCurrentUser + let keychain = home.appendingPathComponent("Library/Keychains/login.keychain-db") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/security") + process.arguments = [ + "add-trusted-cert", + "-d", + "-r", + "trustRoot", + "-k", + keychain.path, + certificateURL.path, + ] + process.standardOutput = Pipe() + process.standardError = Pipe() + + do { + try process.run() + DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(10)) { + if process.isRunning { + process.terminate() + } + } + process.waitUntilExit() + if process.terminationStatus == 0 { + print("Root CA trusted in login keychain.") + } else { + print("Could not trust the Root CA in the login keychain.") + } + } catch { + print("Could not trust the Root CA: \(error)") + } + #else + print("CA trust installation is only supported on macOS.") + #endif + } + + private static func launchChrome(port: Int, dataDirectory: URL) { + #if os(macOS) + guard let chromeURL = chromeExecutableURL() else { + print("Chrome not found. Install Google Chrome or pass --no-launch-chrome and configure your own client.") + return + } + + let profileURL = dataDirectory.appendingPathComponent("ChromeProfile", isDirectory: true) + do { + try FileManager.default.createDirectory(at: profileURL, withIntermediateDirectories: true) + } catch { + print("Could not create Chrome profile directory: \(error)") + return + } + + let process = Process() + process.executableURL = chromeURL + process.arguments = [ + "--user-data-dir=\(profileURL.path)", + "--proxy-server=http://127.0.0.1:\(port)", + "--no-first-run", + "--no-default-browser-check", + "https://example.com", + ] + + do { + try process.run() + print("Opened isolated Chrome capture profile.") + } catch { + print("Could not launch Chrome: \(error)") + } + #else + print("Chrome auto-launch is only supported on macOS.") + #endif + } + + private static func chromeExecutableURL() -> URL? { + let fileManager = FileManager.default + let candidates = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "\(NSHomeDirectory())/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ] + return candidates.map { URL(fileURLWithPath: $0) }.first { fileManager.isExecutableFile(atPath: $0.path) } + } +} + +private struct CLIOptions { + var port: Int + var trustCA: Bool + var launchChrome: Bool + var systemProxy: Bool + var dataDirectoryOverride: URL? + + static func parse( + arguments: [String] = Array(ProcessInfo.processInfo.arguments.dropFirst()), + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws -> CLIOptions { + var port = environment["RAE_PROXY_PORT"].flatMap(Int.init) ?? 8888 + var trustCA = environment["RAE_PROXY_TRUST_CA"].map(isTruthy) ?? false + var launchChrome = environment["RAE_PROXY_LAUNCH_CHROME"].map(isTruthy) ?? false + var systemProxy = environment["RAE_PROXY_SYSTEM_PROXY"].map(isTruthy) ?? true + var dataDirectory = environment["RAE_PROXY_DATA_DIR"].map { URL(fileURLWithPath: $0, isDirectory: true) } + + var iterator = arguments.makeIterator() + while let argument = iterator.next() { + switch argument { + case "--trust-ca": + trustCA = true + case "--no-trust-ca": + trustCA = false + case "--launch-chrome": + launchChrome = true + case "--no-launch-chrome": + launchChrome = false + case "--system-proxy": + systemProxy = true + case "--no-system-proxy": + systemProxy = false + case "--port": + guard let value = iterator.next(), let parsed = Int(value), HostPort.validPortRange.contains(parsed) else { + throw CLIError.invalidOption("--port requires a value from 1 to 65535") + } + port = parsed + case "--data-dir": + guard let value = iterator.next(), !value.isEmpty else { + throw CLIError.invalidOption("--data-dir requires a path") + } + dataDirectory = URL(fileURLWithPath: value, isDirectory: true) + default: + throw CLIError.invalidOption("unknown option \(argument)") + } + } + + guard HostPort.validPortRange.contains(port) else { + throw CLIError.invalidOption("port must be from 1 to 65535") + } + + return CLIOptions( + port: port, + trustCA: trustCA, + launchChrome: launchChrome, + systemProxy: systemProxy, + dataDirectoryOverride: dataDirectory + ) + } + + func dataDirectory() throws -> URL { + if let dataDirectoryOverride { + return dataDirectoryOverride + } + return try RootCertificateStore.defaultDirectory() + } + + private static func isTruthy(_ value: String) -> Bool { + ["1", "true", "yes", "on"].contains(value.lowercased()) + } +} + +private enum CLIError: Error, CustomStringConvertible { + case invalidOption(String) + + var description: String { + switch self { + case .invalidOption(let message): + return message + } + } +} + +private final class SystemProxyManager: @unchecked Sendable { + private struct Snapshot { + let service: String + let kind: ProxyKind + let enabled: Bool + let server: String + let port: Int + } + + private enum ProxyKind: CaseIterable { + case web + case secureWeb + + var getCommand: String { + switch self { + case .web: return "-getwebproxy" + case .secureWeb: return "-getsecurewebproxy" + } + } + + var setCommand: String { + switch self { + case .web: return "-setwebproxy" + case .secureWeb: return "-setsecurewebproxy" + } + } + + var setStateCommand: String { + switch self { + case .web: return "-setwebproxystate" + case .secureWeb: return "-setsecurewebproxystate" + } + } + } + + private let port: Int + private let lock = NSLock() + private var snapshots: [Snapshot] = [] + private var applied = false + + init(port: Int) { + self.port = port + } + + func apply() throws { + lock.lock() + guard !applied else { + lock.unlock() + return + } + applied = true + lock.unlock() + + do { + let services = try activeNetworkServices() + for service in services { + for kind in ProxyKind.allCases { + let currentSnapshot = try snapshot(service: service, kind: kind) + lock.lock() + snapshots.append(currentSnapshot) + lock.unlock() + try runNetworkSetup([kind.setCommand, service, "127.0.0.1", String(port)]) + try runNetworkSetup([kind.setStateCommand, service, "on"]) + } + } + } catch { + restore() + throw error + } + } + + func restore() { + lock.lock() + let currentSnapshots = snapshots + snapshots = [] + let shouldRestore = applied + applied = false + lock.unlock() + + guard shouldRestore else { return } + for snapshot in currentSnapshots.reversed() { + do { + if snapshot.enabled { + try runNetworkSetup([snapshot.kind.setCommand, snapshot.service, snapshot.server, String(snapshot.port)]) + try runNetworkSetup([snapshot.kind.setStateCommand, snapshot.service, "on"]) + } else { + try runNetworkSetup([snapshot.kind.setStateCommand, snapshot.service, "off"]) + } + } catch { + fputs("warning: failed to restore proxy for \(snapshot.service): \(error)\n", stderr) + } + } + } + + private func activeNetworkServices() throws -> [String] { + let output = try runNetworkSetup(["-listallnetworkservices"]) + return output + .split(separator: "\n") + .dropFirst() + .map(String.init) + .filter { !$0.hasPrefix("*") && !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + + private func snapshot(service: String, kind: ProxyKind) throws -> Snapshot { + let output = try runNetworkSetup([kind.getCommand, service]) + let fields = Dictionary( + uniqueKeysWithValues: output.split(separator: "\n").compactMap { line -> (String, String)? in + let parts = line.split(separator: ":", maxSplits: 1) + guard parts.count == 2 else { return nil } + return ( + String(parts[0]).trimmingCharacters(in: .whitespacesAndNewlines), + String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + ) + return Snapshot( + service: service, + kind: kind, + enabled: fields["Enabled"] == "Yes", + server: fields["Server"] ?? "", + port: Int(fields["Port"] ?? "") ?? 0 + ) + } + + @discardableResult + private func runNetworkSetup(_ arguments: [String]) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/networksetup") + process.arguments = arguments + + let output = Pipe() + let error = Pipe() + process.standardOutput = output + process.standardError = error + + try process.run() + process.waitUntilExit() + + let data = output.fileHandleForReading.readDataToEndOfFile() + let errorData = error.fileHandleForReading.readDataToEndOfFile() + if process.terminationStatus != 0 { + let message = String(data: errorData, encoding: .utf8) ?? "networksetup failed" + throw CLIError.invalidOption(message.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return String(data: data, encoding: .utf8) ?? "" + } +} + +private var signalSources: [DispatchSourceSignal] = [] + +private func installTerminationHandlers(_ cleanup: @escaping @Sendable () -> Void) { + for signalNumber in [SIGINT, SIGTERM] { + signal(signalNumber, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main) + source.setEventHandler { + cleanup() + exit(0) + } + source.resume() + signalSources.append(source) + } +} diff --git a/macos/Tests/ReverseAPIProxyTests/CapturedFlowTests.swift b/macos/Tests/ReverseAPIProxyTests/CapturedFlowTests.swift new file mode 100644 index 0000000..d2bb3f7 --- /dev/null +++ b/macos/Tests/ReverseAPIProxyTests/CapturedFlowTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import ReverseAPIProxy + +final class CapturedFlowURLTests: XCTestCase { + func testHTTPDefaultPortOmitsPortSegment() { + let flow = CapturedFlow(scheme: .http, method: "GET", host: "example.com", port: 80, path: "/x") + XCTAssertEqual(flow.url, "http://example.com/x") + } + + func testHTTPSDefaultPortOmitsPortSegment() { + let flow = CapturedFlow(scheme: .https, method: "GET", host: "example.com", port: 443, path: "/x") + XCTAssertEqual(flow.url, "https://example.com/x") + } + + func testHTTPCustomPortShown() { + let flow = CapturedFlow(scheme: .http, method: "GET", host: "example.com", port: 8080, path: "/x") + XCTAssertEqual(flow.url, "http://example.com:8080/x") + } + + func testIPv6HostWrappedInBrackets() { + let flow = CapturedFlow(scheme: .https, method: "GET", host: "2001:db8::1", port: 443, path: "/x") + XCTAssertEqual(flow.url, "https://[2001:db8::1]/x") + } + + func testIPv6HostWithCustomPort() { + let flow = CapturedFlow(scheme: .https, method: "GET", host: "::1", port: 8443, path: "/") + XCTAssertEqual(flow.url, "https://[::1]:8443/") + } + + func testIPv4HostNotBracketed() { + let flow = CapturedFlow(scheme: .https, method: "GET", host: "127.0.0.1", port: 443, path: "/x") + XCTAssertEqual(flow.url, "https://127.0.0.1/x") + } + + func testAlreadyBracketedHostIsNotDoubleWrapped() { + let flow = CapturedFlow(scheme: .https, method: "GET", host: "[::1]", port: 8443, path: "/") + XCTAssertEqual(flow.url, "https://[::1]:8443/") + } +} + +final class CapturedFlowInitTests: XCTestCase { + func testInitProducesNonNilDefaults() { + let flow = CapturedFlow(scheme: .https, method: "POST", host: "h", port: 1, path: "/") + XCTAssertTrue(flow.requestHeaders.isEmpty) + XCTAssertTrue(flow.responseHeaders.isEmpty) + XCTAssertEqual(flow.requestBody, Data()) + XCTAssertEqual(flow.responseBody, Data()) + XCTAssertNil(flow.responseStatus) + XCTAssertNil(flow.finishedAt) + XCTAssertNil(flow.error) + } +} diff --git a/macos/Tests/ReverseAPIProxyTests/CertificateAuthorityTests.swift b/macos/Tests/ReverseAPIProxyTests/CertificateAuthorityTests.swift new file mode 100644 index 0000000..e12ded8 --- /dev/null +++ b/macos/Tests/ReverseAPIProxyTests/CertificateAuthorityTests.swift @@ -0,0 +1,64 @@ +import XCTest +import X509 +import SwiftASN1 +@testable import ReverseAPIProxy + +final class CertificateAuthorityTests: XCTestCase { + func testGenerateRootIsSelfSigned() throws { + let root = try CertificateAuthority.generateRoot() + XCTAssertEqual(root.certificate.subject, root.certificate.issuer) + } + + func testGenerateRootHasBasicConstraints() throws { + let root = try CertificateAuthority.generateRoot() + let bc = try root.certificate.extensions.basicConstraints + guard case .some(.isCertificateAuthority) = bc else { + XCTFail("Expected CA basic constraints, got \(String(describing: bc))") + return + } + } + + func testRootPEMRoundtrips() throws { + let root = try CertificateAuthority.generateRoot() + let pem = try root.pem() + XCTAssertTrue(pem.contains("-----BEGIN CERTIFICATE-----")) + XCTAssertTrue(pem.contains("-----END CERTIFICATE-----")) + } + + func testLoadRootFromPEM() throws { + let root = try CertificateAuthority.generateRoot() + let loaded = try CertificateAuthority.loadRoot(certificatePEM: try root.pem(), privateKeyPEM: try root.privateKeyPEM()) + XCTAssertEqual(try root.derBytes(), try loaded.derBytes()) + XCTAssertEqual(root.privateKey.publicKey, loaded.privateKey.publicKey) + } + + func testRootCertificateStorePersistsAndReloadsRoot() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let store = RootCertificateStore(directory: directory) + let first = try store.loadOrCreate() + let second = try store.loadOrCreate() + + XCTAssertTrue(FileManager.default.fileExists(atPath: store.certificateURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: store.privateKeyURL.path)) + XCTAssertEqual(try first.derBytes(), try second.derBytes()) + XCTAssertEqual(first.privateKey.publicKey, second.privateKey.publicKey) + } + + func testLeafCertificateFactoryProducesLeafForHost() async throws { + let root = try CertificateAuthority.generateRoot() + let factory = try LeafCertificateFactory(root: root) + let materials = try await factory.materials(for: "example.com") + XCTAssertNotNil(materials.certificate) + XCTAssertNotNil(materials.privateKey) + } + + func testLeafCertificateFactoryCachesPerHost() async throws { + let root = try CertificateAuthority.generateRoot() + let factory = try LeafCertificateFactory(root: root) + let first = try await factory.materials(for: "example.com") + let second = try await factory.materials(for: "example.com") + XCTAssertEqual(try first.certificate.toDERBytes(), try second.certificate.toDERBytes()) + } +} diff --git a/macos/Tests/ReverseAPIProxyTests/FlowBusTests.swift b/macos/Tests/ReverseAPIProxyTests/FlowBusTests.swift new file mode 100644 index 0000000..1ce03a4 --- /dev/null +++ b/macos/Tests/ReverseAPIProxyTests/FlowBusTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import ReverseAPIProxy + +final class FlowBusTests: XCTestCase { + func testMultipleSubscribersReceiveEvent() async { + let bus = FlowBus(bufferLimit: 16) + let s1 = await bus.subscribe() + let s2 = await bus.subscribe() + let flow = CapturedFlow(scheme: .https, method: "GET", host: "h", port: 443, path: "/") + let received1 = expectation(description: "s1 receives") + let received2 = expectation(description: "s2 receives") + Task { + for await event in s1 { + if case .started(let f) = event, f.id == flow.id { + received1.fulfill() + return + } + } + } + Task { + for await event in s2 { + if case .started(let f) = event, f.id == flow.id { + received2.fulfill() + return + } + } + } + try? await Task.sleep(for: .milliseconds(50)) + await bus.emit(.started(flow)) + await fulfillment(of: [received1, received2], timeout: 2) + } + + func testUnsubscribeOnStreamTermination() async { + let bus = FlowBus(bufferLimit: 16) + var stream: AsyncStream? = await bus.subscribe() + let initialCount = await bus.subscriberCount() + XCTAssertEqual(initialCount, 1) + stream = nil + _ = stream + try? await Task.sleep(for: .milliseconds(100)) + let count = await bus.subscriberCount() + XCTAssertEqual(count, 0) + } + + func testBoundedBufferDropsOldestWhenSlowConsumer() async { + let bus = FlowBus(bufferLimit: 2) + let stream = await bus.subscribe() + let f1 = CapturedFlow(scheme: .http, method: "GET", host: "a", port: 80, path: "/1") + let f2 = CapturedFlow(scheme: .http, method: "GET", host: "a", port: 80, path: "/2") + let f3 = CapturedFlow(scheme: .http, method: "GET", host: "a", port: 80, path: "/3") + let f4 = CapturedFlow(scheme: .http, method: "GET", host: "a", port: 80, path: "/4") + await bus.emit(.started(f1)) + await bus.emit(.started(f2)) + await bus.emit(.started(f3)) + await bus.emit(.started(f4)) + var collected: [String] = [] + for await event in stream { + if case .started(let flow) = event { + collected.append(flow.path) + } + if collected.count == 2 { break } + } + XCTAssertEqual(collected.count, 2) + XCTAssertEqual(collected.last, "/4", "bufferingNewest must retain the latest events") + } + + func testDefaultBufferLimitIsPositive() { + XCTAssertGreaterThan(FlowBus.defaultBufferLimit, 0) + } +} diff --git a/macos/Tests/ReverseAPIProxyTests/HostPortTests.swift b/macos/Tests/ReverseAPIProxyTests/HostPortTests.swift new file mode 100644 index 0000000..dae309a --- /dev/null +++ b/macos/Tests/ReverseAPIProxyTests/HostPortTests.swift @@ -0,0 +1,162 @@ +import XCTest +@testable import ReverseAPIProxy + +final class HostPortTests: XCTestCase { + // MARK: - parseAuthority + + func testParsesHostnameWithoutPort() { + let hp = HostPort.parseAuthority("example.com") + XCTAssertEqual(hp?.host, "example.com") + XCTAssertEqual(hp?.port, 443) + } + + func testParsesHostnameWithPort() { + let hp = HostPort.parseAuthority("example.com:8080") + XCTAssertEqual(hp?.host, "example.com") + XCTAssertEqual(hp?.port, 8080) + } + + func testParsesAuthorityWithCustomDefaultPort() { + let hp = HostPort.parseAuthority("example.com", defaultPort: 80) + XCTAssertEqual(hp?.port, 80) + } + + func testParsesIPv4() { + let hp = HostPort.parseAuthority("127.0.0.1:9090") + XCTAssertEqual(hp?.host, "127.0.0.1") + XCTAssertEqual(hp?.port, 9090) + } + + func testParsesBracketedIPv6WithoutPort() { + let hp = HostPort.parseAuthority("[::1]", defaultPort: 80) + XCTAssertEqual(hp?.host, "::1") + XCTAssertEqual(hp?.port, 80) + } + + func testParsesBracketedIPv6WithPort() { + let hp = HostPort.parseAuthority("[2001:db8::1]:8443") + XCTAssertEqual(hp?.host, "2001:db8::1") + XCTAssertEqual(hp?.port, 8443) + } + + func testRejectsEmptyBrackets() { + XCTAssertNil(HostPort.parseAuthority("[]:80")) + } + + func testRejectsBracketedHostWithoutColonBeforePort() { + XCTAssertNil(HostPort.parseAuthority("[::1]8080")) + } + + func testRejectsPortZero() { + XCTAssertNil(HostPort.parseAuthority("example.com:0")) + } + + func testRejectsNegativePort() { + XCTAssertNil(HostPort.parseAuthority("example.com:-1")) + } + + func testRejectsPortBeyondMax() { + XCTAssertNil(HostPort.parseAuthority("example.com:99999")) + } + + func testRejectsNonNumericPort() { + XCTAssertNil(HostPort.parseAuthority("example.com:abc")) + } + + func testRejectsEmptyHost() { + XCTAssertNil(HostPort.parseAuthority("")) + XCTAssertNil(HostPort.parseAuthority(":80")) + } + + // MARK: - parseAbsoluteURI + + func testParsesAbsoluteHTTPWithDefaultPort() { + guard let (hp, path, scheme) = HostPort.parseAbsoluteURI("http://example.com/foo") else { + return XCTFail("expected parse to succeed") + } + XCTAssertEqual(hp.host, "example.com") + XCTAssertEqual(hp.port, 80) + XCTAssertEqual(path, "/foo") + XCTAssertEqual(scheme, .http) + } + + func testParsesAbsoluteHTTPSWithDefaultPort() { + guard let (hp, path, scheme) = HostPort.parseAbsoluteURI("https://api.example.com/v1/users") else { + return XCTFail() + } + XCTAssertEqual(hp.host, "api.example.com") + XCTAssertEqual(hp.port, 443) + XCTAssertEqual(path, "/v1/users") + XCTAssertEqual(scheme, .https) + } + + func testParsesAbsoluteHTTPSWithExplicitPort() { + guard let (hp, _, _) = HostPort.parseAbsoluteURI("https://example.com:8443/x") else { + return XCTFail() + } + XCTAssertEqual(hp.port, 8443) + } + + func testParsesIPv6HTTPDefaultsTo80NotTo443() { + guard let (hp, _, _) = HostPort.parseAbsoluteURI("http://[::1]/path") else { + return XCTFail() + } + XCTAssertEqual(hp.host, "::1") + XCTAssertEqual(hp.port, 80, "IPv6 HTTP URL must default to port 80, not 443") + } + + func testParsesIPv6HTTPSDefaultsTo443() { + guard let (hp, _, _) = HostPort.parseAbsoluteURI("https://[::1]/path") else { + return XCTFail() + } + XCTAssertEqual(hp.port, 443) + } + + func testParsesIPv6WithExplicitPort() { + guard let (hp, _, _) = HostPort.parseAbsoluteURI("https://[2001:db8::1]:9000/x") else { + return XCTFail() + } + XCTAssertEqual(hp.host, "2001:db8::1") + XCTAssertEqual(hp.port, 9000) + } + + func testParsesURIWithQueryStringNoSlash() { + guard let (hp, path, _) = HostPort.parseAbsoluteURI("http://example.com?q=1") else { + return XCTFail() + } + XCTAssertEqual(hp.host, "example.com") + XCTAssertEqual(path, "/?q=1") + } + + func testParsesURIWithFragmentNoSlash() { + guard let (hp, path, _) = HostPort.parseAbsoluteURI("http://example.com#section") else { + return XCTFail() + } + XCTAssertEqual(hp.host, "example.com") + XCTAssertEqual(path, "/#section") + } + + func testParsesURIWithoutPathDefaultsToSlash() { + guard let (hp, path, _) = HostPort.parseAbsoluteURI("http://example.com") else { + return XCTFail() + } + XCTAssertEqual(hp.host, "example.com") + XCTAssertEqual(path, "/") + } + + func testParsesURIWithPathAndQuery() { + guard let (_, path, _) = HostPort.parseAbsoluteURI("https://example.com/users?id=42&name=hi") else { + return XCTFail() + } + XCTAssertEqual(path, "/users?id=42&name=hi") + } + + func testRejectsURIWithUnknownScheme() { + XCTAssertNil(HostPort.parseAbsoluteURI("ftp://example.com/x")) + } + + func testRejectsURIWithInvalidPort() { + XCTAssertNil(HostPort.parseAbsoluteURI("http://example.com:abc/x")) + XCTAssertNil(HostPort.parseAbsoluteURI("http://example.com:99999/x")) + } +} diff --git a/macos/Tests/ReverseAPIProxyTests/LeafCertificateFactoryTests.swift b/macos/Tests/ReverseAPIProxyTests/LeafCertificateFactoryTests.swift new file mode 100644 index 0000000..59cad24 --- /dev/null +++ b/macos/Tests/ReverseAPIProxyTests/LeafCertificateFactoryTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import ReverseAPIProxy + +final class LeafCertificateFactoryTests: XCTestCase { + func testCachesMaterialsPerHost() async throws { + let root = try CertificateAuthority.generateRoot() + let factory = try LeafCertificateFactory(root: root) + let first = try await factory.materials(for: "example.com") + let second = try await factory.materials(for: "example.com") + XCTAssertEqual(try first.certificate.toDERBytes(), try second.certificate.toDERBytes()) + } + + func testDifferentHostsProduceDifferentCertificates() async throws { + let root = try CertificateAuthority.generateRoot() + let factory = try LeafCertificateFactory(root: root) + let a = try await factory.materials(for: "a.example.com") + let b = try await factory.materials(for: "b.example.com") + XCTAssertNotEqual(try a.certificate.toDERBytes(), try b.certificate.toDERBytes()) + } + + func testCacheRespectsLimit() async throws { + let root = try CertificateAuthority.generateRoot() + let factory = try LeafCertificateFactory(root: root, cacheLimit: 2) + _ = try await factory.materials(for: "a.example.com") + _ = try await factory.materials(for: "b.example.com") + _ = try await factory.materials(for: "c.example.com") + let count = await factory.cacheCount() + XCTAssertEqual(count, 2, "cache must not exceed declared limit") + } + + func testCacheEvictsLeastRecentlyUsed() async throws { + let root = try CertificateAuthority.generateRoot() + let factory = try LeafCertificateFactory(root: root, cacheLimit: 2) + _ = try await factory.materials(for: "a.example.com") + _ = try await factory.materials(for: "b.example.com") + _ = try await factory.materials(for: "a.example.com") + _ = try await factory.materials(for: "c.example.com") + let aFirstDER = try await factory.materials(for: "a.example.com").certificate.toDERBytes() + _ = try await factory.materials(for: "b.example.com") + let aSecondDER = try await factory.materials(for: "a.example.com").certificate.toDERBytes() + XCTAssertEqual(aFirstDER, aSecondDER, "recently used cert should still be cached") + } + + func testIPv4HostProducesValidCertificate() async throws { + let root = try CertificateAuthority.generateRoot() + let factory = try LeafCertificateFactory(root: root) + let materials = try await factory.materials(for: "127.0.0.1") + XCTAssertFalse(try materials.certificate.toDERBytes().isEmpty) + } +} diff --git a/macos/Tests/ReverseAPIProxyTests/TLSContextFactoryTests.swift b/macos/Tests/ReverseAPIProxyTests/TLSContextFactoryTests.swift new file mode 100644 index 0000000..4460b61 --- /dev/null +++ b/macos/Tests/ReverseAPIProxyTests/TLSContextFactoryTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import ReverseAPIProxy + +final class TLSContextFactoryTests: XCTestCase { + func testReturnsSameInstanceOnSecondCall() async throws { + let root = try CertificateAuthority.generateRoot() + let leaf = try LeafCertificateFactory(root: root) + let factory = TLSContextFactory(leafFactory: leaf) + let first = try await factory.serverContext(for: "example.com") + let second = try await factory.serverContext(for: "example.com") + XCTAssertTrue(first === second) + } + + func testDeduplicatesConcurrentCallsForSameHost() async throws { + let root = try CertificateAuthority.generateRoot() + let leaf = try LeafCertificateFactory(root: root) + let factory = TLSContextFactory(leafFactory: leaf) + async let a = factory.serverContext(for: "concurrent.example.com") + async let b = factory.serverContext(for: "concurrent.example.com") + async let c = factory.serverContext(for: "concurrent.example.com") + let (ra, rb, rc) = try await (a, b, c) + XCTAssertTrue(ra === rb) + XCTAssertTrue(rb === rc) + let hosts = await factory.cachedHosts() + XCTAssertEqual(hosts.filter { $0 == "concurrent.example.com" }.count, 1) + } + + func testDifferentHostsGetDifferentContexts() async throws { + let root = try CertificateAuthority.generateRoot() + let leaf = try LeafCertificateFactory(root: root) + let factory = TLSContextFactory(leafFactory: leaf) + let a = try await factory.serverContext(for: "a.example.com") + let b = try await factory.serverContext(for: "b.example.com") + XCTAssertFalse(a === b) + } +}