753 lines
26 KiB
Swift
753 lines
26 KiB
Swift
import Darwin
|
||
import Foundation
|
||
import MacToolsPluginKit
|
||
|
||
protocol IPOverviewProviding: Sendable {
|
||
func collectSnapshot() async -> IPOverviewSnapshot
|
||
func collectPublicIPSnapshot(preserving snapshot: IPOverviewSnapshot) async -> IPOverviewSnapshot
|
||
}
|
||
|
||
protocol IPOverviewHTTPClient: Sendable {
|
||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||
}
|
||
|
||
extension URLSession: IPOverviewHTTPClient {
|
||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||
let (data, response) = try await data(for: request, delegate: nil)
|
||
guard let httpResponse = response as? HTTPURLResponse else {
|
||
throw IPOverviewServiceError.invalidResponse
|
||
}
|
||
|
||
return (data, httpResponse)
|
||
}
|
||
}
|
||
|
||
enum IPOverviewServiceError: LocalizedError {
|
||
case invalidResponse
|
||
case badStatus(Int)
|
||
case invalidPayload
|
||
case invalidIP(String)
|
||
|
||
var errorDescription: String? {
|
||
localizedDescription()
|
||
}
|
||
|
||
func localizedDescription(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
|
||
switch self {
|
||
case .invalidResponse:
|
||
return localization.string("service.error.invalidResponse", defaultValue: "响应无效")
|
||
case .badStatus(let statusCode):
|
||
return "HTTP \(statusCode)"
|
||
case .invalidPayload:
|
||
return localization.string("service.error.invalidPayload", defaultValue: "数据格式不正确")
|
||
case .invalidIP(let value):
|
||
return localization.format("service.error.invalidIP", defaultValue: "IP 无效:%@", value)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct IPOverviewService: IPOverviewProviding {
|
||
private let httpClient: any IPOverviewHTTPClient
|
||
private let timeout: TimeInterval
|
||
private let localization: PluginLocalization
|
||
|
||
init(
|
||
httpClient: any IPOverviewHTTPClient = URLSession.shared,
|
||
timeout: TimeInterval = 4,
|
||
localization: PluginLocalization = PluginLocalization(bundle: .main)
|
||
) {
|
||
self.httpClient = httpClient
|
||
self.timeout = timeout
|
||
self.localization = localization
|
||
}
|
||
|
||
func collectSnapshot() async -> IPOverviewSnapshot {
|
||
async let localAddresses = Self.localAddresses()
|
||
async let domesticIPv4Results = fetchPublicIP(route: .domestic, family: .ipv4)
|
||
async let domesticIPv6Results = fetchPublicIP(route: .domestic, family: .ipv6)
|
||
async let internationalIPv4Results = fetchPublicIP(route: .international, family: .ipv4)
|
||
async let internationalIPv6Results = fetchPublicIP(route: .international, family: .ipv6)
|
||
|
||
let local = await localAddresses
|
||
let domesticIPv4 = await domesticIPv4Results
|
||
let domesticIPv6 = await domesticIPv6Results
|
||
let internationalIPv4 = await internationalIPv4Results
|
||
let internationalIPv6 = await internationalIPv6Results
|
||
let sourceResults = domesticIPv4.results
|
||
+ domesticIPv6.results
|
||
+ internationalIPv4.results
|
||
+ internationalIPv6.results
|
||
let domesticIPv4Result = domesticIPv4.best
|
||
let domesticIPv6Result = domesticIPv6.best
|
||
let internationalIPv4Result = internationalIPv4.best
|
||
let internationalIPv6Result = internationalIPv6.best
|
||
|
||
var geoInfoByIP: [String: IPOverviewGeoInfo] = [:]
|
||
for result in [
|
||
domesticIPv4Result,
|
||
domesticIPv6Result,
|
||
internationalIPv4Result,
|
||
internationalIPv6Result
|
||
].compactMap({ $0 }) {
|
||
if let geoInfo = await fetchGeoInfo(ip: result.ip) {
|
||
geoInfoByIP[result.ip] = geoInfo
|
||
}
|
||
}
|
||
|
||
let errorMessage: String?
|
||
if domesticIPv4Result == nil
|
||
&& domesticIPv6Result == nil
|
||
&& internationalIPv4Result == nil
|
||
&& internationalIPv6Result == nil {
|
||
errorMessage = localization.string(
|
||
"service.error.noPublicIP",
|
||
defaultValue: "未能从外部检测源获取公网 IP"
|
||
)
|
||
} else {
|
||
errorMessage = nil
|
||
}
|
||
|
||
return IPOverviewSnapshot(
|
||
domesticIPv4: domesticIPv4Result,
|
||
domesticIPv6: domesticIPv6Result,
|
||
internationalIPv4: internationalIPv4Result,
|
||
internationalIPv6: internationalIPv6Result,
|
||
localAddresses: local,
|
||
geoInfoByIP: geoInfoByIP,
|
||
sourceResults: sourceResults,
|
||
lastUpdated: Date(),
|
||
errorMessage: errorMessage,
|
||
isRefreshing: false
|
||
)
|
||
}
|
||
|
||
func collectPublicIPSnapshot(preserving snapshot: IPOverviewSnapshot) async -> IPOverviewSnapshot {
|
||
async let domesticIPv4Results = fetchPublicIP(route: .domestic, family: .ipv4)
|
||
async let domesticIPv6Results = fetchPublicIP(route: .domestic, family: .ipv6)
|
||
async let internationalIPv4Results = fetchPublicIP(route: .international, family: .ipv4)
|
||
async let internationalIPv6Results = fetchPublicIP(route: .international, family: .ipv6)
|
||
|
||
let domesticIPv4 = await domesticIPv4Results
|
||
let domesticIPv6 = await domesticIPv6Results
|
||
let internationalIPv4 = await internationalIPv4Results
|
||
let internationalIPv6 = await internationalIPv6Results
|
||
let sourceResults = domesticIPv4.results
|
||
+ domesticIPv6.results
|
||
+ internationalIPv4.results
|
||
+ internationalIPv6.results
|
||
let domesticIPv4Result = domesticIPv4.best
|
||
let domesticIPv6Result = domesticIPv6.best
|
||
let internationalIPv4Result = internationalIPv4.best
|
||
let internationalIPv6Result = internationalIPv6.best
|
||
let publicIPs = Set([
|
||
domesticIPv4Result?.ip,
|
||
domesticIPv6Result?.ip,
|
||
internationalIPv4Result?.ip,
|
||
internationalIPv6Result?.ip
|
||
].compactMap(\.self))
|
||
|
||
let errorMessage: String?
|
||
if domesticIPv4Result == nil
|
||
&& domesticIPv6Result == nil
|
||
&& internationalIPv4Result == nil
|
||
&& internationalIPv6Result == nil {
|
||
errorMessage = localization.string(
|
||
"service.error.noPublicIP",
|
||
defaultValue: "未能从外部检测源获取公网 IP"
|
||
)
|
||
} else {
|
||
errorMessage = nil
|
||
}
|
||
|
||
return IPOverviewSnapshot(
|
||
domesticIPv4: domesticIPv4Result,
|
||
domesticIPv6: domesticIPv6Result,
|
||
internationalIPv4: internationalIPv4Result,
|
||
internationalIPv6: internationalIPv6Result,
|
||
localAddresses: snapshot.localAddresses,
|
||
geoInfoByIP: snapshot.geoInfoByIP.filter { publicIPs.contains($0.key) },
|
||
sourceResults: sourceResults,
|
||
lastUpdated: Date(),
|
||
errorMessage: errorMessage,
|
||
isRefreshing: false
|
||
)
|
||
}
|
||
|
||
private func fetchPublicIP(
|
||
route: IPOverviewEgressRoute,
|
||
family: IPOverviewAddressFamily
|
||
) async -> (best: IPOverviewPublicIPResult?, results: [IPOverviewSourceResult]) {
|
||
let sources = IPOverviewPublicIPSource.sources(route: route, family: family)
|
||
let orderByID = Dictionary(uniqueKeysWithValues: sources.enumerated().map { ($0.element.id, $0.offset) })
|
||
|
||
let unorderedResults = await withTaskGroup(of: IPOverviewSourceResult.self) { group in
|
||
for source in sources {
|
||
group.addTask {
|
||
do {
|
||
let ip = try await fetchIP(from: source)
|
||
return IPOverviewSourceResult(
|
||
id: source.id,
|
||
family: family,
|
||
route: route,
|
||
source: source.name,
|
||
status: .success(ip)
|
||
)
|
||
} catch {
|
||
let message = (error as? IPOverviewServiceError)?
|
||
.localizedDescription(localization: localization)
|
||
?? error.localizedDescription
|
||
return IPOverviewSourceResult(
|
||
id: source.id,
|
||
family: family,
|
||
route: route,
|
||
source: source.name,
|
||
status: .failure(message)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
var results: [IPOverviewSourceResult] = []
|
||
for await result in group {
|
||
results.append(result)
|
||
}
|
||
return results
|
||
}
|
||
|
||
let results = unorderedResults.sorted {
|
||
(orderByID[$0.id] ?? Int.max) < (orderByID[$1.id] ?? Int.max)
|
||
}
|
||
let best = results.lazy.compactMap { sourceResult -> IPOverviewPublicIPResult? in
|
||
guard case .success(let ip) = sourceResult.status else {
|
||
return nil
|
||
}
|
||
|
||
return IPOverviewPublicIPResult(
|
||
family: family,
|
||
route: route,
|
||
ip: ip,
|
||
source: sourceResult.source
|
||
)
|
||
}.first
|
||
|
||
return (best, results)
|
||
}
|
||
|
||
private func fetchIP(from source: IPOverviewPublicIPSource) async throws -> String {
|
||
let (data, response) = try await fetch(source.url)
|
||
guard (200..<300).contains(response.statusCode) else {
|
||
throw IPOverviewServiceError.badStatus(response.statusCode)
|
||
}
|
||
|
||
let ip: String?
|
||
switch source.parser {
|
||
case .jsonIP:
|
||
ip = IPOverviewParser.ipFromJSON(data)
|
||
case .cloudflareTrace:
|
||
ip = IPOverviewParser.ipFromCloudflareTrace(data)
|
||
case .plainText:
|
||
ip = String(data: data, encoding: .utf8)?
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
case .bilibiliIPService:
|
||
ip = IPOverviewParser.ipFromBilibiliIPService(data)
|
||
case .cipText:
|
||
ip = IPOverviewParser.ipFromCIPText(data)
|
||
case .ipipText:
|
||
ip = IPOverviewParser.ipFromIPIPText(data)
|
||
}
|
||
|
||
guard let ip else {
|
||
throw IPOverviewServiceError.invalidPayload
|
||
}
|
||
|
||
guard Self.isValidIPAddress(ip, family: source.family) else {
|
||
throw IPOverviewServiceError.invalidIP(ip)
|
||
}
|
||
|
||
return ip
|
||
}
|
||
|
||
private func fetchGeoInfo(ip: String) async -> IPOverviewGeoInfo? {
|
||
for source in IPOverviewGeoSource.allCases {
|
||
do {
|
||
let (data, response) = try await fetch(source.url(ip: ip))
|
||
guard (200..<300).contains(response.statusCode) else {
|
||
continue
|
||
}
|
||
|
||
switch source {
|
||
case .ipwhois:
|
||
if let info = IPOverviewParser.geoInfoFromIPWhois(data, ip: ip) {
|
||
return info
|
||
}
|
||
case .ipapi:
|
||
if let info = IPOverviewParser.geoInfoFromIPAPI(data, ip: ip) {
|
||
return info
|
||
}
|
||
}
|
||
} catch {
|
||
continue
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
private func fetch(_ url: URL) async throws -> (Data, HTTPURLResponse) {
|
||
var request = URLRequest(url: url)
|
||
request.timeoutInterval = timeout
|
||
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||
request.setValue("MacTools IP Overview", forHTTPHeaderField: "User-Agent")
|
||
return try await httpClient.data(for: request)
|
||
}
|
||
|
||
static func localAddresses() -> [IPOverviewLocalAddress] {
|
||
var interfaceAddresses: UnsafeMutablePointer<ifaddrs>?
|
||
guard getifaddrs(&interfaceAddresses) == 0, let firstAddress = interfaceAddresses else {
|
||
return []
|
||
}
|
||
defer { freeifaddrs(interfaceAddresses) }
|
||
|
||
var addresses: [IPOverviewLocalAddress] = []
|
||
var pointer: UnsafeMutablePointer<ifaddrs>? = firstAddress
|
||
|
||
while let currentPointer = pointer {
|
||
defer { pointer = currentPointer.pointee.ifa_next }
|
||
|
||
let name = String(cString: currentPointer.pointee.ifa_name)
|
||
guard !isNoiseInterface(name),
|
||
(currentPointer.pointee.ifa_flags & UInt32(IFF_UP)) != 0,
|
||
let socketAddress = currentPointer.pointee.ifa_addr
|
||
else {
|
||
continue
|
||
}
|
||
|
||
let family: IPOverviewAddressFamily
|
||
switch Int32(socketAddress.pointee.sa_family) {
|
||
case AF_INET:
|
||
family = .ipv4
|
||
case AF_INET6:
|
||
family = .ipv6
|
||
default:
|
||
continue
|
||
}
|
||
|
||
guard let address = numericAddress(from: socketAddress),
|
||
isUsableLocalAddress(address, family: family)
|
||
else {
|
||
continue
|
||
}
|
||
|
||
addresses.append(IPOverviewLocalAddress(
|
||
id: "\(name)-\(address)",
|
||
interfaceName: name,
|
||
address: address,
|
||
family: family
|
||
))
|
||
}
|
||
|
||
return addresses.sorted {
|
||
if $0.interfaceName == $1.interfaceName {
|
||
return $0.address < $1.address
|
||
}
|
||
|
||
return $0.interfaceName < $1.interfaceName
|
||
}
|
||
}
|
||
|
||
static func isValidIPAddress(_ value: String, family: IPOverviewAddressFamily? = nil) -> Bool {
|
||
var ipv4 = in_addr()
|
||
var ipv6 = in6_addr()
|
||
|
||
switch family {
|
||
case .ipv4:
|
||
return inet_pton(AF_INET, value, &ipv4) == 1
|
||
case .ipv6:
|
||
return inet_pton(AF_INET6, value, &ipv6) == 1
|
||
case nil:
|
||
return inet_pton(AF_INET, value, &ipv4) == 1
|
||
|| inet_pton(AF_INET6, value, &ipv6) == 1
|
||
}
|
||
}
|
||
|
||
private static func numericAddress(from pointer: UnsafePointer<sockaddr>) -> String? {
|
||
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||
let result = getnameinfo(
|
||
pointer,
|
||
socklen_t(pointer.pointee.sa_len),
|
||
&host,
|
||
socklen_t(host.count),
|
||
nil,
|
||
0,
|
||
NI_NUMERICHOST
|
||
)
|
||
|
||
guard result == 0 else {
|
||
return nil
|
||
}
|
||
|
||
let nullIndex = host.firstIndex(of: 0) ?? host.endIndex
|
||
let bytes = host[..<nullIndex].map { UInt8(bitPattern: $0) }
|
||
return String(decoding: bytes, as: UTF8.self).nilIfEmpty
|
||
}
|
||
|
||
private static func isUsableLocalAddress(
|
||
_ address: String,
|
||
family: IPOverviewAddressFamily
|
||
) -> Bool {
|
||
switch family {
|
||
case .ipv4:
|
||
return !address.hasPrefix("127.") && address != "0.0.0.0"
|
||
case .ipv6:
|
||
return !address.hasPrefix("::1")
|
||
&& !address.lowercased().hasPrefix("fe80")
|
||
}
|
||
}
|
||
|
||
private static func isNoiseInterface(_ name: String) -> Bool {
|
||
let lowercasedName = name.lowercased()
|
||
let noisePrefixes = ["lo", "awdl", "utun", "llw", "bridge", "gif", "stf", "xhc", "anpi"]
|
||
return noisePrefixes.contains { lowercasedName.hasPrefix($0) }
|
||
}
|
||
}
|
||
|
||
struct IPOverviewPublicIPSource: Sendable {
|
||
enum Parser: Sendable {
|
||
case jsonIP
|
||
case cloudflareTrace
|
||
case plainText
|
||
case bilibiliIPService
|
||
case cipText
|
||
case ipipText
|
||
}
|
||
|
||
let id: String
|
||
let name: String
|
||
let route: IPOverviewEgressRoute
|
||
let family: IPOverviewAddressFamily
|
||
let url: URL
|
||
let parser: Parser
|
||
|
||
static func sources(
|
||
route: IPOverviewEgressRoute,
|
||
family: IPOverviewAddressFamily
|
||
) -> [IPOverviewPublicIPSource] {
|
||
switch (route, family) {
|
||
case (.domestic, .ipv4):
|
||
return [
|
||
IPOverviewPublicIPSource(
|
||
id: "bilibili-v4",
|
||
name: "Bilibili IPv4",
|
||
route: .domestic,
|
||
family: .ipv4,
|
||
url: URL(string: "https://api.live.bilibili.com/ip_service/v1/ip_service/get_ip_addr")!,
|
||
parser: .bilibiliIPService
|
||
),
|
||
IPOverviewPublicIPSource(
|
||
id: "cip-v4",
|
||
name: "cip.cc IPv4",
|
||
route: .domestic,
|
||
family: .ipv4,
|
||
url: URL(string: "https://cip.cc")!,
|
||
parser: .cipText
|
||
),
|
||
IPOverviewPublicIPSource(
|
||
id: "ipip-v4",
|
||
name: "IPIP IPv4",
|
||
route: .domestic,
|
||
family: .ipv4,
|
||
url: URL(string: "https://myip.ipip.net")!,
|
||
parser: .ipipText
|
||
),
|
||
IPOverviewPublicIPSource(
|
||
id: "netart-v4",
|
||
name: "NetArt IPv4",
|
||
route: .domestic,
|
||
family: .ipv4,
|
||
url: URL(string: "https://ipv4.netart.cn")!,
|
||
parser: .jsonIP
|
||
)
|
||
]
|
||
case (.domestic, .ipv6):
|
||
return [
|
||
IPOverviewPublicIPSource(
|
||
id: "ddnspod-v6",
|
||
name: "DNSPod IPv6",
|
||
route: .domestic,
|
||
family: .ipv6,
|
||
url: URL(string: "https://ipv6.ddnspod.com")!,
|
||
parser: .plainText
|
||
),
|
||
IPOverviewPublicIPSource(
|
||
id: "netart-v6",
|
||
name: "NetArt IPv6",
|
||
route: .domestic,
|
||
family: .ipv6,
|
||
url: URL(string: "https://ipv6.netart.cn")!,
|
||
parser: .jsonIP
|
||
)
|
||
]
|
||
case (.international, .ipv4):
|
||
return [
|
||
IPOverviewPublicIPSource(
|
||
id: "ipcheck-v4-json",
|
||
name: "IPCheck.ing IPv4",
|
||
route: .international,
|
||
family: .ipv4,
|
||
url: URL(string: "https://4.ipcheck.ing")!,
|
||
parser: .jsonIP
|
||
),
|
||
IPOverviewPublicIPSource(
|
||
id: "ipcheck-v4-trace",
|
||
name: "IPCheck.ing Trace IPv4",
|
||
route: .international,
|
||
family: .ipv4,
|
||
url: URL(string: "https://4.ipcheck.ing/cdn-cgi/trace")!,
|
||
parser: .cloudflareTrace
|
||
),
|
||
IPOverviewPublicIPSource(
|
||
id: "ipify-v4",
|
||
name: "IPify IPv4",
|
||
route: .international,
|
||
family: .ipv4,
|
||
url: URL(string: "https://api4.ipify.org?format=json")!,
|
||
parser: .jsonIP
|
||
),
|
||
IPOverviewPublicIPSource(
|
||
id: "ifconfig-v4",
|
||
name: "ifconfig.me IPv4",
|
||
route: .international,
|
||
family: .ipv4,
|
||
url: URL(string: "https://ifconfig.me/ip")!,
|
||
parser: .plainText
|
||
)
|
||
]
|
||
case (.international, .ipv6):
|
||
return [
|
||
IPOverviewPublicIPSource(
|
||
id: "ipcheck-v6-json",
|
||
name: "IPCheck.ing IPv6",
|
||
route: .international,
|
||
family: .ipv6,
|
||
url: URL(string: "https://6.ipcheck.ing")!,
|
||
parser: .jsonIP
|
||
),
|
||
IPOverviewPublicIPSource(
|
||
id: "ipcheck-v6-trace",
|
||
name: "IPCheck.ing Trace IPv6",
|
||
route: .international,
|
||
family: .ipv6,
|
||
url: URL(string: "https://6.ipcheck.ing/cdn-cgi/trace")!,
|
||
parser: .cloudflareTrace
|
||
),
|
||
IPOverviewPublicIPSource(
|
||
id: "ipify-v6",
|
||
name: "IPify IPv6",
|
||
route: .international,
|
||
family: .ipv6,
|
||
url: URL(string: "https://api6.ipify.org?format=json")!,
|
||
parser: .jsonIP
|
||
)
|
||
]
|
||
}
|
||
}
|
||
}
|
||
|
||
enum IPOverviewGeoSource: CaseIterable {
|
||
case ipwhois
|
||
case ipapi
|
||
|
||
func url(ip: String) -> URL {
|
||
switch self {
|
||
case .ipwhois:
|
||
return url(ip: ip, languageCode: IPOverviewLocale.geoLanguageCode())
|
||
case .ipapi:
|
||
return URL(string: "https://ipapi.co/\(ip)/json/")!
|
||
}
|
||
}
|
||
|
||
func url(ip: String, languageCode: String?) -> URL {
|
||
switch self {
|
||
case .ipwhois:
|
||
var components = URLComponents(string: "https://ipwho.is/\(ip)")!
|
||
if let languageCode {
|
||
components.queryItems = [URLQueryItem(name: "lang", value: languageCode)]
|
||
}
|
||
return components.url!
|
||
case .ipapi:
|
||
return URL(string: "https://ipapi.co/\(ip)/json/")!
|
||
}
|
||
}
|
||
}
|
||
|
||
enum IPOverviewLocale {
|
||
static func geoLanguageCode() -> String? {
|
||
let preferredLocalization = PluginRuntimeLocalization.preferredLanguages.first
|
||
?? Locale.current.identifier
|
||
return geoLanguageCode(preferredLocalization: preferredLocalization)
|
||
}
|
||
|
||
static func geoLanguageCode(preferredLocalization: String) -> String? {
|
||
let normalizedLocalization = preferredLocalization
|
||
.replacingOccurrences(of: "_", with: "-")
|
||
.lowercased()
|
||
|
||
if normalizedLocalization.hasPrefix("zh") {
|
||
return "zh-CN"
|
||
}
|
||
|
||
if normalizedLocalization.hasPrefix("en") {
|
||
return "en"
|
||
}
|
||
|
||
return nil
|
||
}
|
||
}
|
||
|
||
enum IPOverviewParser {
|
||
static func ipFromJSON(_ data: Data) -> String? {
|
||
guard
|
||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||
let ip = object["ip"] as? String
|
||
else {
|
||
return nil
|
||
}
|
||
|
||
return ip.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||
}
|
||
|
||
static func ipFromCloudflareTrace(_ data: Data) -> String? {
|
||
guard let text = String(data: data, encoding: .utf8) else {
|
||
return nil
|
||
}
|
||
|
||
return text
|
||
.split(separator: "\n")
|
||
.first { $0.hasPrefix("ip=") }
|
||
.map { String($0.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) }?
|
||
.nilIfEmpty
|
||
}
|
||
|
||
static func ipFromBilibiliIPService(_ data: Data) -> String? {
|
||
guard
|
||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||
let data = object["data"] as? [String: Any],
|
||
let ip = data["addr"] as? String
|
||
else {
|
||
return nil
|
||
}
|
||
|
||
return ip.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||
}
|
||
|
||
static func ipFromCIPText(_ data: Data) -> String? {
|
||
guard let text = String(data: data, encoding: .utf8) else {
|
||
return nil
|
||
}
|
||
|
||
return text
|
||
.split(separator: "\n")
|
||
.lazy
|
||
.compactMap { line -> String? in
|
||
let parts = line.split(separator: ":", maxSplits: 1)
|
||
guard parts.count == 2,
|
||
parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "ip"
|
||
else {
|
||
return nil
|
||
}
|
||
return String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||
}
|
||
.first
|
||
}
|
||
|
||
static func ipFromIPIPText(_ data: Data) -> String? {
|
||
guard let text = String(data: data, encoding: .utf8) else {
|
||
return nil
|
||
}
|
||
|
||
let pattern = #"(?:(?:当前\s*)?IP|Ip)\s*[::]\s*([0-9A-Fa-f:.]+)"#
|
||
guard let regex = try? NSRegularExpression(pattern: pattern),
|
||
let match = regex.firstMatch(
|
||
in: text,
|
||
range: NSRange(text.startIndex..<text.endIndex, in: text)
|
||
),
|
||
let range = Range(match.range(at: 1), in: text)
|
||
else {
|
||
return nil
|
||
}
|
||
|
||
return String(text[range]).trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||
}
|
||
|
||
static func geoInfoFromIPWhois(_ data: Data, ip: String) -> IPOverviewGeoInfo? {
|
||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||
return nil
|
||
}
|
||
|
||
if let success = object["success"] as? Bool, success == false {
|
||
return nil
|
||
}
|
||
|
||
let connection = object["connection"] as? [String: Any]
|
||
let timezone = object["timezone"] as? [String: Any]
|
||
let asnValue = connection?["asn"].map { value in
|
||
if let intValue = value as? Int {
|
||
return "AS\(intValue)"
|
||
}
|
||
return "\(value)"
|
||
}
|
||
|
||
return IPOverviewGeoInfo(
|
||
ip: ip,
|
||
country: object["country"] as? String,
|
||
countryCode: object["country_code"] as? String,
|
||
region: object["region"] as? String,
|
||
city: object["city"] as? String,
|
||
isp: connection?["isp"] as? String,
|
||
organization: connection?["org"] as? String,
|
||
asn: asnValue,
|
||
timezone: timezone?["id"] as? String,
|
||
networkType: IPOverviewNetworkType.infer(
|
||
organization: connection?["org"] as? String,
|
||
isp: connection?["isp"] as? String,
|
||
isHosting: nil
|
||
),
|
||
isProxy: nil,
|
||
isHosting: nil,
|
||
source: "ipwho.is"
|
||
)
|
||
}
|
||
|
||
static func geoInfoFromIPAPI(_ data: Data, ip: String) -> IPOverviewGeoInfo? {
|
||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||
return nil
|
||
}
|
||
|
||
if object["error"] as? Bool == true {
|
||
return nil
|
||
}
|
||
|
||
let asn = object["asn"] as? String
|
||
let organization = object["org"] as? String
|
||
return IPOverviewGeoInfo(
|
||
ip: ip,
|
||
country: object["country_name"] as? String,
|
||
countryCode: object["country_code"] as? String,
|
||
region: object["region"] as? String,
|
||
city: object["city"] as? String,
|
||
isp: organization,
|
||
organization: organization,
|
||
asn: asn?.hasPrefix("AS") == true ? asn : asn.map { "AS\($0)" },
|
||
timezone: object["timezone"] as? String,
|
||
networkType: IPOverviewNetworkType.infer(
|
||
organization: organization,
|
||
isp: organization,
|
||
isHosting: nil
|
||
),
|
||
isProxy: nil,
|
||
isHosting: nil,
|
||
source: "ipapi.co"
|
||
)
|
||
}
|
||
}
|