Files

333 lines
14 KiB
Swift

import Foundation
import Network
// MARK: - 线路
/// 测速走哪条线路
public enum SpeedRoute: String, Sendable, Codable, CaseIterable {
/// 绑定物理网卡、忽略系统代理:绕开 VPN 与代理,测的是本机宽带
case direct
/// 按系统设置发出:开着 VPN 或代理就经过它,测的是代理线路
case proxy
}
/// 按当前网络环境定下来的一条测速线路。
///
/// 开着 Surge、Clash 这类代理时,连接先到本机的代理或隧道,TCP 握手由它当场应答,
/// 握手计时只剩零点几毫秒,与目标远近无关。所以要么绑定物理网卡绕开代理(直连),
/// 要么经代理时改用 HTTP 往返计时(请求必须真的到达目标、拿回响应)
public struct SpeedPath: @unchecked Sendable {
// NWInterface 没有标 Sendable,但它是不可变的值,跨任务传递是安全的
/// 绑定的物理网卡;nil 表示按系统路由与代理设置
let interface: NWInterface?
/// 物理网卡上的 DNS 也被代理改成了 fake-ip 或本机地址:直连前先经这块网卡用 DoH 自己解析
let resolvesItself: Bool
/// 这条线路经过代理或隧道,延迟只能用 HTTP 往返计时
public let isProxied: Bool
/// 开着代理时这次实际走的线路;没有代理时为 nil,两条线路没有区别
public let route: SpeedRoute?
/// 没有代理:按系统路由走,TCP 握手计时就是真实往返
public static let system = SpeedPath(interface: nil, resolvesItself: false, isProxied: false, route: nil)
/// 选直连但找不到物理网卡(例如 VPN 不允许绕开)时退回经代理,`route` 如实记为 `.proxy`
public static func make(route: SpeedRoute, details: NetworkDetails, environment: ProxyEnvironment) async -> SpeedPath {
guard environment.hasProxy else { return .system }
if route == .direct, let interface = await EgressProber.physicalInterface(named: environment.physicalInterface) {
let hijacked = (details.physical?.manualDNS ?? []).contains(where: Self.isHijackedDNS)
return SpeedPath(interface: interface, resolvesItself: hijacked, isProxied: false, route: .direct)
}
return SpeedPath(interface: nil, resolvesItself: false, isProxied: true, route: .proxy)
}
/// fake-ip 或本机回环:绑定物理网卡后这类 DNS 要么查不到、要么给出假地址
static func isHijackedDNS(_ server: String) -> Bool {
ProxyEnvironment.isFakeIP(server) || server.hasPrefix("127.") || server == "::1"
}
func apply(to parameters: NWParameters) {
guard let interface else { return }
parameters.requiredInterface = interface
parameters.preferNoProxies = true
}
/// 需要自己解析时返回目标 IP;不需要时返回主机名本身,交给系统(绑定网卡后按这块网卡的 DNS 解析)
func address(for host: String) async -> String? {
guard resolvesItself, let interface, !PublicAddressLookup.isAddress(host) else { return host }
return await EgressProber.resolve(host, ipv6: false, interface: interface)
}
}
// MARK: - HTTP 连接
/// 测速用的 HTTP/1.1 连接,建在 Network.framework 上:能绑定物理网卡、绕开系统代理(URLSession 做不到),
/// 一条连接上可以接连发多个请求。只实现测速用得到的部分:请求头、定长或读到关闭为止的响应体、上传定长数据。
///
/// 同一时间只由一个任务使用;取消靠 `cancel()`,挂着的收发会立刻以失败返回
final class SpeedConnection: @unchecked Sendable {
struct Response {
var status: Int
var headers: [String: String]
/// chunked 编码不解析,按读到关闭为止处理
var contentLength: Int? {
headers["transfer-encoding"]?.lowercased().contains("chunked") == true
? nil : headers["content-length"].flatMap { Int($0) }
}
/// 读完响应体后这条连接还能接着发下一个请求
var isReusable: Bool {
contentLength != nil && headers["connection"]?.lowercased() != "close"
}
}
let url: URL
private let connection: NWConnection
private let queue = DispatchQueue(label: "com.openstats.speedtest.http", qos: .utility)
/// 已收到、还没交给调用方的字节
private var buffer = Data()
private init(url: URL, connection: NWConnection) {
self.url = url
self.connection = connection
}
/// 建连(HTTPS 含 TLS 握手);连不上返回 nil
static func open(_ url: URL, path: SpeedPath, timeout: TimeInterval) async -> SpeedConnection? {
guard let host = url.host, let scheme = url.scheme, let address = await path.address(for: host) else { return nil }
let secure = scheme == "https"
let tcp = NWProtocolTCP.Options()
tcp.noDelay = true
tcp.connectionTimeout = Int(timeout.rounded(.up))
var tls: NWProtocolTLS.Options?
if secure {
let options = NWProtocolTLS.Options()
sec_protocol_options_add_tls_application_protocol(options.securityProtocolOptions, "http/1.1")
// 连的是自己解析出的 IP 时,证书校验与 SNI 仍按域名
if address != host { sec_protocol_options_set_tls_server_name(options.securityProtocolOptions, host) }
tls = options
}
let parameters = NWParameters(tls: tls, tcp: tcp)
path.apply(to: parameters)
guard let port = NWEndpoint.Port(rawValue: UInt16(url.port ?? (secure ? 443 : 80))) else { return nil }
let result = SpeedConnection(url: url, connection: NWConnection(host: NWEndpoint.Host(address), port: port,
using: parameters))
guard await result.start(timeout: timeout) else {
result.cancel()
return nil
}
return result
}
func cancel() {
connection.cancel()
}
private func start(timeout: TimeInterval) async -> Bool {
let gate = ContinuationGate<Bool>()
let ready = await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
gate.attach(continuation)
connection.stateUpdateHandler = { state in
switch state {
case .ready: gate.resume(true)
// 路由不可达、被拒、等网络:都算连不上,不干等超时
case .waiting, .failed, .cancelled: gate.resume(false)
default: break
}
}
connection.start(queue: queue)
queue.asyncAfter(deadline: .now() + timeout) { gate.resume(false) }
}
} onCancel: {
gate.resume(false)
}
connection.stateUpdateHandler = nil
return ready
}
// MARK: 收发
func send(_ data: Data) async -> Bool {
await withCheckedContinuation { continuation in
connection.send(content: data, completion: .contentProcessed { error in
continuation.resume(returning: error == nil)
})
}
}
/// 收下一段数据;对方关闭、出错或连接被取消时返回 nil
private func receive() async -> Data? {
await withCheckedContinuation { continuation in
connection.receive(minimumIncompleteLength: 1, maximumLength: 256 * 1024) { data, _, _, _ in
continuation.resume(returning: data.flatMap { $0.isEmpty ? nil : $0 })
}
}
}
// MARK: HTTP
/// 发请求头;上传的请求体由调用方随后用 `sendBody` 发
func sendRequest(_ method: String, headers: [(String, String)] = []) async -> Bool {
var target = url.path(percentEncoded: true)
if target.isEmpty { target = "/" }
if let query = url.query(percentEncoded: true) { target += "?" + query }
var host = url.host ?? ""
if host.contains(":") { host = "[\(host)]" }
if let port = url.port { host += ":\(port)" }
let lines = ["\(method) \(target) HTTP/1.1", "Host: \(host)", "User-Agent: OpenStats", "Accept: */*",
"Accept-Encoding: identity", "Cache-Control: no-cache"]
+ headers.map { "\($0.0): \($0.1)" }
return await send(Data((lines.joined(separator: "\r\n") + "\r\n\r\n").utf8))
}
/// 读到响应头结束;多收到的字节留在缓冲区,作为响应体的开头
func readResponse() async -> Response? {
let separator = Data("\r\n\r\n".utf8)
var end = buffer.range(of: separator)
while end == nil {
// 响应头不会这么大,多半不是 HTTP
guard buffer.count < 64 * 1024, let data = await receive() else { return nil }
buffer.append(data)
end = buffer.range(of: separator)
}
guard let end else { return nil }
let head = String(decoding: buffer[buffer.startIndex..<end.lowerBound], as: UTF8.self)
buffer = Data(buffer[end.upperBound...])
let lines = head.components(separatedBy: "\r\n")
let status = lines.first?.split(separator: " ") ?? []
guard status.count >= 2, status[0].hasPrefix("HTTP/"), let code = Int(status[1]) else { return nil }
var headers: [String: String] = [:]
for line in lines.dropFirst() {
guard let colon = line.firstIndex(of: ":") else { continue }
let name = line[..<colon].lowercased()
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
// 同名的头(例如两行 Server-Timing)按 HTTP 的规矩用逗号拼起来
headers[name] = headers[name].map { $0 + ", " + value } ?? value
}
return Response(status: code, headers: headers)
}
/// 读响应体:每收到一段就把字节数交给 `consume`,它返回 false 就不再往下读(这条连接也就不能再用)。
/// 返回是否读完了整个响应体
func readBody(_ response: Response, method: String = "GET", consume: (Int) -> Bool = { _ in true }) async -> Bool {
if method == "HEAD" || response.status == 204 || response.status == 304 { return true }
let length = response.contentLength
var remaining = length ?? .max
if !buffer.isEmpty {
let take = min(buffer.count, remaining)
buffer = Data(buffer.dropFirst(take))
remaining -= take
if !consume(take) { return remaining == 0 }
}
while remaining > 0 {
// 没有长度的响应读到对方关闭为止
guard let data = await receive() else { return length == nil }
let take = min(data.count, remaining)
if take < data.count { buffer.append(data.dropFirst(take)) }
remaining -= take
if !consume(take) { return remaining == 0 }
}
return true
}
/// 上传定长的空数据,分段发,每段交给协议栈后把字节数交给 `consume`;它返回 false 就停下。
/// 返回是否整段发完
func sendBody(bytes total: Int, consume: (Int) -> Bool) async -> Bool {
let chunk = Data(count: 64 * 1024)
var sent = 0
while sent < total {
let size = min(chunk.count, total - sent)
guard await send(size == chunk.count ? chunk : chunk.prefix(size)) else { return false }
sent += size
if !consume(size) { return sent == total }
}
return true
}
/// 在这条连接上接连发 `count` 个请求,返回每次的响应与从发出到读完响应的毫秒数;中途失败就停下
func roundTrips(_ count: Int, method: String) async -> [(response: Response, milliseconds: Double)] {
let clock = ContinuousClock()
var trips: [(response: Response, milliseconds: Double)] = []
for _ in 0..<count {
if Task.isCancelled { break }
let start = clock.now
guard await sendRequest(method), let response = await readResponse(),
await readBody(response, method: method) else { break }
trips.append((response, Self.milliseconds(clock.now - start)))
if method != "HEAD", !response.isReusable { break }
}
return trips
}
static func milliseconds(_ duration: Duration) -> Double {
Double(duration.components.seconds) * 1000 + Double(duration.components.attoseconds) / 1e15
}
}
// MARK: - 工具
/// 状态回调、超时与取消都可能同时到达,续体只能恢复一次;取消可能在续体挂上之前就到,先记下结果
final class ContinuationGate<Value: Sendable>: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<Value, Never>?
private var done = false
private var pending: Value?
func attach(_ continuation: CheckedContinuation<Value, Never>) {
lock.lock()
if let pending {
lock.unlock()
continuation.resume(returning: pending)
return
}
self.continuation = continuation
lock.unlock()
}
func resume(_ value: Value) {
lock.lock()
guard !done else { return lock.unlock() }
done = true
guard let continuation else {
pending = value
lock.unlock()
return
}
self.continuation = nil
lock.unlock()
continuation.resume(returning: value)
}
}
/// 当前在用的连接:任务取消或超时看门狗触发时从别的线程把它掐断
final class ConnectionSlot: @unchecked Sendable {
private let lock = NSLock()
private var connection: SpeedConnection?
private var cancelled = false
var isCancelled: Bool {
lock.lock()
defer { lock.unlock() }
return cancelled
}
/// 已经取消过就立刻掐断新连接
func hold(_ connection: SpeedConnection) {
lock.lock()
self.connection = connection
let cancelled = cancelled
lock.unlock()
if cancelled { connection.cancel() }
}
func cancel() {
lock.lock()
cancelled = true
let connection = connection
lock.unlock()
connection?.cancel()
}
}