diff --git a/macos/Sources/ReverseAPIProxy/CA/CAStore.swift b/macos/Sources/ReverseAPIProxy/CA/CAStore.swift new file mode 100644 index 0000000..3d98fb5 --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/CA/CAStore.swift @@ -0,0 +1,137 @@ +import Foundation +import Crypto +import X509 +import SwiftASN1 +import Security + +public enum CAStoreError: Error { + case missingCertificateOnDisk + case missingPrivateKeyInKeychain + case keychainWriteFailed(OSStatus) + case keychainReadFailed(OSStatus) + case keychainDeleteFailed(OSStatus) + case invalidStoredPrivateKey + case certificateDeleteFailed(any Error) +} + +public final class CAStore: @unchecked Sendable { + public let directory: URL + public let certificateURL: URL + + private let keychainService = "app.reverseapi" + private let keychainAccount = "ca.root-private-key" + + public init(applicationSupportURL: URL) throws { + let root = applicationSupportURL.appendingPathComponent("ReverseAPI", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + self.directory = root + self.certificateURL = root.appendingPathComponent("root.cer") + } + + public func loadOrCreate() throws -> RootCertificate { + if exists() { + return try load() + } + return try createAndStore() + } + + public func load() throws -> RootCertificate { + guard FileManager.default.fileExists(atPath: certificateURL.path) else { + throw CAStoreError.missingCertificateOnDisk + } + let derBytes = try Data(contentsOf: certificateURL) + let certificate = try Certificate(derEncoded: Array(derBytes)) + let pemData = try loadPrivateKeyPEM() + guard let pemString = String(data: pemData, encoding: .utf8) else { + throw CAStoreError.invalidStoredPrivateKey + } + let privateKey = try Certificate.PrivateKey(pemEncoded: pemString) + return RootCertificate(certificate: certificate, privateKey: privateKey) + } + + public func createAndStore() throws -> RootCertificate { + let root = try CertificateAuthority.generateRoot() + try Data(try root.derBytes()).write(to: certificateURL, options: .atomic) + let pem = try root.privateKey.serializeAsPEM().pemString + try storePrivateKeyPEM(Data(pem.utf8)) + return root + } + + public func reset() throws { + let manager = FileManager.default + if manager.fileExists(atPath: certificateURL.path) { + do { + try manager.removeItem(at: certificateURL) + } catch { + throw CAStoreError.certificateDeleteFailed(error) + } + } + try deletePrivateKey() + } + + public func exists() -> Bool { + guard FileManager.default.fileExists(atPath: certificateURL.path) else { return false } + return privateKeyExists() + } + + private func storePrivateKeyPEM(_ data: Data) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + ] + let deleteStatus = SecItemDelete(query as CFDictionary) + if deleteStatus != errSecSuccess && deleteStatus != errSecItemNotFound { + throw CAStoreError.keychainDeleteFailed(deleteStatus) + } + + var addQuery = query + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { throw CAStoreError.keychainWriteFailed(status) } + } + + private func loadPrivateKeyPEM() throws -> Data { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + throw CAStoreError.missingPrivateKeyInKeychain + } + guard status == errSecSuccess, let data = result as? Data else { + throw CAStoreError.keychainReadFailed(status) + } + return data + } + + private func privateKeyExists() -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecReturnData as String: false, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + let status = SecItemCopyMatching(query as CFDictionary, nil) + return status == errSecSuccess + } + + private func deletePrivateKey() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + ] + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + throw CAStoreError.keychainDeleteFailed(status) + } + } +} diff --git a/macos/Sources/ReverseAPIProxy/ProxyEngine+Bootstrap.swift b/macos/Sources/ReverseAPIProxy/ProxyEngine+Bootstrap.swift new file mode 100644 index 0000000..26dc566 --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/ProxyEngine+Bootstrap.swift @@ -0,0 +1,21 @@ +import Foundation + +extension ProxyEngine { + public static func bootstrap( + applicationSupportURL: URL, + port: Int = 8888, + bus: FlowBus = FlowBus() + ) throws -> ProxyEngine { + let store = try CAStore(applicationSupportURL: applicationSupportURL) + let root = try store.loadOrCreate() + return try ProxyEngine(root: root, port: port, bus: bus) + } + + public func rootDERBytes() throws -> Data { + Data(try root.derBytes()) + } + + public func rootPEM() throws -> String { + try root.pem() + } +} diff --git a/macos/Sources/ReverseAPIProxy/System/CertificateTrustInstaller.swift b/macos/Sources/ReverseAPIProxy/System/CertificateTrustInstaller.swift new file mode 100644 index 0000000..280bac1 --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/System/CertificateTrustInstaller.swift @@ -0,0 +1,66 @@ +import Foundation +import Security + +public enum CertificateTrustError: Error { + case invalidCertificate + case addFailed(OSStatus) + case trustFailed(OSStatus) +} + +public final class CertificateTrustInstaller: Sendable { + public init() {} + + public func install(derBytes: Data) throws { + let cert = try secCertificate(from: derBytes) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecValueRef as String: cert, + ] + let status = SecItemAdd(addQuery as CFDictionary, nil) + if status != errSecSuccess && status != errSecDuplicateItem { + throw CertificateTrustError.addFailed(status) + } + + let trustSettings: [[String: Any]] = [[ + kSecTrustSettingsResult as String: SecTrustSettingsResult.trustRoot.rawValue + ]] + let trustStatus = SecTrustSettingsSetTrustSettings(cert, .user, trustSettings as CFArray) + guard trustStatus == errSecSuccess else { + throw CertificateTrustError.trustFailed(trustStatus) + } + } + + public func uninstall(derBytes: Data) throws { + let cert = try secCertificate(from: derBytes) + SecTrustSettingsRemoveTrustSettings(cert, .user) + let removeQuery: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecValueRef as String: cert, + ] + SecItemDelete(removeQuery as CFDictionary) + } + + public func isInstalled(derBytes: Data) -> Bool { + guard let cert = try? secCertificate(from: derBytes) else { return false } + var settings: CFArray? + let status = SecTrustSettingsCopyTrustSettings(cert, .user, &settings) + guard status == errSecSuccess, let array = settings as? [[String: Any]] else { + return false + } + for entry in array { + if let raw = entry[kSecTrustSettingsResult as String] as? Int, + raw == Int(SecTrustSettingsResult.trustRoot.rawValue) { + return true + } + } + return false + } + + private func secCertificate(from derBytes: Data) throws -> SecCertificate { + guard let cert = SecCertificateCreateWithData(nil, derBytes as CFData) else { + throw CertificateTrustError.invalidCertificate + } + return cert + } +} diff --git a/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift b/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift new file mode 100644 index 0000000..bf7bf65 --- /dev/null +++ b/macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift @@ -0,0 +1,195 @@ +import Foundation + +public enum SystemProxyError: Error { + case scriptFailed(String) + case networksetupFailed(Int32, String) + case noNetworkServices + case invalidHost(String) + case invalidPort(Int) +} + +public struct ProxyServiceSnapshot: Sendable, Equatable { + public let service: String + public let httpEnabled: Bool + public let httpHost: String + public let httpPort: Int + public let httpsEnabled: Bool + public let httpsHost: String + public let httpsPort: Int +} + +public final class SystemProxyController: @unchecked Sendable { + private let networksetup = "/usr/sbin/networksetup" + + public init() {} + + public func enable(host: String, port: Int) throws { + try validate(host: host, port: port) + let services = try listNetworkServices() + guard !services.isEmpty else { throw SystemProxyError.noNetworkServices } + let quotedHost = shellQuote(host) + let commands = services.flatMap { service -> [String] in + let quotedService = shellQuote(service) + return [ + "\(networksetup) -setwebproxy \(quotedService) \(quotedHost) \(port)", + "\(networksetup) -setsecurewebproxy \(quotedService) \(quotedHost) \(port)", + "\(networksetup) -setwebproxystate \(quotedService) on", + "\(networksetup) -setsecurewebproxystate \(quotedService) on", + ] + } + try runWithAdminPrivileges(commands.joined(separator: " && ")) + } + + public func disable() throws { + let services = try listNetworkServices() + guard !services.isEmpty else { return } + let commands = services.flatMap { service -> [String] in + let q = shellQuote(service) + return [ + "\(networksetup) -setwebproxystate \(q) off", + "\(networksetup) -setsecurewebproxystate \(q) off", + ] + } + try runWithAdminPrivileges(commands.joined(separator: " && ")) + } + + public func restore(_ snapshots: [ProxyServiceSnapshot]) throws { + var commands: [String] = [] + for snapshot in snapshots { + let q = shellQuote(snapshot.service) + commands.append( + "\(networksetup) -setwebproxy \(q) \(shellQuote(snapshot.httpHost)) \(snapshot.httpPort)" + ) + commands.append( + "\(networksetup) -setsecurewebproxy \(q) \(shellQuote(snapshot.httpsHost)) \(snapshot.httpsPort)" + ) + commands.append( + "\(networksetup) -setwebproxystate \(q) \(snapshot.httpEnabled ? "on" : "off")" + ) + commands.append( + "\(networksetup) -setsecurewebproxystate \(q) \(snapshot.httpsEnabled ? "on" : "off")" + ) + } + guard !commands.isEmpty else { return } + try runWithAdminPrivileges(commands.joined(separator: " && ")) + } + + public func snapshot() throws -> [ProxyServiceSnapshot] { + try listNetworkServices().map { service in + let http = try parseGetWebProxy(service: service, command: "-getwebproxy") + let https = try parseGetWebProxy(service: service, command: "-getsecurewebproxy") + return ProxyServiceSnapshot( + service: service, + httpEnabled: http.enabled, + httpHost: http.host, + httpPort: http.port, + httpsEnabled: https.enabled, + httpsHost: https.host, + httpsPort: https.port + ) + } + } + + public func isEnabled() throws -> Bool { + let services = try listNetworkServices() + for service in services { + let http = try shell(networksetup, "-getwebproxy", service) + let https = try shell(networksetup, "-getsecurewebproxy", service) + if http.contains("Enabled: Yes") && https.contains("Enabled: Yes") { + return true + } + } + return false + } + + public func listNetworkServices() throws -> [String] { + let output = try shell(networksetup, "-listallnetworkservices") + return output + .split(whereSeparator: \.isNewline) + .map(String.init) + .filter { line in + guard !line.isEmpty else { return false } + if line.hasPrefix("An asterisk") { return false } + if line.hasPrefix("*") { return false } + return true + } + } + + internal struct GetWebProxyResult { + let enabled: Bool + let host: String + let port: Int + } + + internal static func parse(getWebProxyOutput output: String) -> GetWebProxyResult { + var enabled = false + var host = "" + var port = 0 + for line in output.split(whereSeparator: \.isNewline) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("Enabled:") { + enabled = trimmed.contains("Yes") + } else if trimmed.hasPrefix("Server:") { + host = String(trimmed.dropFirst("Server:".count)).trimmingCharacters(in: .whitespaces) + } else if trimmed.hasPrefix("Port:") { + port = Int(trimmed.dropFirst("Port:".count).trimmingCharacters(in: .whitespaces)) ?? 0 + } + } + return GetWebProxyResult(enabled: enabled, host: host, port: port) + } + + private func parseGetWebProxy(service: String, command: String) throws -> GetWebProxyResult { + let output = try shell(networksetup, command, service) + return Self.parse(getWebProxyOutput: output) + } + + private func validate(host: String, port: Int) throws { + guard HostPort.validPortRange.contains(port) else { throw SystemProxyError.invalidPort(port) } + let forbidden = CharacterSet(charactersIn: " \t\n\r'\"`\\$;&|<>") + if host.rangeOfCharacter(from: forbidden) != nil || host.isEmpty { + throw SystemProxyError.invalidHost(host) + } + } + + @discardableResult + private func shell(_ executable: String, _ arguments: String...) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + try process.run() + process.waitUntilExit() + let outData = outPipe.fileHandleForReading.readDataToEndOfFile() + let errData = errPipe.fileHandleForReading.readDataToEndOfFile() + let stdout = String(decoding: outData, as: UTF8.self) + let stderr = String(decoding: errData, as: UTF8.self) + guard process.terminationStatus == 0 else { + throw SystemProxyError.networksetupFailed(process.terminationStatus, stderr.isEmpty ? stdout : stderr) + } + return stdout + } + + private func runWithAdminPrivileges(_ command: String) throws { + let escaped = command + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let script = "do shell script \"\(escaped)\" with administrator privileges" + var error: NSDictionary? + if let apple = NSAppleScript(source: script) { + apple.executeAndReturnError(&error) + } else { + throw SystemProxyError.scriptFailed("failed to construct script") + } + if let error { + let message = error[NSAppleScript.errorMessage] as? String ?? "\(error)" + throw SystemProxyError.scriptFailed(message) + } + } + + internal func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/macos/Sources/rae-proxy/main.swift b/macos/Sources/rae-proxy/main.swift index 46b74de..b84943d 100644 --- a/macos/Sources/rae-proxy/main.swift +++ b/macos/Sources/rae-proxy/main.swift @@ -8,11 +8,34 @@ struct RAEProxyCLI { let logger = AppLogger("cli") do { let options = try CLIOptions.parse() - let store = RootCertificateStore(directory: try options.dataDirectory()) + let appSupport = try options.applicationSupportDirectory() + let store = try CAStore(applicationSupportURL: appSupport) + let installer = CertificateTrustInstaller() + + if options.resetCA { + if store.exists(), let existing = try? store.load() { + let existingDER = Data(try existing.derBytes()) + if installer.isInstalled(derBytes: existingDER) { + try installer.uninstall(derBytes: existingDER) + } + } + try store.reset() + print("CA reset.") + if options.exitAfterInstall { return } + } + let root = try store.loadOrCreate() + let rootDERBytes = Data(try root.derBytes()) + + if options.trustCA { + try installer.install(derBytes: rootDERBytes) + print("CA trust installed for current user.") + if options.exitAfterInstall { return } + } 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 { @@ -38,11 +61,6 @@ struct RAEProxyCLI { 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 { @@ -78,45 +96,6 @@ struct RAEProxyCLI { } } - 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 { @@ -168,6 +147,8 @@ struct RAEProxyCLI { private struct CLIOptions { var port: Int var trustCA: Bool + var exitAfterInstall: Bool + var resetCA: Bool var launchChrome: Bool var systemProxy: Bool var dataDirectoryOverride: URL? @@ -178,6 +159,8 @@ private struct CLIOptions { ) throws -> CLIOptions { var port = environment["RAE_PROXY_PORT"].flatMap(Int.init) ?? 8888 var trustCA = environment["RAE_PROXY_TRUST_CA"].map(isTruthy) ?? false + var exitAfterInstall = false + var resetCA = 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) } @@ -185,15 +168,19 @@ private struct CLIOptions { var iterator = arguments.makeIterator() while let argument = iterator.next() { switch argument { - case "--trust-ca": + case "--trust-ca", "--install-ca-trust": trustCA = true case "--no-trust-ca": trustCA = false + case "--exit-after-install": + exitAfterInstall = true + case "--reset-ca": + resetCA = true case "--launch-chrome": launchChrome = true case "--no-launch-chrome": launchChrome = false - case "--system-proxy": + case "--system-proxy", "--enable-system-proxy": systemProxy = true case "--no-system-proxy": systemProxy = false @@ -219,17 +206,24 @@ private struct CLIOptions { return CLIOptions( port: port, trustCA: trustCA, + exitAfterInstall: exitAfterInstall, + resetCA: resetCA, launchChrome: launchChrome, systemProxy: systemProxy, dataDirectoryOverride: dataDirectory ) } - func dataDirectory() throws -> URL { + func applicationSupportDirectory() throws -> URL { if let dataDirectoryOverride { return dataDirectoryOverride } - return try RootCertificateStore.defaultDirectory() + return try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) } private static func isTruthy(_ value: String) -> Bool { diff --git a/macos/Tests/ReverseAPIProxyTests/SystemProxyControllerTests.swift b/macos/Tests/ReverseAPIProxyTests/SystemProxyControllerTests.swift new file mode 100644 index 0000000..043966f --- /dev/null +++ b/macos/Tests/ReverseAPIProxyTests/SystemProxyControllerTests.swift @@ -0,0 +1,81 @@ +import XCTest +@testable import ReverseAPIProxy + +final class SystemProxyControllerTests: XCTestCase { + func testShellQuoteEscapesSingleQuotes() { + let controller = SystemProxyController() + XCTAssertEqual(controller.shellQuote("plain"), "'plain'") + XCTAssertEqual(controller.shellQuote("o'brien"), "'o'\\''brien'") + } + + func testShellQuoteHandlesShellMetacharacters() { + let controller = SystemProxyController() + XCTAssertEqual(controller.shellQuote("a; rm -rf /"), "'a; rm -rf /'") + XCTAssertEqual(controller.shellQuote("foo && bar"), "'foo && bar'") + } + + func testParseGetWebProxyEnabled() { + let output = """ + Enabled: Yes + Server: 127.0.0.1 + Port: 8888 + Authenticated Proxy Enabled: 0 + """ + let result = SystemProxyController.parse(getWebProxyOutput: output) + XCTAssertTrue(result.enabled) + XCTAssertEqual(result.host, "127.0.0.1") + XCTAssertEqual(result.port, 8888) + } + + func testParseGetWebProxyDisabled() { + let output = """ + Enabled: No + Server: + Port: 0 + Authenticated Proxy Enabled: 0 + """ + let result = SystemProxyController.parse(getWebProxyOutput: output) + XCTAssertFalse(result.enabled) + XCTAssertEqual(result.host, "") + XCTAssertEqual(result.port, 0) + } + + func testParseGetWebProxyHandlesExtraWhitespace() { + let output = " Enabled: Yes \n Server: proxy.example.com \n Port: 3128" + let result = SystemProxyController.parse(getWebProxyOutput: output) + XCTAssertTrue(result.enabled) + XCTAssertEqual(result.host, "proxy.example.com") + XCTAssertEqual(result.port, 3128) + } + + func testEnableRejectsHostWithShellMetacharacters() { + let controller = SystemProxyController() + for bad in ["127.0.0.1; rm -rf /", "$(whoami)", "a b"] { + XCTAssertThrowsError(try controller.enable(host: bad, port: 8888)) { error in + guard case SystemProxyError.invalidHost = error else { + XCTFail("expected invalidHost, got \(error)"); return + } + } + } + } + + func testEnableRejectsInvalidPort() { + let controller = SystemProxyController() + for bad in [0, 70000] { + XCTAssertThrowsError(try controller.enable(host: "127.0.0.1", port: bad)) { error in + guard case SystemProxyError.invalidPort = error else { + XCTFail("expected invalidPort, got \(error)"); return + } + } + } + } + + func testEnableRejectsEmptyHost() { + let controller = SystemProxyController() + XCTAssertThrowsError(try controller.enable(host: "", port: 8888)) { error in + guard case SystemProxyError.invalidHost = error else { + XCTFail("expected invalidHost, got \(error)"); return + } + } + } +}