Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions macos/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.DS_Store
.build/
.swiftpm/
DerivedData/
*.xcodeproj
*.xcworkspace
xcuserdata/
Package.resolved
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
*.profraw
40 changes: 40 additions & 0 deletions macos/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// 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: "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"]
),
]
)
56 changes: 56 additions & 0 deletions macos/Sources/ReverseAPIProxy/CA/CertificateAuthority.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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 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)
}
}
115 changes: 115 additions & 0 deletions macos/Sources/ReverseAPIProxy/CA/LeafCertificateFactory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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
}

private let root: RootCertificate
private let rootSSL: NIOSSLCertificate
private let rootKeyIdentifier: ArraySlice<UInt8>
private var cache: [String: Materials] = [:]

public init(root: RootCertificate) 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
}

public func materials(for host: String) throws -> Materials {
if let cached = cache[host] { return cached }
let minted = try mint(host: host)
cache[host] = minted
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
return minted
}

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))
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(ArraySlice(bytes))])
}
if let bytes = ipv6Bytes(host) {
return SubjectAlternativeNames([.ipAddress(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<addrinfo>?
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) }
}
}
70 changes: 70 additions & 0 deletions macos/Sources/ReverseAPIProxy/Capture/CapturedFlow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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 portSegment: String
switch (scheme, port) {
case (.http, 80), (.https, 443):
portSegment = ""
default:
portSegment = ":\(port)"
}
return "\(scheme.rawValue)://\(host)\(portSegment)\(path)"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}
}
35 changes: 35 additions & 0 deletions macos/Sources/ReverseAPIProxy/Capture/FlowBus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Foundation

public enum FlowEvent: Sendable {
case started(CapturedFlow)
case updated(CapturedFlow)
case finished(CapturedFlow)
}

public actor FlowBus {
public typealias Stream = AsyncStream<FlowEvent>

private var subscribers: [UUID: Stream.Continuation] = [:]

public init() {}

public func subscribe() -> Stream {
let (stream, continuation) = Stream.makeStream(bufferingPolicy: .unbounded)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
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)
}
}

private func unsubscribe(_ token: UUID) {
subscribers.removeValue(forKey: token)
}
}
46 changes: 46 additions & 0 deletions macos/Sources/ReverseAPIProxy/Proxy/HostPort.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation

public struct HostPort: Sendable, Hashable {
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) -> HostPort? {
if string.hasPrefix("[") {
guard let close = string.firstIndex(of: "]") else { return nil }
let host = String(string[string.index(after: string.startIndex)..<close])
let rest = string[string.index(after: close)...]
if rest.isEmpty { return HostPort(host: host, port: 443) }
guard rest.first == ":", let port = Int(rest.dropFirst()) else { return nil }
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
return HostPort(host: host, port: port)
}
let parts = string.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
guard let hostPart = parts.first, !hostPart.isEmpty else { return nil }
if parts.count == 1 {
return HostPort(host: String(hostPart), port: 443)
}
guard let port = Int(parts[1]) else { return nil }
return HostPort(host: String(hostPart), port: port)
}

public static func parseAbsoluteURI(_ uri: String) -> (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 pathStart = withoutScheme.firstIndex(of: "/") ?? withoutScheme.endIndex
let authority = String(withoutScheme[..<pathStart])
let path = pathStart == withoutScheme.endIndex ? "/" : String(withoutScheme[pathStart...])
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
guard var hp = HostPort.parseAuthority(authority) else { return nil }
if !authority.contains(":") {
hp = HostPort(host: hp.host, port: defaultPort)
}
return (hp, path, captured)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}
}
8 changes: 8 additions & 0 deletions macos/Sources/ReverseAPIProxy/Proxy/ProxyContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import NIOCore

struct ProxyContext: @unchecked Sendable {
let tlsContexts: TLSContextFactory
let upstream: UpstreamPump
let bus: FlowBus
let logger: AppLogger
}
Loading