Files
OpenStats/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift
T
gentpan 0342074489 IP 归属地改用本地 MaxMind GeoLite2:自研 .mmdb 读取器、服务器定时同步、应用校验下载与导入
- Metrics:MaxMindDatabase 按 MaxMind DB 2.0 规范解析搜索树与数据段(内存映射,无第三方依赖),GeoLookup 取国家、城市与 ASN
- 服务器:server/geoip 下的更新脚本与 systemd 定时器,License Key 只放在服务器 /etc/openstats/maxmind.env,生成 manifest.json
- 应用:GeoDatabaseController 每 3 天检查清单,下载后校验 sha256 与库类型再替换;支持从文件导入、可选城市库
- 有本地库时只向外查询公网 IP,归属地与 ASN 本机查询;网络详情标明数据来源;官网部署排除 /geoip/
- 新增读取器单元测试;更新隐私说明、第三方声明与架构文档
2026-09-13 06:40:05 +05:00

158 lines
6.7 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
public struct PublicAddresses: Sendable, Equatable {
public var ipv4: String?
public var ipv6: String?
/// ISO 3166 两位国家 / 地区代码(大写),例如 “CN”
public var countryCode: String?
public var city: String?
/// 自治系统号,例如 “AS4134”
public var asn: String?
/// 网络所属组织(英文),例如 “CHINANET-BACKBONE”
public var organization: String?
public var source: Source = .online
public enum Source: Sendable, Equatable {
/// 本机的 MaxMind GeoLite2 数据库
case localDatabase
/// ipinfo.io 在线查询
case online
}
public init(ipv4: String? = nil, ipv6: String? = nil, countryCode: String? = nil,
city: String? = nil, asn: String? = nil, organization: String? = nil) {
self.ipv4 = ipv4
self.ipv6 = ipv6
self.countryCode = countryCode
self.city = city
self.asn = asn
self.organization = organization
}
}
/// 查询公网 IP 与归属地。只在用户打开网络详情时请求,不携带任何本机信息。
/// 归属地与 ASN 来自 ipinfo.io;IPv6 地址与失败时的回退使用 Cloudflare trace(直连 IP,不依赖 DNS)与 ipify。
public enum PublicAddressLookup {
private static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 5
configuration.timeoutIntervalForResource = 8
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.httpShouldSetCookies = false
configuration.waitsForConnectivity = false
return URLSession(configuration: configuration)
}()
/// `includeGeo` 为假时只查询公网 IP(Cloudflare trace / ipify),不请求任何归属地服务
public static func fetch(includeGeo: Bool = true) async -> PublicAddresses {
guard includeGeo else {
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)
return PublicAddresses(ipv4: ipv4.ip, ipv6: ipv6.ip, countryCode: ipv4.country ?? ipv6.country)
}
async let geoResult = geo()
async let v6Result = lookup(trace: "https://[2606:4700:4700::1111]/cdn-cgi/trace", fallback: "https://api6.ipify.org")
var result = await geoResult ?? PublicAddresses()
let v6 = await v6Result
if let ip = result.ipv6, result.ipv4 == nil {
// 本机优先走 IPv6 时,归属地接口返回的是 IPv6 地址,IPv4 另外查询
result.ipv6 = ip
let v4 = await lookup(trace: "https://1.1.1.1/cdn-cgi/trace", fallback: "https://api.ipify.org")
result.ipv4 = v4.ip
} else if result.ipv4 == nil {
let v4 = await lookup(trace: "https://1.1.1.1/cdn-cgi/trace", fallback: "https://api.ipify.org")
result.ipv4 = v4.ip
result.countryCode = result.countryCode ?? v4.country
}
if result.ipv6 == nil { result.ipv6 = v6.ip }
if result.countryCode == nil { result.countryCode = v6.country }
return result
}
// MARK: 归属地
/// ipinfo.io 的响应:`country` 是两位代码,`org` 形如 “AS151338 POLONETWORK LIMITED”
struct GeoResponse: Decodable {
let ip: String?
let city: String?
let country: String?
let org: String?
}
private static func geo() async -> PublicAddresses? {
guard let data = await get("https://ipinfo.io/json") else { return nil }
return parseGeo(data)
}
static func parseGeo(_ data: Data) -> PublicAddresses? {
guard let response = try? JSONDecoder().decode(GeoResponse.self, from: data),
let ip = response.ip, isAddress(ip) else { return nil }
var result = PublicAddresses()
if ip.contains(":") { result.ipv6 = ip } else { result.ipv4 = ip }
result.countryCode = response.country.flatMap(validCountryCode)
result.city = response.city.flatMap(nonEmpty)
if let org = response.org.flatMap(nonEmpty) {
let parts = org.split(separator: " ", maxSplits: 1).map(String.init)
if let first = parts.first, first.hasPrefix("AS"), Int(first.dropFirst(2)) != nil {
result.asn = first
result.organization = parts.count > 1 ? parts[1] : nil
} else {
result.organization = org
}
}
return result
}
/// 只接受两位字母,之后会用作国旗文件名
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),
let (data, response) = try? await session.data(from: url),
(response as? HTTPURLResponse)?.statusCode == 200 else { return nil }
return data
}
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
}
}