import Flutter import Foundation import UIKit import CommonCrypto public class NewHttpDnsPlugin: NSObject, FlutterPlugin { private var appId: String? private var secretKey: String? private var apiUrl: String? private var primaryServiceHost: String? private var backupServiceHost: String? private var servicePort: Int = 443 private var serviceScheme: String = "https" private var desiredLogEnabled: Bool? private var desiredHttpsEnabled: Bool? private var desiredConnectTimeoutSeconds: TimeInterval = 3 private var desiredReadTimeoutSeconds: TimeInterval = 5 private lazy var sessionId: String = { UUID().uuidString.replacingOccurrences(of: "-", with: "") }() public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "TrustAPP_httpdns", binaryMessenger: registrar.messenger()) let instance = NewHttpDnsPlugin() registrar.addMethodCallDelegate(instance, channel: channel) } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "initialize": let options = call.arguments as? [String: Any] ?? [:] guard let rawAppId = options["appId"] as? String else { result(false) return } let normalizedAppId = rawAppId.trimmingCharacters(in: .whitespacesAndNewlines) if normalizedAppId.isEmpty { result(false) return } appId = normalizedAppId apiUrl = (options["apiUrl"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) primaryServiceHost = (options["primaryServiceHost"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) backupServiceHost = (options["backupServiceHost"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) secretKey = (options["secretKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) if let p = options["servicePort"] as? Int, p > 0 { servicePort = p } if let urlStr = apiUrl, !urlStr.isEmpty { var normalized = urlStr if !urlStr.lowercased().hasPrefix("http://") && !urlStr.lowercased().hasPrefix("https://") { normalized = "https://" + urlStr } if let url = URL(string: normalized) { primaryServiceHost = url.host if let p = url.port { servicePort = p } serviceScheme = url.scheme ?? "https" } } else if let host = primaryServiceHost { // Keep as is, using defaults } else { result(false) return } result(true) case "setLogEnabled": let args = call.arguments as? [String: Any] desiredLogEnabled = (args?["enabled"] as? Bool) ?? false result(nil) case "setHttpsRequestEnabled": let args = call.arguments as? [String: Any] desiredHttpsEnabled = (args?["enabled"] as? Bool) ?? false result(nil) case "setPersistentCacheIPEnabled": result(nil) case "setReuseExpiredIPEnabled": result(nil) case "setPreResolveAfterNetworkChanged": result(nil) case "setIPRankingList": result(nil) case "setPreResolveHosts": result(nil) case "getSessionId": result(sessionId) case "cleanAllHostCache": result(nil) case "build": if desiredHttpsEnabled == false { NSLog("NewHttpDns(iOS): HTTPS is required by current protocol, force enabled") } if desiredLogEnabled == true { NSLog("NewHttpDns(iOS): build success, appId=\(appId ?? "")") } result(appId != nil && primaryServiceHost != nil) case "resolveHostSyncNonBlocking": guard let args = call.arguments as? [String: Any], let hostname = args["hostname"] as? String else { result(["ipv4": [], "ipv6": []]) return } let ipType = ((args["ipType"] as? String) ?? "auto").lowercased() resolveHost(hostname: hostname, ipType: ipType) { payload in result(payload) } case "resolveHostV1": guard let args = call.arguments as? [String: Any], let hostname = args["hostname"] as? String else { result(["ipv4": [], "ipv6": [], "ttl": 0]) return } let qtype = ((args["qtype"] as? String) ?? "A").uppercased() let cip = (args["cip"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) resolveSingle(hostname: hostname, qtype: qtype, cip: cip) { records, ttl in if qtype == "AAAA" { result(["ipv4": [], "ipv6": records, "ttl": ttl]) } else { result(["ipv4": records, "ipv6": [], "ttl": ttl]) } } default: result(FlutterMethodNotImplemented) } } private func resolveHost(hostname: String, ipType: String, completion: @escaping ([String: [String]]) -> Void) { let qtypes: [String] switch ipType { case "ipv6", "v6": qtypes = ["AAAA"] case "both", "64": qtypes = ["A", "AAAA"] default: qtypes = ["A"] } let group = DispatchGroup() let lock = NSLock() var ipv4: [String] = [] var ipv6: [String] = [] for qtype in qtypes { group.enter() resolveSingle(hostname: hostname, qtype: qtype, cip: nil) { records, _ in lock.lock() if qtype == "AAAA" { ipv6.append(contentsOf: records) } else { ipv4.append(contentsOf: records) } lock.unlock() group.leave() } } group.notify(queue: .main) { completion([ "ipv4": Array(Set(ipv4)), "ipv6": Array(Set(ipv6)) ]) } } private func resolveSingle(hostname: String, qtype: String, cip: String?, completion: @escaping ([String], Int) -> Void) { guard let appId = appId, let primary = primaryServiceHost else { completion([], 0) return } var hosts: [String] = [primary] if let backup = backupServiceHost, !backup.isEmpty, backup != primary { hosts.append(backup) } func attempt(_ index: Int) { if index >= hosts.count { completion([], 0) return } let serviceHost = hosts[index] var components = URLComponents() components.scheme = serviceScheme components.host = serviceHost components.port = servicePort components.path = "/resolve" var queryItems: [URLQueryItem] = [ URLQueryItem(name: "appId", value: appId), URLQueryItem(name: "dn", value: hostname), URLQueryItem(name: "qtype", value: qtype), URLQueryItem(name: "sdk_version", value: "flutter-ios-1.0.0"), URLQueryItem(name: "os", value: "ios"), URLQueryItem(name: "sid", value: sessionId) ] if let secret = secretKey, !secret.isEmpty { let exp = String(Int(Date().timeIntervalSince1970) + 600) let nonce = UUID().uuidString.replacingOccurrences(of: "-", with: "") let signRaw = "\(appId)|\(hostname.lowercased())|\(qtype.uppercased())|\(exp)|\(nonce)" let sign = hmacSHA256Hex(message: signRaw, secret: secret) queryItems.append(URLQueryItem(name: "exp", value: exp)) queryItems.append(URLQueryItem(name: "nonce", value: nonce)) queryItems.append(URLQueryItem(name: "sign", value: sign)) } if let cip = cip, !cip.isEmpty { queryItems.append(URLQueryItem(name: "cip", value: cip)) } components.queryItems = queryItems guard let url = components.url else { completion([], 0) return } var req = URLRequest(url: url) req.httpMethod = "GET" req.timeoutInterval = desiredConnectTimeoutSeconds + desiredReadTimeoutSeconds req.setValue(serviceHost, forHTTPHeaderField: "Host") let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = desiredConnectTimeoutSeconds + desiredReadTimeoutSeconds config.timeoutIntervalForResource = desiredConnectTimeoutSeconds + desiredReadTimeoutSeconds let session = URLSession(configuration: config) session.dataTask(with: req) { [weak self] data, _, error in defer { session.finishTasksAndInvalidate() } if let error = error { if self?.desiredLogEnabled == true { NSLog("NewHttpDns(iOS): resolve request failed host=\(serviceHost), err=\(error.localizedDescription)") } attempt(index + 1) return } guard let data = data else { attempt(index + 1) return } let parsedIPs = Self.extractIPsFromResolveResponse(data: data, qtype: qtype) if parsedIPs.isEmpty { attempt(index + 1) return } let ttl = Self.extractTTLFromResolveResponse(data: data) completion(parsedIPs, ttl) }.resume() } attempt(0) } private static func extractIPsFromResolveResponse(data: Data, qtype: String) -> [String] { guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], isResolveSuccessCode(obj["code"]), let dataObj = obj["data"] as? [String: Any], let records = dataObj["records"] as? [[String: Any]] else { return [] } var ips: [String] = [] for row in records { let type = ((row["type"] as? String) ?? "").uppercased() if type != qtype.uppercased() { continue } if let ip = row["ip"] as? String, !ip.isEmpty { ips.append(ip) } } return ips } private static func extractTTLFromResolveResponse(data: Data) -> Int { guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let dataObj = obj["data"] as? [String: Any], let ttlValue = dataObj["ttl"] else { return 0 } if let ttl = ttlValue as? Int { return ttl } if let ttlString = ttlValue as? String, let ttl = Int(ttlString) { return ttl } return 0 } private static func isResolveSuccessCode(_ codeValue: Any?) -> Bool { if let code = codeValue as? String { let normalized = code.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() return normalized == "SUCCESS" || normalized == "0" } if let code = codeValue as? Int { return code == 0 } if let code = codeValue as? NSNumber { return code.intValue == 0 } return false } private func hmacSHA256Hex(message: String, secret: String) -> String { guard let keyData = secret.data(using: .utf8), let messageData = message.data(using: .utf8) else { return "" } var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) keyData.withUnsafeBytes { keyBytes in messageData.withUnsafeBytes { msgBytes in CCHmac( CCHmacAlgorithm(kCCHmacAlgSHA256), keyBytes.baseAddress, keyData.count, msgBytes.baseAddress, messageData.count, &digest ) } } return digest.map { String(format: "%02x", $0) }.joined() } }