359 lines
17 KiB
Swift
359 lines
17 KiB
Swift
import Foundation
|
||
import Localization
|
||
import Network
|
||
|
||
/// 出口检测的一个目标:CleanIP 的国内回显、Cloudflare,或一个常用网站。
|
||
/// 做法参照 cleanip.io/outbound:回显端点直接返回它看到的来源 IP;接入 Cloudflare 的网站读 /cdn-cgi/trace,
|
||
/// 能实测访问它时的出口、节点与国家;其余网站只能测连通与延迟,出口按规则推断
|
||
public struct EgressTarget: Sendable, Hashable, Identifiable {
|
||
public enum Region: String, Sendable, Codable { case domestic, international }
|
||
|
||
public enum Category: String, Sendable, CaseIterable {
|
||
case ai, media, social, developer, common, domestic
|
||
}
|
||
|
||
enum Probe: Sendable, Hashable {
|
||
/// Cloudflare 的 /cdn-cgi/trace
|
||
case trace
|
||
/// 回显 JSON:{"ip": "…"}
|
||
case echo(path: String)
|
||
/// 只测连通与延迟:请求一个很小的资源,收到任何 HTTP 响应都算通
|
||
case reach(path: String, head: Bool)
|
||
}
|
||
|
||
public let id: String
|
||
public let name: String
|
||
public let host: String
|
||
public let category: Category
|
||
/// Resources/Logos 里的图标名
|
||
public let logo: String?
|
||
let probe: Probe
|
||
|
||
public var region: Region { category == .domestic || id == Self.domestic.id ? .domestic : .international }
|
||
|
||
/// 能读出这次连接的出口;否则只知道通不通
|
||
public var measuresExit: Bool {
|
||
if case .reach = probe { return false }
|
||
return true
|
||
}
|
||
|
||
var isIPv6Literal: Bool { host.contains(":") }
|
||
|
||
var url: URL {
|
||
let path = switch probe {
|
||
case .trace: "/cdn-cgi/trace"
|
||
case .echo(let path), .reach(let path, _): path
|
||
}
|
||
return URL(string: "https://\(isIPv6Literal ? "[\(host)]" : host)\(path)")!
|
||
}
|
||
|
||
/// CleanIP 的国内回显机,用 IP 访问:代理规则里的 GEOIP,CN 能命中它,按域名分流的用户也会直连
|
||
public static let domestic = EgressTarget(id: "domestic", name: "CleanIP", host: "124.222.230.137",
|
||
category: .common, logo: nil, probe: .echo(path: "/"))
|
||
/// 用 IP 访问,不经过 DNS,也就不受 fake-ip 与域名规则影响
|
||
public static let cloudflare = EgressTarget(id: "cloudflare", name: "Cloudflare", host: "1.1.1.1",
|
||
category: .common, logo: nil, probe: .trace)
|
||
public static let cloudflareIPv6 = EgressTarget(id: "cloudflare-ipv6", name: "Cloudflare", host: "2606:4700:4700::1111",
|
||
category: .common, logo: nil, probe: .trace)
|
||
|
||
/// 三条路线都要测的基准目标
|
||
public static let baseline = [domestic, cloudflare, cloudflareIPv6]
|
||
|
||
/// 精选网站,只按系统代理测。能读 trace 的优先用 trace(实测出口)
|
||
public static let sites: [EgressTarget] = [
|
||
trace("chatgpt.com", "ChatGPT", .ai, "openai"),
|
||
trace("claude.ai", "Claude", .ai, "claude"),
|
||
reach("gemini.google.com", "Gemini", .ai, "gemini"),
|
||
trace("grok.com", "Grok", .ai, "grok"),
|
||
trace("www.perplexity.ai", "Perplexity", .ai, "perplexity"),
|
||
trace("www.midjourney.com", "Midjourney", .ai, "midjourney"),
|
||
reach("www.youtube.com", "YouTube", .media, "youtube", path: "/generate_204", head: false),
|
||
reach("www.netflix.com", "Netflix", .media, "netflix"),
|
||
reach("www.tiktok.com", "TikTok", .media, "tiktok"),
|
||
reach("www.twitch.tv", "Twitch", .media, "twitch"),
|
||
trace("x.com", "X", .social, "x"),
|
||
reach("telegram.org", "Telegram", .social, "telegram"),
|
||
reach("www.instagram.com", "Instagram", .social, "instagram"),
|
||
reach("discord.com", "Discord", .social, "discord"),
|
||
reach("www.reddit.com", "Reddit", .social, "reddit"),
|
||
reach("github.com", "GitHub", .developer, "github"),
|
||
trace("www.npmjs.com", "npm", .developer, "npm"),
|
||
trace("hub.docker.com", "Docker Hub", .developer, "docker"),
|
||
reach("www.google.com", "Google", .common, "google", path: "/generate_204", head: false),
|
||
trace("www.cloudflare.com", "Cloudflare", .common, "cloudflare"),
|
||
reach("www.baidu.com", tr("百度"), .domestic, "baidu"),
|
||
reach("www.taobao.com", tr("淘宝"), .domestic, "taobao"),
|
||
reach("www.bilibili.com", tr("哔哩哔哩"), .domestic, "bilibili"),
|
||
reach("www.douyin.com", tr("抖音"), .domestic, "douyin"),
|
||
reach("weixin.qq.com", tr("微信"), .domestic, "wechat"),
|
||
]
|
||
|
||
public static let all = baseline + sites
|
||
|
||
private static func trace(_ host: String, _ name: String, _ category: Category, _ logo: String) -> EgressTarget {
|
||
EgressTarget(id: host, name: name, host: host, category: category, logo: "site-" + logo, probe: .trace)
|
||
}
|
||
|
||
/// 默认用 HEAD 请求 favicon:响应小、不跳转到大页面
|
||
private static func reach(_ host: String, _ name: String, _ category: Category, _ logo: String,
|
||
path: String = "/favicon.ico", head: Bool = true) -> EgressTarget {
|
||
EgressTarget(id: host, name: name, host: host, category: category, logo: "site-" + logo, probe: .reach(path: path, head: head))
|
||
}
|
||
}
|
||
|
||
/// 请求怎么发出去
|
||
public enum EgressRoute: String, Sendable, Codable, CaseIterable {
|
||
/// 按系统代理设置发出(URLSession),浏览器等大多数应用都是这样
|
||
case system
|
||
/// 不读代理设置,直接建连接,走系统路由表;VPN 或增强模式会接管它
|
||
case socket
|
||
/// 绑定物理网卡并忽略代理,绕开 VPN 与代理,得到本机网络原本的出口
|
||
case interface
|
||
}
|
||
|
||
/// 一次测量:某个目标经某条路线看到的出口
|
||
public struct EgressHit: Sendable, Codable, Equatable {
|
||
public var targetID: String
|
||
public var route: EgressRoute
|
||
public var ip: String?
|
||
/// Cloudflare 给出的出口国家
|
||
public var country: String?
|
||
/// Cloudflare 接入节点(机场代码)
|
||
public var colo: String?
|
||
public var milliseconds: Int?
|
||
|
||
public init(targetID: String, route: EgressRoute, ip: String? = nil, country: String? = nil, colo: String? = nil,
|
||
milliseconds: Int? = nil) {
|
||
self.targetID = targetID
|
||
self.route = route
|
||
self.ip = ip
|
||
self.country = country
|
||
self.colo = colo
|
||
self.milliseconds = milliseconds
|
||
}
|
||
}
|
||
|
||
/// 出口 IP 的归属地与类型,来自 cleanip.io
|
||
public struct EgressGeo: Sendable, Codable, Equatable {
|
||
public var countryCode: String?
|
||
public var region: String?
|
||
public var city: String?
|
||
public var regionEnglish: String?
|
||
public var cityEnglish: String?
|
||
public var asn: String?
|
||
public var organization: String?
|
||
public var ipType: String?
|
||
public var isNative: Bool?
|
||
|
||
init(_ info: PublicAddressLookup.GeoInfo) {
|
||
countryCode = info.countryCode
|
||
region = info.region
|
||
city = info.city
|
||
regionEnglish = info.regionEnglish
|
||
cityEnglish = info.cityEnglish
|
||
asn = info.asn
|
||
organization = info.organization
|
||
ipType = info.ipType
|
||
isNative = info.isNative
|
||
}
|
||
}
|
||
|
||
public enum EgressProber {
|
||
static let timeout: TimeInterval = 5
|
||
/// 同时进行的请求数:太多会把慢线路上的正常站点挤成超时
|
||
static let concurrency = 10
|
||
|
||
private static let session: URLSession = {
|
||
let configuration = URLSessionConfiguration.ephemeral
|
||
configuration.timeoutIntervalForRequest = timeout
|
||
configuration.timeoutIntervalForResource = timeout + 2
|
||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||
configuration.httpShouldSetCookies = false
|
||
configuration.waitsForConnectivity = false
|
||
configuration.httpAdditionalHeaders = ["User-Agent": "OpenStats"]
|
||
return URLSession(configuration: configuration)
|
||
}()
|
||
|
||
/// 所有要做的测量:基准目标三条路线各一次在前(结论靠它们),精选网站只按系统代理测
|
||
public static var jobCount: Int { EgressTarget.baseline.count * 3 + EgressTarget.sites.count }
|
||
|
||
/// `progress` 每完成一项回调一次(已完成数)
|
||
public static func measure(interfaceName: String?, progress: @escaping @Sendable (Int) -> Void = { _ in }) async -> [EgressHit] {
|
||
let interface = await physicalInterface(named: interfaceName)
|
||
var jobs: [(EgressTarget, EgressRoute)] = []
|
||
for target in EgressTarget.baseline {
|
||
jobs.append((target, .system))
|
||
jobs.append((target, .socket))
|
||
jobs.append((target, .interface))
|
||
}
|
||
jobs += EgressTarget.sites.map { ($0, .system) }
|
||
|
||
return await withTaskGroup(of: (Int, EgressHit).self) { group in
|
||
var results = [EgressHit?](repeating: nil, count: jobs.count)
|
||
var next = 0
|
||
var done = 0
|
||
while next < min(concurrency, jobs.count) {
|
||
let (index, job) = (next, jobs[next])
|
||
group.addTask { (index, await measure(job.0, route: job.1, interface: interface)) }
|
||
next += 1
|
||
}
|
||
for await (index, hit) in group {
|
||
results[index] = hit
|
||
done += 1
|
||
progress(done)
|
||
if next < jobs.count {
|
||
let (index, job) = (next, jobs[next])
|
||
group.addTask { (index, await measure(job.0, route: job.1, interface: interface)) }
|
||
next += 1
|
||
}
|
||
}
|
||
return results.compactMap { $0 }
|
||
}
|
||
}
|
||
|
||
/// 系统代理路线先预热一次(建连、握手、代理选路),再计时请求一次,延迟更接近真实往返;
|
||
/// 另外两条路线只用于读出口,请求一次即可
|
||
static func measure(_ target: EgressTarget, route: EgressRoute, interface: NWInterface?) async -> EgressHit {
|
||
var hit = EgressHit(targetID: target.id, route: route)
|
||
if route == .interface && interface == nil { return hit }
|
||
let clock = ContinuousClock()
|
||
var start = clock.now
|
||
guard var response = await fetch(target, route: route, interface: interface) else { return hit }
|
||
if route == .system {
|
||
start = clock.now
|
||
if let timed = await fetch(target, route: route, interface: interface) { response = timed }
|
||
}
|
||
let elapsed = clock.now - start
|
||
hit.milliseconds = Int((Double(elapsed.components.seconds) * 1000 + Double(elapsed.components.attoseconds) / 1e15).rounded())
|
||
|
||
switch target.probe {
|
||
case .trace:
|
||
let fields = PublicAddressLookup.parseTrace(String(decoding: response, as: UTF8.self))
|
||
hit.ip = fields["ip"]
|
||
hit.country = fields["loc"].flatMap(PublicAddressLookup.validCountryCode)
|
||
hit.colo = fields["colo"].flatMap { $0.isEmpty ? nil : $0 }
|
||
case .echo:
|
||
let ip = (try? JSONDecoder().decode([String: String].self, from: response))?["ip"]?
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
hit.ip = ip.flatMap { PublicAddressLookup.isAddress($0) ? $0 : nil }
|
||
case .reach:
|
||
break
|
||
}
|
||
// 基准目标就是用来读出口的,没读到地址(比如被拦截页顶替)就算没连上;网站只要有响应就算通
|
||
if EgressTarget.baseline.contains(target) && hit.ip == nil { hit.milliseconds = nil }
|
||
return hit
|
||
}
|
||
|
||
/// 连上了返回响应体(HEAD 为空数据);读出口的目标要求 200,只测连通的收到任何 HTTP 响应都算
|
||
private static func fetch(_ target: EgressTarget, route: EgressRoute, interface: NWInterface?) async -> Data? {
|
||
switch route {
|
||
case .system:
|
||
var request = URLRequest(url: target.url)
|
||
if case .reach(_, let head) = target.probe, head { request.httpMethod = "HEAD" }
|
||
guard let (data, response) = try? await session.data(for: request),
|
||
let status = (response as? HTTPURLResponse)?.statusCode else { return nil }
|
||
return target.measuresExit ? (status == 200 ? data : nil) : data
|
||
case .socket:
|
||
return ok(await AddressFamilyRequest.get(target.url, ipv6: target.isIPv6Literal, timeout: timeout,
|
||
options: .init(bypassProxies: true)))
|
||
case .interface:
|
||
guard let interface else { return nil }
|
||
return ok(await AddressFamilyRequest.get(target.url, ipv6: target.isIPv6Literal, timeout: timeout,
|
||
options: .init(interface: interface, bypassProxies: true)))
|
||
}
|
||
}
|
||
|
||
// MARK: 归属地
|
||
|
||
/// 查某条路线出口的归属地。cleanip.io 只查请求方自己,所以请求必须沿这条路线发出:
|
||
/// 系统代理路线直接请求;网卡路线先经同一块网卡向 Cloudflare DoH 解析 cleanip.io,再连解析到的 IP,
|
||
/// 避免拿到代理下发的 fake-ip。返回 cleanip.io 看到的地址与结果
|
||
public static func geo(route: EgressRoute, ipv6: Bool, interfaceName: String?) async -> (ip: String, geo: EgressGeo)? {
|
||
let url = PublicAddressLookup.cleanIPURL
|
||
let body: Data?
|
||
switch route {
|
||
case .system:
|
||
body = await get(url)
|
||
case .socket:
|
||
body = ok(await AddressFamilyRequest.get(url, ipv6: ipv6, timeout: timeout, options: .init(bypassProxies: true)))
|
||
case .interface:
|
||
guard let interface = await physicalInterface(named: interfaceName),
|
||
let address = await resolve("cleanip.io", ipv6: ipv6, interface: interface) else { return nil }
|
||
body = ok(await AddressFamilyRequest.get(url, ipv6: ipv6, timeout: timeout,
|
||
options: .init(interface: interface, address: address, bypassProxies: true)))
|
||
}
|
||
guard let body, let info = PublicAddressLookup.parseCleanIP(body), let ip = info.ip else { return nil }
|
||
return (ip, EgressGeo(info))
|
||
}
|
||
|
||
/// 经指定网卡用 DoH(JSON 格式)解析域名。某些线路上连不上 Cloudflare,依次换 Google、阿里
|
||
static func resolve(_ name: String, ipv6: Bool, interface: NWInterface) async -> String? {
|
||
let servers = ipv6
|
||
? ["[2606:4700:4700::1111]/dns-query", "[2001:4860:4860::8888]/resolve", "[2400:3200::1]/resolve"]
|
||
: ["1.1.1.1/dns-query", "8.8.8.8/resolve", "223.5.5.5/resolve"]
|
||
for server in servers {
|
||
guard let url = URL(string: "https://\(server)?name=\(name)&type=\(ipv6 ? 28 : 1)") else { continue }
|
||
if let body = ok(await AddressFamilyRequest.get(url, ipv6: ipv6, timeout: timeout,
|
||
options: .init(interface: interface, bypassProxies: true,
|
||
accept: "application/dns-json"))),
|
||
let address = parseDoH(body, ipv6: ipv6) {
|
||
return address
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
static func parseDoH(_ body: Data, ipv6: Bool) -> String? {
|
||
struct Response: Decodable {
|
||
struct Answer: Decodable {
|
||
let type: Int
|
||
let data: String
|
||
}
|
||
let Answer: [Answer]?
|
||
}
|
||
let type = ipv6 ? 28 : 1
|
||
return (try? JSONDecoder().decode(Response.self, from: body))?.Answer?
|
||
.first { $0.type == type && PublicAddressLookup.isAddress($0.data) }?.data
|
||
}
|
||
|
||
// MARK: 工具
|
||
|
||
private static func get(_ url: URL) async -> Data? {
|
||
ok(await PublicAddressLookup.getWithStatus(url, session: session))
|
||
}
|
||
|
||
/// 按接口名找到 Network.framework 的网卡对象;VPN 开着时它仍在可用接口里
|
||
static func physicalInterface(named name: String?) async -> NWInterface? {
|
||
let interfaces = await withCheckedContinuation { (continuation: CheckedContinuation<[NWInterface], Never>) in
|
||
let monitor = NWPathMonitor()
|
||
let resumed = ResumeOnce()
|
||
monitor.pathUpdateHandler = { path in
|
||
guard resumed.claim() else { return }
|
||
monitor.cancel()
|
||
continuation.resume(returning: path.availableInterfaces)
|
||
}
|
||
monitor.start(queue: DispatchQueue(label: "OpenStats.EgressProber.interfaces"))
|
||
}
|
||
if let name, let match = interfaces.first(where: { $0.name == name }) { return match }
|
||
return interfaces.first { $0.type == .wifi || $0.type == .wiredEthernet }
|
||
}
|
||
}
|
||
|
||
private final class ResumeOnce: @unchecked Sendable {
|
||
private let lock = NSLock()
|
||
private var done = false
|
||
|
||
func claim() -> Bool {
|
||
lock.lock()
|
||
defer { lock.unlock() }
|
||
guard !done else { return false }
|
||
done = true
|
||
return true
|
||
}
|
||
}
|
||
|
||
/// 只要 200 的响应体
|
||
private func ok(_ response: (Data?, Int)) -> Data? {
|
||
response.1 == 200 ? response.0 : nil
|
||
}
|