325 lines
10 KiB
Swift
325 lines
10 KiB
Swift
import Flutter
|
|
import Foundation
|
|
import UIKit
|
|
import CommonCrypto
|
|
|
|
public class AliyunHttpDnsPlugin: NSObject, FlutterPlugin {
|
|
private var appId: String?
|
|
private var secretKey: String?
|
|
private var primaryServiceHost: String?
|
|
private var backupServiceHost: String?
|
|
private var servicePort: Int = 443
|
|
|
|
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: "aliyun_httpdns", binaryMessenger: registrar.messenger())
|
|
let instance = AliyunHttpDnsPlugin()
|
|
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,
|
|
let rawPrimaryHost = options["primaryServiceHost"] as? String else {
|
|
result(false)
|
|
return
|
|
}
|
|
|
|
let normalizedAppId = rawAppId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let normalizedPrimaryHost = rawPrimaryHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if normalizedAppId.isEmpty || normalizedPrimaryHost.isEmpty {
|
|
result(false)
|
|
return
|
|
}
|
|
|
|
appId = normalizedAppId
|
|
primaryServiceHost = normalizedPrimaryHost
|
|
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
|
|
}
|
|
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("AliyunHttpDns(iOS): HTTPS is required by current protocol, force enabled")
|
|
}
|
|
if desiredLogEnabled == true {
|
|
NSLog("AliyunHttpDns(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 = "https"
|
|
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("AliyunHttpDns(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()
|
|
}
|
|
}
|