import Foundation import Network import MacToolsPluginKit protocol IPOverviewLeakTesting: Sendable { func checkWebRTC(target: IPOverviewWebRTCTarget) async -> IPOverviewLeakTestResult func checkDNS(id: String, name: String) async -> IPOverviewLeakTestResult } struct IPOverviewLeakTestService: IPOverviewLeakTesting { private let httpClient: any IPOverviewHTTPClient private let timeout: TimeInterval private let localization: PluginLocalization init( httpClient: any IPOverviewHTTPClient = URLSession.shared, timeout: TimeInterval = 5, localization: PluginLocalization = PluginLocalization(bundle: .main) ) { self.httpClient = httpClient self.timeout = timeout self.localization = localization } func checkWebRTC(target: IPOverviewWebRTCTarget) async -> IPOverviewLeakTestResult { do { let ip = try await STUNClient(timeout: timeout).reflexiveAddress( host: target.host, port: target.port ) let geo = await geoEndpoint( ip: ip, natType: localization.string("leak.nat.portRestrictedOrSymmetric", defaultValue: "端口限制型或对称型") ) return IPOverviewLeakTestResult( id: target.id, name: target.name, status: .success(geo) ) } catch { return IPOverviewLeakTestResult( id: target.id, name: target.name, status: .failure(Self.userFacingMessage(for: error, localization: localization)) ) } } func checkDNS(id: String, name: String) async -> IPOverviewLeakTestResult { if let endpoint = await checkIPAPIDNSLeak() { return IPOverviewLeakTestResult(id: id, name: name, status: .success(endpoint)) } if let endpoint = await checkSurfsharkDNSLeak() { return IPOverviewLeakTestResult(id: id, name: name, status: .success(endpoint)) } return IPOverviewLeakTestResult( id: id, name: name, status: .failure(localization.string("leak.error.noDNSExit", defaultValue: "未获取到 DNS 出口")) ) } private func checkIPAPIDNSLeak() async -> IPOverviewLeakEndpoint? { guard let url = URL(string: "https://\(randomToken()).edns.ip-api.com/json") else { return nil } do { let (data, response) = try await fetch(url) guard (200..<300).contains(response.statusCode), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let dns = object["dns"] as? [String: Any], let ip = dns["ip"] as? String else { return nil } let geo = dns["geo"] as? String let parts = geo?.components(separatedBy: " - ") ?? [] return IPOverviewLeakEndpoint( ip: ip, natType: nil, country: parts.first, countryCode: nil, organization: parts.dropFirst().first ) } catch { return nil } } private func checkSurfsharkDNSLeak() async -> IPOverviewLeakEndpoint? { guard let url = URL(string: "https://\(shortToken()).ipv4.surfsharkdns.com") else { return nil } do { let (data, response) = try await fetch(url) guard (200..<300).contains(response.statusCode), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let first = object.values.first as? [String: Any], let ip = first["IP"] as? String else { return nil } return IPOverviewLeakEndpoint( ip: ip, natType: nil, country: first["Country"] as? String, countryCode: first["CountryCode"] as? String, organization: first["ISP"] as? String ) } catch { return nil } } private func geoEndpoint(ip: String, natType: String?) async -> IPOverviewLeakEndpoint { let url = IPOverviewGeoSource.ipwhois.url(ip: ip) do { let (data, response) = try await fetch(url) guard (200..<300).contains(response.statusCode), let info = IPOverviewParser.geoInfoFromIPWhois(data, ip: ip) else { return IPOverviewLeakEndpoint( ip: ip, natType: natType, country: nil, countryCode: nil, organization: nil ) } return IPOverviewLeakEndpoint( ip: ip, natType: natType, country: info.country, countryCode: info.countryCode, organization: info.organization ?? info.isp ) } catch { return IPOverviewLeakEndpoint( ip: ip, natType: natType, country: nil, countryCode: nil, organization: nil ) } } private func fetch(_ url: URL) async throws -> (Data, HTTPURLResponse) { var request = URLRequest(url: url) request.timeoutInterval = timeout request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData request.setValue("MacTools IP Overview", forHTTPHeaderField: "User-Agent") return try await httpClient.data(for: request) } private func randomToken() -> String { "\(Int(Date().timeIntervalSince1970))mactools\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(10))" } private func shortToken() -> String { "mt\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(10))" } private static func userFacingMessage(for error: Error, localization: PluginLocalization) -> String { if let stunError = error as? STUNClient.Error { return stunError.localizedDescription(localization: localization) } return error.localizedDescription } } private struct STUNClient { enum Error: LocalizedError { case invalidPort case timedOut case noMappedAddress case failed(String) var errorDescription: String? { localizedDescription() } func localizedDescription(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { switch self { case .invalidPort: return localization.string("stun.error.invalidPort", defaultValue: "STUN 端口无效") case .timedOut: return localization.string("stun.error.timedOut", defaultValue: "STUN 请求超时") case .noMappedAddress: return localization.string("stun.error.noMappedAddress", defaultValue: "未返回外部地址") case .failed(let message): return message } } } let timeout: TimeInterval func reflexiveAddress(host: String, port: UInt16) async throws -> String { guard let nwPort = NWEndpoint.Port(rawValue: port) else { throw Error.invalidPort } return try await withCheckedThrowingContinuation { continuation in let connection = NWConnection( host: NWEndpoint.Host(host), port: nwPort, using: .udp ) let transactionID = Self.transactionID() let request = Self.bindingRequest(transactionID: transactionID) let resumeState = STUNResumeState(connection: connection, continuation: continuation) @Sendable func finish(_ result: Result) { resumeState.finish(result) } connection.stateUpdateHandler = { state in switch state { case .ready: connection.send(content: request, completion: .contentProcessed { error in if let error { finish(.failure(Error.failed(error.localizedDescription))) return } connection.receiveMessage { data, _, _, error in if let error { finish(.failure(Error.failed(error.localizedDescription))) return } guard let data, let ip = Self.parseMappedAddress(data, transactionID: transactionID) else { finish(.failure(Error.noMappedAddress)) return } finish(.success(ip)) } }) case .failed(let error): finish(.failure(Error.failed(error.localizedDescription))) case .cancelled: break default: break } } connection.start(queue: .global(qos: .utility)) DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + timeout) { finish(.failure(Error.timedOut)) } } } private static func transactionID() -> [UInt8] { (0..<12).map { _ in UInt8.random(in: 0...255) } } private static func bindingRequest(transactionID: [UInt8]) -> Data { var bytes: [UInt8] = [ 0x00, 0x01, 0x00, 0x00, 0x21, 0x12, 0xA4, 0x42 ] bytes.append(contentsOf: transactionID) return Data(bytes) } private static func parseMappedAddress(_ data: Data, transactionID: [UInt8]) -> String? { let bytes = [UInt8](data) guard bytes.count >= 20, bytes[0] == 0x01, bytes[1] == 0x01, Array(bytes[8..<20]) == transactionID else { return nil } var index = 20 while index + 4 <= bytes.count { let type = UInt16(bytes[index]) << 8 | UInt16(bytes[index + 1]) let length = Int(UInt16(bytes[index + 2]) << 8 | UInt16(bytes[index + 3])) let valueStart = index + 4 let valueEnd = valueStart + length guard valueEnd <= bytes.count else { return nil } if type == 0x0020 || type == 0x0001 { let value = Array(bytes[valueStart.. String? { guard value.count >= 8 else { return nil } let family = value[1] if family == 0x01 { var address = Array(value[4..<8]) if isXOR { let cookie: [UInt8] = [0x21, 0x12, 0xA4, 0x42] for index in 0..<4 { address[index] ^= cookie[index] } } return address.map(String.init).joined(separator: ".") } if family == 0x02, value.count >= 20 { var address = Array(value[4..<20]) if isXOR { let mask: [UInt8] = [0x21, 0x12, 0xA4, 0x42] for index in 0..<4 { address[index] ^= mask[index] } } var chunks: [String] = [] for index in stride(from: 0, to: address.count, by: 2) { let chunk = UInt16(address[index]) << 8 | UInt16(address[index + 1]) chunks.append(String(chunk, radix: 16)) } return chunks.joined(separator: ":") } return nil } } private final class STUNResumeState: @unchecked Sendable { private let lock = NSLock() private var didResume = false private let connection: NWConnection private let continuation: CheckedContinuation init( connection: NWConnection, continuation: CheckedContinuation ) { self.connection = connection self.continuation = continuation } func finish(_ result: Result) { lock.lock() defer { lock.unlock() } guard !didResume else { return } didResume = true connection.cancel() continuation.resume(with: result) } }