Files
OpenStats/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift
T
gentpan 645d161cbd 清理死代码、死译文与重复代码
- 删掉没人调用的函数、属性与解码字段:网络弹窗的两个标签函数、supportsStyleOverride、probedAddress、countryEn、辅助工具客户端的单风扇恢复自动封装(协议与辅助工具端保留,不动 XPC 接口)
- 译文表删掉 19 条不再使用的条目,补上测速窗口“开始”按钮漏掉的英文
- 网络弹窗与出口窗口各自一份的归属地格式化和 IP 类型徽章合并为 GeoText;两个折线图里重复的参考线绘制提成 ChartGrid
- 官网样式删掉没人用的 .mb-drop__ext
- server/sync/sync 是误提交的 Go 编译产物,从仓库移除并忽略
2026-09-21 03:38:08 +05:00

349 lines
16 KiB
Swift
Raw 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
/// 公网地址与 cleanip.io 给出的归属地、网络类型与纯净度
public struct PublicAddresses: Sendable, Equatable, Codable {
public var ipv4: String?
public var ipv6: String?
/// ISO 3166 两位国家 / 地区代码(大写),例如 “CN”
public var countryCode: String?
public var city: String?
/// 省 / 州,例如 “New York”
public var region: String?
/// cleanip.io 给的是中文地名,这里是英文,供英文界面使用
public var cityEnglish: String?
public var regionEnglish: String?
/// 自治系统号,例如 “AS4134”
public var asn: String?
/// 网络所属组织(英文),例如 “CHINANET-BACKBONE”
public var organization: String?
public var hostname: String?
/// 网络类型:Residential / Business / Hosting / Mobile …
public var networkType: String?
/// 自治系统的类型:isp / hosting / business / education / government / mobile
public var asnType: String?
/// 接入方式:Cable/DSL / Cellular / Corporate …
public var connectionType: String?
/// IP 类型:Residential IP / Datacenter IP …
public var ipType: String?
/// 原生 IP(登记国、宣告 AS 注册国与定位国一致)还是广播 IP
public var isNative: Bool?
public var residentialProbability: Int?
public var purity: Purity?
public var risk: Risk?
public var reportURL: URL?
/// 查纯净度时 Cloudflare 给出的这一族地址。分流代理下 cleanip.io 看到的出口可能不同,
/// 界面显示 cleanip.io 看到的那个,缓存按这个比对“地址变没变”
public var queriedAddress: String?
public struct Purity: Sendable, Equatable, Codable {
public var score: Int
public var grade: String
public var confidence: Int?
public var recommendation: String?
public init(score: Int, grade: String, confidence: Int? = nil, recommendation: String? = nil) {
self.score = score
self.grade = grade
self.confidence = confidence
self.recommendation = recommendation
}
}
public struct Risk: Sendable, Equatable, Codable {
public var score: Int
public var label: String?
/// 命中的标记:vpn、proxy、tor、datacenter、hosting、relay、abuser、residentialProxy
public var flags: [String]
public init(score: Int, label: String? = nil, flags: [String] = []) {
self.score = score
self.label = label
self.flags = flags
}
}
public init(ipv4: String? = nil, ipv6: String? = nil, countryCode: String? = nil,
city: String? = nil, region: String? = nil, asn: String? = nil, organization: String? = nil) {
self.ipv4 = ipv4
self.ipv6 = ipv6
self.countryCode = countryCode
self.city = city
self.region = region
self.asn = asn
self.organization = organization
}
/// 把 cleanip.io 查到的信息并进来;国家代码以 Cloudflare 给的为准,没有时才用 cleanip.io 的
public mutating func apply(_ geo: PublicAddressLookup.GeoInfo) {
city = geo.city
region = geo.region
cityEnglish = geo.cityEnglish
regionEnglish = geo.regionEnglish
asn = geo.asn
organization = geo.organization
hostname = geo.hostname
networkType = geo.networkType
asnType = geo.asnType
connectionType = geo.connectionType
ipType = geo.ipType
isNative = geo.isNative
residentialProbability = geo.residentialProbability
purity = geo.purity
risk = geo.risk
reportURL = geo.reportURL
if countryCode == nil { countryCode = geo.countryCode }
}
}
/// 查询公网 IP 与归属地。只在用户打开网络详情时请求,不携带任何本机信息。
/// 公网地址来自 Cloudflare trace(直连 IP,不依赖 DNS,同时给出国家代码),回退 ipify;
/// 归属地、ASN、网络类型与纯净度由每台 Mac 自己直接向 cleanip.io 查,不经过我们的服务器。
/// 接口按客户端 IP 限额,所以同一个公网 IP 的结果会记住一小时,收到 429 后到当天(UTC)结束都不再请求
public enum PublicAddressLookup {
private static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 8
configuration.timeoutIntervalForResource = 12
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.httpShouldSetCookies = false
configuration.waitsForConnectivity = false
configuration.httpAdditionalHeaders = ["User-Agent": "OpenStats"]
return URLSession(configuration: configuration)
}()
private static let geoCache = GeoCache()
/// `includeGeo` 为假时只查询公网 IP,不请求 cleanip.io
public static func fetch(includeGeo: Bool = true) async -> PublicAddresses {
async let v4 = lookup(trace: "https://1.1.1.1/cdn-cgi/trace", fallback: "https://api.ipify.org")
async let v6 = lookup(trace: "https://[2606:4700:4700::1111]/cdn-cgi/trace", fallback: "https://api6.ipify.org")
let (ipv4, ipv6) = await (v4, v6)
var result = PublicAddresses(ipv4: ipv4.ip, ipv6: ipv6.ip, countryCode: ipv4.country ?? ipv6.country)
guard includeGeo, let ip = result.ipv4 ?? result.ipv6, let geo = await geoCache.lookup(ip) else { return result }
result.apply(geo)
return result
}
/// 只查一个已知地址的归属地,公网 IP 没变时用它补全,不必重新取地址
public static func geo(for ip: String) async -> GeoInfo? {
await geoCache.lookup(ip)
}
// MARK: cleanip.io
public struct GeoInfo: Sendable, Equatable {
public var countryCode: String?
public var city: String?
public var region: String?
public var cityEnglish: String?
public var regionEnglish: String?
public var asn: String?
public var organization: String?
public var hostname: String?
public var networkType: String?
public var asnType: String?
public var connectionType: String?
public var ipType: String?
public var isNative: Bool?
public var residentialProbability: Int?
public var purity: PublicAddresses.Purity?
public var risk: PublicAddresses.Risk?
public var reportURL: URL?
/// cleanip.io 看到的出口地址,也就是这份结果对应的地址
public var ip: String?
}
/// 只查请求方自己的出口 IP:cleanip.io 的 /cli 自 2026-09-15 起不再接受 `ip` 参数(带了一律 400)。
/// cleanip.io 在 Cloudflare 后面,A 与 AAAA 都有,所以锁定地址族各连一次,就能分别查到本机的公网 IPv4 与 IPv6
static let cleanIPURL = URL(string: "https://cleanip.io/cli?json=1")!
struct CleanIPResponse: Decodable {
struct Geo: Decodable {
let countryCode: String?
let country: String?
let region: String?
let city: String?
}
struct GeoSource: Decodable {
let source: String?
let region: String?
let city: String?
}
struct Network: Decodable {
let asn: Int?
let asnName: String?
let asnType: String?
let isp: String?
let networkType: String?
let connectionType: String?
let nativeOrBroadcast: String?
}
struct Purity: Decodable {
let score: Int?
let grade: String?
let confidence: Int?
let ipType: String?
let isNative: Bool?
let residentialProbability: Int?
let recommendation: String?
}
struct Risk: Decodable {
let riskScore: Int?
let riskLabel: String?
let isVpn: Bool?
let isProxy: Bool?
let isTor: Bool?
let isDatacenter: Bool?
let isHosting: Bool?
let isRelay: Bool?
let isAbuser: Bool?
let isResidentialProxy: Bool?
}
let ok: Bool?
let ip: String?
let hostname: String?
let geo: Geo?
let geoSources: [GeoSource]?
let network: Network?
let purity: Purity?
let risk: Risk?
}
static func parseCleanIP(_ data: Data) -> GeoInfo? {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
guard let response = try? decoder.decode(CleanIPResponse.self, from: data), response.ok != false else { return nil }
var info = GeoInfo()
info.countryCode = response.geo?.countryCode.flatMap(validCountryCode)
info.city = response.geo?.city.flatMap(nonEmpty)
info.region = response.geo?.region.flatMap(nonEmpty)
// 英文地名取 ipinfo 那一路的
if let english = response.geoSources?.first(where: { $0.source == "ipinfo" }) {
info.cityEnglish = english.city.flatMap(nonEmpty)
info.regionEnglish = english.region.flatMap(nonEmpty)
}
if let asn = response.network?.asn, asn > 0 { info.asn = "AS\(asn)" }
info.organization = response.network?.isp.flatMap(nonEmpty) ?? response.network?.asnName.flatMap(nonEmpty)
if let hostname = response.hostname.flatMap(nonEmpty), hostname != response.ip { info.hostname = hostname }
info.networkType = response.network?.networkType.flatMap(nonEmpty)
info.asnType = response.network?.asnType.flatMap(nonEmpty)?.lowercased()
info.connectionType = response.network?.connectionType.flatMap(nonEmpty)
info.ipType = response.purity?.ipType.flatMap(nonEmpty)
info.isNative = response.purity?.isNative ?? response.network?.nativeOrBroadcast.map { $0.uppercased() == "NATIVE" }
info.residentialProbability = response.purity?.residentialProbability
if let score = response.purity?.score, let grade = response.purity?.grade.flatMap(nonEmpty) {
info.purity = PublicAddresses.Purity(score: min(100, max(0, score)), grade: grade,
confidence: response.purity?.confidence,
recommendation: response.purity?.recommendation.flatMap(nonEmpty))
}
if let risk = response.risk, let score = risk.riskScore {
var flags: [String] = []
if risk.isVpn == true { flags.append("vpn") }
if risk.isProxy == true { flags.append("proxy") }
if risk.isResidentialProxy == true { flags.append("residentialProxy") }
if risk.isTor == true { flags.append("tor") }
if risk.isRelay == true { flags.append("relay") }
if risk.isDatacenter == true { flags.append("datacenter") }
if risk.isHosting == true { flags.append("hosting") }
if risk.isAbuser == true { flags.append("abuser") }
info.risk = PublicAddresses.Risk(score: min(100, max(0, score)), label: risk.riskLabel.flatMap(nonEmpty), flags: flags)
}
if let ip = response.ip, isAddress(ip) {
info.ip = ip
info.reportURL = URL(string: "https://cleanip.io/\(ip)")
}
return info
}
/// 按 IP 缓存一小时;收到 429 后到当天(UTC)结束都不再请求
actor GeoCache {
private static let lifetime: TimeInterval = 60 * 60
private var entries: [String: (info: GeoInfo, date: Date)] = [:]
private var blockedUntil: Date?
/// `ip` 是 Cloudflare 给出的地址,决定锁定哪一族、按它缓存;查不到的不缓存,下次打开详情再试
func lookup(_ ip: String) async -> GeoInfo? {
if let entry = entries[ip], Date().timeIntervalSince(entry.date) < Self.lifetime { return entry.info }
if let blockedUntil, Date() < blockedUntil { return nil }
let ipv6 = ip.contains(":")
var (data, status) = await AddressFamilyRequest.get(PublicAddressLookup.cleanIPURL, ipv6: ipv6)
// 锁定地址族的连接建不起来(比如只认系统 HTTP 代理的网络)时,退回系统默认的请求方式
if status == 0 {
(data, status) = await PublicAddressLookup.getWithStatus(PublicAddressLookup.cleanIPURL)
}
if status == 429 {
blockedUntil = Self.nextUTCMidnight()
return nil
}
// 退回的请求走哪一族由系统决定,查到的不是要查的那一族就不能拿来用
guard status == 200, let data, let info = PublicAddressLookup.parseCleanIP(data),
let seen = info.ip, seen.contains(":") == ipv6 else { return nil }
entries[ip] = (info, Date())
return info
}
private static func nextUTCMidnight() -> Date {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!
let start = calendar.startOfDay(for: Date())
return calendar.date(byAdding: .day, value: 1, to: start) ?? Date().addingTimeInterval(12 * 3600)
}
}
/// 只接受两位字母,之后会用作国旗文件名
static func validCountryCode(_ code: String) -> String? {
let upper = code.uppercased()
guard upper.count == 2, upper.unicodeScalars.allSatisfy({ ("A"..."Z").contains($0) }) else { return nil }
return upper
}
private static func nonEmpty(_ text: String) -> String? {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
// MARK: 回退
private static func lookup(trace: String, fallback: String) async -> (ip: String?, country: String?) {
if let data = await get(trace), let body = String(data: data, encoding: .utf8) {
let fields = parseTrace(body)
if let ip = fields["ip"] { return (ip, fields["loc"].flatMap(validCountryCode)) }
}
if let data = await get(fallback), let body = String(data: data, encoding: .utf8) {
let ip = body.trimmingCharacters(in: .whitespacesAndNewlines)
if isAddress(ip) { return (ip, nil) }
}
return (nil, nil)
}
private static func get(_ string: String) async -> Data? {
guard let url = URL(string: string) else { return nil }
let (data, status) = await getWithStatus(url)
return status == 200 ? data : nil
}
static func getWithStatus(_ url: URL) async -> (Data?, Int) {
await getWithStatus(url, session: session)
}
static func getWithStatus(_ url: URL, session: URLSession) async -> (Data?, Int) {
guard let (data, response) = try? await session.data(from: url) else { return (nil, 0) }
return (data, (response as? HTTPURLResponse)?.statusCode ?? 0)
}
static func parseTrace(_ body: String) -> [String: String] {
var fields: [String: String] = [:]
for line in body.split(whereSeparator: \.isNewline) {
let parts = line.split(separator: "=", maxSplits: 1)
guard parts.count == 2 else { continue }
fields[String(parts[0])] = String(parts[1])
}
if let ip = fields["ip"], !isAddress(ip) { fields["ip"] = nil }
return fields
}
static func isAddress(_ text: String) -> Bool {
var v4 = in_addr()
var v6 = in6_addr()
return inet_pton(AF_INET, text, &v4) == 1 || inet_pton(AF_INET6, text, &v6) == 1
}
}