Files

359 lines
17 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}