Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

367 lines
13 KiB
Swift

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<String, Swift.Error>) {
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..<valueEnd])
if let address = mappedAddress(value, isXOR: type == 0x0020) {
return address
}
}
index = valueEnd + ((4 - (length % 4)) % 4)
}
return nil
}
private static func mappedAddress(_ value: [UInt8], isXOR: Bool) -> 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<String, Swift.Error>
init(
connection: NWConnection,
continuation: CheckedContinuation<String, Swift.Error>
) {
self.connection = connection
self.continuation = continuation
}
func finish(_ result: Result<String, Swift.Error>) {
lock.lock()
defer { lock.unlock() }
guard !didResume else { return }
didResume = true
connection.cancel()
continuation.resume(with: result)
}
}