Files

154 lines
7.3 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 Network
/// 强制走 IPv4 或 IPv6 发一个 HTTPS GET。
///
/// cleanip.io 只查请求方自己的出口,结果取决于这次连接用的是哪一族;URLSession 没法指定地址族,
/// 双栈网络下系统通常优先 IPv6,所以用 Network.framework 自己建连接、锁定 IP 版本,
/// 请求写成最简单的 HTTP/1.1(Connection: close,不要压缩),读到对方关闭为止。
/// 这一族没有路由时连接会进入 waiting,直接当失败处理,不干等超时。
enum AddressFamilyRequest {
/// 出口检测用的额外选项:默认与查纯净度时一样,走系统路由、只锁定地址族
struct Options: @unchecked Sendable {
/// 绑定到这块网卡发出,绕开 VPN 隧道
var interface: NWInterface?
/// 直接连这个 IP,TLS 与 Host 仍用 URL 里的域名。绑定物理网卡时系统 DNS 可能被代理改成 fake-ip,只能自己解析
var address: String?
/// 忽略系统里配置的代理
var bypassProxies = false
var accept = "application/json"
}
/// 返回响应体与状态码;连接失败时状态码为 0
static func get(_ url: URL, ipv6: Bool, timeout: TimeInterval = 10, options: Options = Options()) async -> (Data?, Int) {
// IPv6 字面量的 host 可能带着方括号
guard url.scheme == "https",
let host = url.host?.trimmingCharacters(in: CharacterSet(charactersIn: "[]")), !host.isEmpty else { return (nil, 0) }
var path = url.path.isEmpty ? "/" : url.path
if let query = url.query { path += "?" + query }
let tls = NWProtocolTLS.Options()
sec_protocol_options_add_tls_application_protocol(tls.securityProtocolOptions, "http/1.1")
if options.address != nil {
sec_protocol_options_set_tls_server_name(tls.securityProtocolOptions, host)
}
let tcp = NWProtocolTCP.Options()
tcp.connectionTimeout = Int(timeout)
let parameters = NWParameters(tls: tls, tcp: tcp)
if let ip = parameters.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options {
ip.version = ipv6 ? .v6 : .v4
}
if let interface = options.interface { parameters.requiredInterface = interface }
parameters.preferNoProxies = options.bypassProxies
let connection = NWConnection(host: NWEndpoint.Host(options.address ?? host), port: .https, using: parameters)
let hostHeader = host.contains(":") ? "[\(host)]" : host
let request = "GET \(path) HTTP/1.1\r\nHost: \(hostHeader)\r\nUser-Agent: OpenStats\r\nAccept: \(options.accept)\r\nConnection: close\r\n\r\n"
guard let raw = await Exchange(connection: connection, request: Data(request.utf8), timeout: timeout).run() else {
return (nil, 0)
}
return parse(raw)
}
/// 拆出状态码与响应体,处理 chunked 与 Content-Length
static func parse(_ raw: Data) -> (Data?, Int) {
guard let separator = raw.range(of: Data("\r\n\r\n".utf8)),
let head = String(data: raw[raw.startIndex..<separator.lowerBound], encoding: .utf8) else { return (nil, 0) }
let lines = head.components(separatedBy: "\r\n")
let statusParts = lines.first?.split(separator: " ") ?? []
guard statusParts.count >= 2, statusParts[0].hasPrefix("HTTP/"), let status = Int(statusParts[1]) else { return (nil, 0) }
var headers: [String: String] = [:]
for line in lines.dropFirst() {
guard let colon = line.firstIndex(of: ":") else { continue }
headers[line[..<colon].lowercased()] = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
}
var body = Data(raw[separator.upperBound...])
if headers["transfer-encoding"]?.lowercased().contains("chunked") == true {
guard let decoded = dechunk(body) else { return (nil, status) }
body = decoded
} else if let length = headers["content-length"].flatMap({ Int($0) }) {
guard body.count >= length else { return (nil, status) }
body = body.prefix(length)
}
return (body, status)
}
/// 解开 chunked 编码;数据不完整时返回 nil
static func dechunk(_ data: Data) -> Data? {
let bytes = [UInt8](data)
var output = Data()
var index = 0
while index < bytes.count {
guard let lineEnd = (index..<max(index, bytes.count - 1)).first(where: { bytes[$0] == 13 && bytes[$0 + 1] == 10 }),
let sizeText = String(bytes: bytes[index..<lineEnd], encoding: .ascii)?
.split(separator: ";").first?.trimmingCharacters(in: .whitespaces),
let size = Int(sizeText, radix: 16) else { return nil }
index = lineEnd + 2
if size == 0 { return output }
guard bytes.count - index >= size else { return nil }
output.append(contentsOf: bytes[index..<index + size])
index += size + 2
}
return nil
}
}
/// 一次连接的收发。所有状态只在自己的串行队列上改,结果只回调一次
private final class Exchange: @unchecked Sendable {
private let connection: NWConnection
private let request: Data
private let timeout: TimeInterval
private let queue = DispatchQueue(label: "OpenStats.AddressFamilyRequest")
private var buffer = Data()
private var continuation: CheckedContinuation<Data?, Never>?
/// 响应超过 2 MB 就不要了,cleanip.io 的 JSON 通常几 KB
private static let limit = 2 << 20
init(connection: NWConnection, request: Data, timeout: TimeInterval) {
self.connection = connection
self.request = request
self.timeout = timeout
}
func run() async -> Data? {
await withCheckedContinuation { continuation in
queue.async {
self.continuation = continuation
self.connection.stateUpdateHandler = { [self] state in
switch state {
case .ready: send()
case .waiting, .failed, .cancelled: finish(nil)
default: break
}
}
self.connection.start(queue: self.queue)
self.queue.asyncAfter(deadline: .now() + self.timeout) { [self] in finish(nil) }
}
}
}
private func send() {
connection.send(content: request, completion: .contentProcessed { [self] error in
if error != nil { finish(nil) } else { receive() }
})
}
private func receive() {
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [self] data, _, isComplete, error in
if let data { buffer.append(data) }
if buffer.count > Self.limit { finish(nil); return }
// 对方关闭连接(或关闭时带出 TLS 错误)就是读完了,完整性交给 parse 判断
if isComplete || error != nil { finish(buffer.isEmpty ? nil : buffer); return }
receive()
}
}
private func finish(_ result: Data?) {
guard let continuation else { return }
self.continuation = nil
connection.stateUpdateHandler = nil
connection.cancel()
continuation.resume(returning: result)
}
}