diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c77b278..228100d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -120,8 +120,13 @@ is open and back to accessory when all are closed. sequence number and a random payload token because the kernel rewrites the identifier. - **Interface and addresses** — `SCDynamicStore` / `SCPreferences` for the primary and physical service, `getifaddrs` for addresses, CoreWLAN for signal and rate. -- **Public IP** — ipinfo.io for address, region and ASN; Cloudflare trace and ipify for IPv6 and - fallback. Country codes are validated before being used as flag file names. +- **Public IP** — Cloudflare trace (ipify as fallback) for the address only. Region and ASN come from + MaxMind GeoLite2 databases read by `MaxMindDatabase`, a memory-mapped reader of the MaxMind DB + format (search tree + data section decoder, no dependencies). `server/geoip/` holds the systemd + timer that syncs GeoLite2 onto getopenstats.com with the MaxMind key kept in `/etc/openstats` on the + server; the app fetches `geoip/manifest.json`, downloads changed files, verifies sha256 and the + database type, then swaps them in. Without a local database it falls back to ipinfo.io. Country + codes are validated before being used as flag file names. - **Per-process traffic** — cumulative bytes from `/usr/bin/nettop`, diffed between samples. - **DNS** — `networksetup -setdnsservers` through the helper (protocol 3), which re-validates the service name and every address; without the helper, a one-off administrator prompt runs the diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ff9977..2c60786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。 - 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。 - 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。 +- IP 归属地改为本地 MaxMind GeoLite2 数据库查询:自己实现 .mmdb 读取器(不依赖第三方库),国家、城市与 ASN 完全离线查;数据库由官网服务器每周二、周五用 MaxMind 账号同步,应用每 3 天检查清单、下载并按 sha256 校验后替换,也可以从文件导入;设置中可选是否包含城市库(约 60 MB);数据库就绪前暂用 ipinfo.io 在线查询,网络详情里标明数据来源。 - 本机信息页:机型图与名称、系统版本与版号、芯片与各类核心、内存与图形、存储用量、电池健康与循环次数、每台显示器的尺寸 / 原生分辨率 / 显示分辨率 / 刷新率、机型标识符、序列号(默认遮住,点眼睛图标显示,截图时始终遮住)与启动时间。 - 进程管理器(主窗口“进程”页):包括 root 与其他用户的系统进程(通过系统自带的 ps 读取,不需要辅助工具);列出 CPU、CPU 时间、内存、线程、唤醒次数、磁盘读写与用户,点列标题排序;可搜索名称、PID 或用户,筛选全部 / 我的 / 系统,切换按进程或按应用合并;选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。 - 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。 diff --git a/Packages/OpenStatsKit/Sources/Metrics/MaxMindDatabase.swift b/Packages/OpenStatsKit/Sources/Metrics/MaxMindDatabase.swift new file mode 100644 index 0000000..e6bda80 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/MaxMindDatabase.swift @@ -0,0 +1,295 @@ +import Darwin +import Foundation + +/// MaxMind DB(.mmdb)读取器,按格式规范 2.0 实现:二叉搜索树定位记录,再解码数据段。 +/// 文件以内存映射方式打开,查询不需要把整个库读进内存。 +public final class MaxMindDatabase: @unchecked Sendable { + public enum Error: Swift.Error, Equatable { + case unreadable + case missingMetadata + case invalidMetadata(String) + case corrupt + } + + public let databaseType: String + /// 数据生成时间 + public let buildDate: Date + public let ipVersion: Int + + private let data: Data + private let nodeCount: Int + private let recordSize: Int + private let treeSize: Int + private let dataSectionStart: Int + private let ipv4Start: Int + + private static let metadataMarker = Data([0xAB, 0xCD, 0xEF] + Array("MaxMind.com".utf8)) + + public convenience init(url: URL) throws { + guard let data = try? Data(contentsOf: url, options: .alwaysMapped) else { throw Error.unreadable } + try self.init(data: data) + } + + public init(data: Data) throws { + self.data = data + // 元数据在文件末尾 128 KB 以内,取最后一次出现的标记 + let searchStart = max(0, data.count - 128 * 1024) + guard let marker = data.range(of: Self.metadataMarker, options: .backwards, in: searchStart.. Value? { + guard let bytes = Self.addressBytes(address) else { return nil } + if bytes.count == 16, ipVersion == 4 { return nil } + + var node = bytes.count == 4 ? ipv4Start : 0 + let bitCount = bytes.count * 8 + for index in 0..> 3] >> (7 - UInt8(index % 8))) & 1) + node = Self.readRecord(data: data, node: node, bit: bit, recordSize: recordSize) + } + guard node > nodeCount else { return nil } // 等于 nodeCount 表示没有数据 + let offset = node - nodeCount + treeSize + guard offset < data.count else { return nil } + var decoder = Decoder(data: data, pointerBase: dataSectionStart) + return try? decoder.decode(at: offset).value + } + + // MARK: 搜索树 + + private static func readRecord(data: Data, node: Int, bit: Int, recordSize: Int) -> Int { + data.withUnsafeBytes { raw -> Int in + let bytes = raw.bindMemory(to: UInt8.self) + func byte(_ offset: Int) -> Int { Int(bytes[offset]) } + switch recordSize { + case 24: + let base = node * 6 + bit * 3 + return byte(base) << 16 | byte(base + 1) << 8 | byte(base + 2) + case 28: + let base = node * 7 + if bit == 0 { + return (byte(base + 3) & 0xF0) << 20 | byte(base) << 16 | byte(base + 1) << 8 | byte(base + 2) + } + return (byte(base + 3) & 0x0F) << 24 | byte(base + 4) << 16 | byte(base + 5) << 8 | byte(base + 6) + default: + let base = node * 8 + bit * 4 + return byte(base) << 24 | byte(base + 1) << 16 | byte(base + 2) << 8 | byte(base + 3) + } + } + } + + static func addressBytes(_ address: String) -> [UInt8]? { + var v4 = in_addr() + if inet_pton(AF_INET, address, &v4) == 1 { + return withUnsafeBytes(of: &v4.s_addr) { Array($0) } + } + var v6 = in6_addr() + if inet_pton(AF_INET6, address, &v6) == 1 { + return withUnsafeBytes(of: &v6) { Array($0) } + } + return nil + } + + // MARK: 数据段 + + public indirect enum Value: Sendable, Equatable { + case string(String) + case double(Double) + case bytes(Data) + case unsigned(UInt64) + case signed(Int64) + case map([String: Value]) + case array([Value]) + case boolean(Bool) + + public subscript(key: String) -> Value? { + if case .map(let map) = self { return map[key] } + return nil + } + + public var string: String? { + if case .string(let value) = self { return value } + return nil + } + + public var integer: Int? { + switch self { + case .unsigned(let value): Int(exactly: value) + case .signed(let value): Int(exactly: value) + default: nil + } + } + } + + struct Decoder { + let data: Data + let pointerBase: Int + + mutating func decode(at offset: Int) throws -> (value: Value, next: Int) { + var position = offset + let control = try byte(&position) + var type = Int(control >> 5) + + if type == 1 { // 指针:跳到数据段里的另一处解码,解码完回到指针之后 + let size = Int((control >> 3) & 0x3) + let high = Int(control & 0x7) + let raw = try bytes(&position, count: size + 1) + var pointer: Int + switch size { + case 0: pointer = high << 8 | raw[0] + case 1: pointer = (high << 16 | raw[0] << 8 | raw[1]) + 2048 + case 2: pointer = (high << 24 | raw[0] << 16 | raw[1] << 8 | raw[2]) + 526_336 + default: pointer = raw[0] << 24 | raw[1] << 16 | raw[2] << 8 | raw[3] + } + pointer += pointerBase + let resolved = try decode(at: pointer) + return (resolved.value, position) + } + if type == 0 { // 扩展类型 + type = 7 + Int(try byte(&position)) + } + + var size = Int(control & 0x1F) + if size >= 29 { + let extra = try bytes(&position, count: size - 28) + switch size { + case 29: size = 29 + extra[0] + case 30: size = 285 + (extra[0] << 8 | extra[1]) + default: size = 65_821 + (extra[0] << 16 | extra[1] << 8 | extra[2]) + } + } + + switch type { + case 2: + let raw = try slice(&position, count: size) + return (.string(String(decoding: raw, as: UTF8.self)), position) + case 3: + guard size == 8 else { throw Error.corrupt } + let raw = try slice(&position, count: 8) + return (.double(Double(bitPattern: UInt64(bigEndianBytes: raw))), position) + case 4: + return (.bytes(try slice(&position, count: size)), position) + case 5, 6, 9, 10: + let raw = try slice(&position, count: size) + // uint128 超出 64 位的部分在地理库里用不到,只保留低 64 位 + return (.unsigned(UInt64(bigEndianBytes: raw.suffix(8))), position) + case 7: + var map: [String: Value] = [:] + map.reserveCapacity(size) + for _ in 0.. UInt8 { + guard position < data.count else { throw Error.corrupt } + defer { position += 1 } + return data[data.startIndex + position] + } + + private func bytes(_ position: inout Int, count: Int) throws -> [Int] { + try slice(&position, count: count).map(Int.init) + } + + private func slice(_ position: inout Int, count: Int) throws -> Data { + guard count >= 0, position + count <= data.count else { throw Error.corrupt } + defer { position += count } + return data.subdata(in: (data.startIndex + position)..<(data.startIndex + position + count)) + } + } +} + +private extension UInt64 { + init(bigEndianBytes bytes: Bytes) where Bytes.Element == UInt8 { + self = bytes.reduce(0) { $0 << 8 | UInt64($1) } + } +} + +// MARK: - 地理信息 + +/// 从 GeoLite2 City / Country 与 ASN 库里取出界面需要的字段 +public struct GeoLocation: Sendable, Equatable { + public var countryCode: String? + public var countryName: String? + public var city: String? + public var asn: String? + public var organization: String? +} + +public enum GeoLookup { + /// 名称优先取简体中文,没有时用英文 + public static func locate(_ address: String, location: MaxMindDatabase?, asn: MaxMindDatabase?) -> GeoLocation? { + var result = GeoLocation() + if let record = location?.lookup(address) { + let country = record["country"] ?? record["registered_country"] + result.countryCode = country?["iso_code"]?.string + result.countryName = name(country) + result.city = name(record["city"]) + } + if let record = asn?.lookup(address) { + result.asn = record["autonomous_system_number"]?.integer.map { "AS\($0)" } + result.organization = record["autonomous_system_organization"]?.string + } + return result == GeoLocation() ? nil : result + } + + private static func name(_ value: MaxMindDatabase.Value?) -> String? { + guard let names = value?["names"] else { return nil } + return names["zh-CN"]?.string ?? names["en"]?.string + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift b/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift index 3194ffe..a22cd98 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift @@ -10,6 +10,14 @@ public struct PublicAddresses: Sendable, Equatable { 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) { @@ -35,7 +43,14 @@ public enum PublicAddressLookup { return URLSession(configuration: configuration) }() - public static func fetch() async -> PublicAddresses { + /// `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() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index e90c2ef..4a86d07 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -40,6 +40,7 @@ public final class AppController: NSObject, NSApplicationDelegate, NSWindowDeleg observeProbeSettings() applyAppearance() model.network.updateProbing(networkShown: model.networkShown) + Task { await model.geo.updateIfNeeded() } // 开发调试:--show-panel [cpu|memory|network|gpu|temperature|fan] 启动后展开并固定弹窗;--show-window 打开主窗口 let arguments = CommandLine.arguments diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift index de066b2..7cf8dbc 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift @@ -214,6 +214,9 @@ private struct AddressSection: View { if let organization = publicAddresses?.organization { InfoRow(label: "网络运营方") { Text(verbatim: organization).lineLimit(1).truncationMode(.middle) } } + if let publicAddresses, publicAddresses.countryCode != nil || publicAddresses.asn != nil { + InfoRow(label: "数据来源", text: publicAddresses.source == .localDatabase ? "本地 GeoLite2(MaxMind)" : "ipinfo.io 在线查询") + } } else { InfoRow(label: "公网 IP", text: "查询已关闭") } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift index 1e425b2..bd7ae20 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift @@ -1,5 +1,6 @@ import AppKit import HelperShared +import Metrics import SwiftUI enum SettingsSection: String, CaseIterable, Identifiable { @@ -450,16 +451,100 @@ private struct NetworkSettings: View { SettingsGroup(caption: "公网 IP") { GroupRow(showsDivider: false) { - SettingRow(title: "查询公网 IP", subtitle: "打开网络详情时向 ipinfo.io 查询公网地址、归属地与 ASN,IPv6 与回退使用 Cloudflare / ipify;10 分钟内不重复请求") { + SettingRow(title: "查询公网 IP", + subtitle: model.geo.isAvailable + ? "打开网络详情时向 Cloudflare(1.1.1.1)或 ipify 查询一次公网地址;归属地与 ASN 在本机数据库里查,10 分钟内不重复请求" + : "打开网络详情时查询公网地址;本地数据库还没准备好时,归属地与 ASN 暂由 ipinfo.io 在线查询") { DSToggle(isOn: $settings.publicIPLookup, label: "查询公网 IP") } } } + GeoDatabaseSettings() + InfoBanner(icon: "lock.shield", text: "修改 DNS 需要管理员权限:已安装辅助工具时直接修改,否则每次弹出系统授权框。", tone: .neutral) } } +/// IP 归属地数据库(MaxMind GeoLite2) +private struct GeoDatabaseSettings: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var settings = model.settings + let geo = model.geo + + SettingsGroup(caption: "IP 归属地数据库") { + GroupRow(showsDivider: false) { + SettingRow(title: geo.isAvailable ? "本地 MaxMind GeoLite2" : "尚未下载", + subtitle: geo.isAvailable + ? "国家、城市与 ASN 在本机查询,不经过任何在线服务" + : "下载后归属地查询完全离线;数据库由 OpenStats 官网每周同步 MaxMind 的最新版本") { + StatusBadge(text: geo.isAvailable ? "离线查询" : "在线查询", tone: geo.isAvailable ? .success : .neutral) + } + } + ForEach(geo.installed.values.sorted { $0.edition < $1.edition }, id: \GeoDatabaseController.Installed.edition) { database in + GroupRow { + SettingRow(title: title(database.edition), subtitle: "版本 \(database.build) · \(Format.bytes(UInt64(database.size), base: .decimal))") { + Text(database.edition).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + } + } + GroupRow { + SettingRow(title: "包含城市数据", subtitle: "显示城市名称,数据库约 60 MB;关闭时只下载国家库(约 9 MB)") { + DSToggle(isOn: $settings.geoIncludeCity, label: "包含城市数据") + } + } + GroupRow { + SettingRow(title: "自动更新", subtitle: geo.lastChecked.map { "每 3 天检查一次 · 上次检查 \($0.formatted(date: .abbreviated, time: .shortened))" } ?? "每 3 天检查一次") { + DSToggle(isOn: $settings.geoAutoUpdate, label: "自动更新") + } + } + GroupRow { + HStack(spacing: DS.Space.s2) { + Button(geo.isUpdating ? "正在更新…" : geo.isAvailable ? "立即检查更新" : "下载数据库") { + Task { await geo.update() } + } + .buttonStyle(DSButtonStyle(kind: .primary)) + .disabled(geo.isUpdating) + Button("从文件导入…") { importFile() } + .buttonStyle(DSButtonStyle(kind: .secondary)) + if geo.isAvailable { + Button("在访达中显示") { NSWorkspace.shared.activateFileViewerSelecting([geo.directory]) } + .buttonStyle(DSButtonStyle(kind: .ghost)) + } + Spacer() + } + } + if let message = geo.message { + GroupRow { + Text(message.text).dsFont(.xs).foregroundStyle(message.isError ? DS.Palette.error : DS.Palette.success) + } + } + } + + Text(GeoDatabaseController.attribution) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + } + + private func title(_ edition: String) -> String { + if edition.hasSuffix("-City") { return "城市库" } + if edition.hasSuffix("-Country") { return "国家库" } + if edition.hasSuffix("-ASN") { return "ASN 库" } + return edition + } + + private func importFile() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.init(filenameExtension: "mmdb") ?? .data] + panel.allowsMultipleSelection = true + panel.message = "选择 GeoLite2 City / Country / ASN 的 .mmdb 文件" + guard panel.runModal() == .OK else { return } + for url in panel.urls { model.geo.importDatabase(from: url) } + } +} + // MARK: - 散热与防休眠 private struct ThermalSettings: View { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 58ff7aa..273efc9 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -16,6 +16,7 @@ public final class AppModel { public let cleaner: CleanerController public let maintenance: MaintenanceController public let network: NetworkController + public let geo: GeoDatabaseController public let explainer = ProcessExplainer() @ObservationIgnored public let hub = MetricsHub() @@ -39,7 +40,8 @@ public final class AppModel { keepAwake = KeepAwakeController(helper: helper, settings: settings) cleaner = CleanerController(settings: settings) maintenance = MaintenanceController(helper: helper) - network = NetworkController(settings: settings) + geo = GeoDatabaseController(settings: settings) + network = NetworkController(settings: settings, geo: geo) } /// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index fb9083d..ed294ad 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -344,6 +344,14 @@ public final class AppSettings { public var publicIPLookup: Bool { didSet { defaults.set(publicIPLookup, forKey: Keys.publicIPLookup) } } + /// 本地归属地库包含城市数据(GeoLite2-City,体积约为国家库的 7 倍) + public var geoIncludeCity: Bool { + didSet { defaults.set(geoIncludeCity, forKey: Keys.geoIncludeCity) } + } + /// 定期从官网检查归属地数据库更新 + public var geoAutoUpdate: Bool { + didSet { defaults.set(geoAutoUpdate, forKey: Keys.geoAutoUpdate) } + } /// 可再生的缓存也先移到废纸篓(可恢复,但不会立即释放空间) public var cleanPrefersTrash: Bool { didSet { defaults.set(cleanPrefersTrash, forKey: Keys.cleanPrefersTrash) } @@ -383,6 +391,8 @@ public final class AppSettings { ? defaults.integer(forKey: Keys.probeSeconds) : 2 probeTarget = defaults.string(forKey: Keys.probeTarget).flatMap(ProbeTarget.init(rawValue:)) ?? .cloudflare publicIPLookup = defaults.object(forKey: Keys.publicIPLookup) as? Bool ?? true + geoIncludeCity = defaults.bool(forKey: Keys.geoIncludeCity) + geoAutoUpdate = defaults.object(forKey: Keys.geoAutoUpdate) as? Bool ?? true } /// 按固定顺序返回已启用的菜单栏项目 @@ -430,5 +440,7 @@ public final class AppSettings { static let probeSeconds = "probeSeconds" static let probeTarget = "probeTarget" static let publicIPLookup = "publicIPLookup" + static let geoIncludeCity = "geoIncludeCity" + static let geoAutoUpdate = "geoAutoUpdate" } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/GeoDatabaseController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/GeoDatabaseController.swift new file mode 100644 index 0000000..2dd6b77 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/GeoDatabaseController.swift @@ -0,0 +1,191 @@ +import CryptoKit +import Foundation +import Metrics +import Observation + +/// 本地 IP 归属地数据库(MaxMind GeoLite2):从官网下载或手动导入,校验后放在应用支持目录,查询完全离线。 +@MainActor +@Observable +public final class GeoDatabaseController { + public struct Installed: Codable, Equatable { + let edition: String + let build: String + let sha256: String + let size: Int64 + } + + public private(set) var installed: [String: Installed] = [:] + public private(set) var isUpdating = false + public private(set) var message: (text: String, isError: Bool)? + public private(set) var lastChecked: Date? + + @ObservationIgnored private let settings: AppSettings + @ObservationIgnored private var locationDatabase: MaxMindDatabase? + @ObservationIgnored private var asnDatabase: MaxMindDatabase? + + static let manifestURL = URL(string: "https://getopenstats.com/geoip/manifest.json")! + static let attribution = "本产品包含 MaxMind 创建的 GeoLite2 数据,可从 https://www.maxmind.com 获取。" + private static let checkInterval: TimeInterval = 3 * 24 * 3600 + private static let lastCheckedKey = "geoDatabaseLastChecked" + + let directory: URL = { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return base.appendingPathComponent("OpenStats/GeoIP", isDirectory: true) + }() + private var indexURL: URL { directory.appendingPathComponent("installed.json") } + + init(settings: AppSettings) { + self.settings = settings + lastChecked = UserDefaults.standard.object(forKey: Self.lastCheckedKey) as? Date + reload() + } + + /// 至少有国家 / 城市库或 ASN 库之一 + var isAvailable: Bool { locationDatabase != nil || asnDatabase != nil } + + func locate(_ address: String) -> GeoLocation? { + GeoLookup.locate(address, location: locationDatabase, asn: asnDatabase) + } + + // MARK: 更新 + + func updateIfNeeded() async { + guard settings.geoAutoUpdate else { return } + if let lastChecked, Date().timeIntervalSince(lastChecked) < Self.checkInterval, isAvailable { return } + await update() + } + + func update() async { + guard !isUpdating else { return } + isUpdating = true + message = nil + defer { isUpdating = false } + + let wanted = ["GeoLite2-ASN", settings.geoIncludeCity ? "GeoLite2-City" : "GeoLite2-Country"] + do { + let (data, response) = try await URLSession.shared.data(from: Self.manifestURL) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw Failure("服务器上还没有数据库,稍后再试") + } + let manifest = try JSONDecoder().decode(Manifest.self, from: data) + var changed = 0 + for edition in wanted { + guard let entry = manifest.databases.first(where: { $0.edition == edition }) else { + throw Failure("服务器清单里没有 \(edition)") + } + if installed[edition]?.sha256 == entry.sha256 { continue } + try await download(entry) + changed += 1 + } + // 切换城市 / 国家库后删掉不再需要的那个 + for edition in installed.keys where !wanted.contains(edition) && edition.hasPrefix("GeoLite2-") { + try? FileManager.default.removeItem(at: directory.appendingPathComponent("\(edition).mmdb")) + installed[edition] = nil + } + try saveIndex() + reload() + lastChecked = Date() + UserDefaults.standard.set(lastChecked, forKey: Self.lastCheckedKey) + message = (changed > 0 ? "已更新 \(changed) 个数据库" : "已经是最新", false) + } catch let failure as Failure { + message = (failure.text, true) + } catch { + message = ("更新失败:\(error.localizedDescription)", true) + } + } + + /// 下载到临时文件,sha256 与清单一致、且能按库类型打开后才替换 + private func download(_ entry: Manifest.Entry) async throws { + guard let url = URL(string: entry.file, relativeTo: Self.manifestURL) else { throw Failure("清单里的文件地址无效") } + let (temporary, response) = try await URLSession.shared.download(from: url) + defer { try? FileManager.default.removeItem(at: temporary) } + guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw Failure("下载 \(entry.edition) 失败") } + let digest = try await Task.detached { try Self.sha256(of: temporary) }.value + guard digest == entry.sha256 else { throw Failure("\(entry.edition) 校验不一致,已丢弃") } + let database = try MaxMindDatabase(url: temporary) + guard database.databaseType == entry.edition else { throw Failure("\(entry.edition) 文件类型不对") } + try install(temporary, edition: entry.edition, build: entry.build, sha256: digest, size: entry.size) + } + + /// 手动导入 .mmdb:按文件里记录的库类型识别是城市、国家还是 ASN 库 + func importDatabase(from url: URL) { + do { + let database = try MaxMindDatabase(url: url) + let type = database.databaseType + guard type.hasSuffix("-City") || type.hasSuffix("-Country") || type.hasSuffix("-ASN") else { + throw Failure("不是城市、国家或 ASN 数据库(\(type))") + } + let digest = try Self.sha256(of: url) + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map(Int64.init) ?? 0 + let build = database.buildDate.formatted(.iso8601.year().month().day().dateSeparator(.omitted)) + try install(url, edition: type, build: build, sha256: digest, size: size, copy: true) + try saveIndex() + reload() + message = ("已导入 \(type)", false) + } catch let failure as Failure { + message = (failure.text, true) + } catch { + message = ("导入失败:\(error.localizedDescription)", true) + } + } + + private func install(_ source: URL, edition: String, build: String, sha256: String, size: Int64, copy: Bool = false) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let destination = directory.appendingPathComponent("\(edition).mmdb") + let staging = directory.appendingPathComponent(".\(edition).mmdb.new") + try? FileManager.default.removeItem(at: staging) + if copy { + try FileManager.default.copyItem(at: source, to: staging) + } else { + try FileManager.default.moveItem(at: source, to: staging) + } + _ = try FileManager.default.replaceItemAt(destination, withItemAt: staging) + installed[edition] = Installed(edition: edition, build: build, sha256: sha256, size: size) + } + + // MARK: 加载 + + private func reload() { + if let data = try? Data(contentsOf: indexURL), + let index = try? JSONDecoder().decode([String: Installed].self, from: data) { + installed = index.filter { FileManager.default.fileExists(atPath: directory.appendingPathComponent("\($0.key).mmdb").path) } + } + func open(_ suffix: String) -> MaxMindDatabase? { + installed.keys.sorted().filter { $0.hasSuffix(suffix) } + .lazy.compactMap { try? MaxMindDatabase(url: self.directory.appendingPathComponent("\($0).mmdb")) }.first + } + locationDatabase = open("-City") ?? open("-Country") + asnDatabase = open("-ASN") + } + + private func saveIndex() throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try JSONEncoder().encode(installed).write(to: indexURL, options: .atomic) + } + + nonisolated static func sha256(of url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var hasher = SHA256() + while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { + hasher.update(data: chunk) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + private struct Manifest: Decodable { + struct Entry: Decodable { + let edition: String + let file: String + let build: String + let sha256: String + let size: Int64 + } + let databases: [Entry] + } + + private struct Failure: Error { + let text: String + init(_ text: String) { self.text = text } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift index 743043c..f5ebc8f 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift @@ -29,8 +29,11 @@ public final class NetworkController { @ObservationIgnored private var isPaused = false @ObservationIgnored private var wantsDetail = false - init(settings: AppSettings) { + @ObservationIgnored private let geo: GeoDatabaseController + + init(settings: AppSettings, geo: GeoDatabaseController) { self.settings = settings + self.geo = geo } // MARK: 统计 @@ -133,7 +136,16 @@ public final class NetworkController { isLookingUpPublic = true let localIPv4 = details?.physical?.ipv4 ?? [] Task { - let result = await PublicAddressLookup.fetch() + // 有本地 GeoLite2 时只向外查询公网 IP,归属地与 ASN 在本机查 + let local = geo.isAvailable + var result = await PublicAddressLookup.fetch(includeGeo: !local) + if local, let address = result.ipv4 ?? result.ipv6, let location = geo.locate(address) { + result.countryCode = location.countryCode ?? result.countryCode + result.city = location.city + result.asn = location.asn + result.organization = location.organization + result.source = .localDatabase + } publicAddresses = result lastPublicLookup = (Date(), localIPv4) isLookingUpPublic = false diff --git a/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift b/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift index d31a9e6..624fd55 100644 --- a/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift +++ b/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift @@ -224,3 +224,76 @@ struct LiveSamplerTests { #expect(counts.threads >= counts.processes) } } + +@Suite struct MaxMindDatabaseTests { + /// 按 MaxMind DB 格式拼一个最小的 IPv4 库:只有一个节点,0.0.0.0/1 指向数据,128.0.0.0/1 没有数据 + private func makeDatabase() -> Data { + func string(_ text: String) -> [UInt8] { + let bytes = Array(text.utf8) + // 长度 29 及以上时,控制字节写 29,后面跟一个字节存“长度 - 29” + let header: [UInt8] = bytes.count < 29 ? [UInt8(2 << 5) | UInt8(bytes.count)] : [UInt8(2 << 5) | 29, UInt8(bytes.count - 29)] + return header + bytes + } + func map(_ pairs: [(String, [UInt8])]) -> [UInt8] { + [UInt8(7 << 5) | UInt8(pairs.count)] + pairs.flatMap { string($0.0) + $0.1 } + } + func uint32(_ value: UInt32) -> [UInt8] { + [UInt8(6 << 5) | 4] + withUnsafeBytes(of: value.bigEndian) { Array($0) } + } + func uint16(_ value: UInt16) -> [UInt8] { + [UInt8(5 << 5) | 2] + withUnsafeBytes(of: value.bigEndian) { Array($0) } + } + + // 数据段开头先放一个字符串,记录里用指针(类型 1,偏移 0)引用它 + let sharedName = string("中国") + let pointerToName: [UInt8] = [0x20, 0x00] + let record = map([ + ("country", map([("iso_code", string("CN")), ("names", map([("zh-CN", pointerToName), ("en", string("China"))]))])), + ("autonomous_system_number", uint32(4134)), + ("autonomous_system_organization", string("CHINANET")), + ]) + let dataSection = sharedName + record + let nodeCount = 1 + let recordValue = nodeCount + 16 + sharedName.count // 数据段中记录的偏移 + 节点数 + 16 + let tree: [UInt8] = [UInt8(recordValue >> 16), UInt8(recordValue >> 8 & 0xFF), UInt8(recordValue & 0xFF), 0, 0, 1] + let metadata = map([ + ("node_count", uint32(UInt32(nodeCount))), + ("record_size", uint16(24)), + ("ip_version", uint16(4)), + ("database_type", string("Test-City")), + ("build_epoch", uint32(1_789_000_000)), + ]) + let marker: [UInt8] = [0xAB, 0xCD, 0xEF] + Array("MaxMind.com".utf8) + return Data(tree + [UInt8](repeating: 0, count: 16) + dataSection + marker + metadata) + } + + @Test func readsMetadata() throws { + let database = try MaxMindDatabase(data: makeDatabase()) + #expect(database.databaseType == "Test-City") + #expect(database.ipVersion == 4) + #expect(database.buildDate == Date(timeIntervalSince1970: 1_789_000_000)) + } + + @Test func looksUpAddressesThroughTheTree() throws { + let database = try MaxMindDatabase(data: makeDatabase()) + let record = try #require(database.lookup("1.2.3.4")) + #expect(record["country"]?["iso_code"]?.string == "CN") + #expect(record["autonomous_system_number"]?.integer == 4134) + #expect(database.lookup("200.1.1.1") == nil) + #expect(database.lookup("not an ip") == nil) + #expect(database.lookup("2001:db8::1") == nil) // IPv4 库不含 IPv6 + } + + @Test func followsPointersAndPrefersChineseNames() throws { + let database = try MaxMindDatabase(data: makeDatabase()) + let location = try #require(GeoLookup.locate("8.8.8.8", location: database, asn: database)) + #expect(location.countryName == "中国") + #expect(location.countryCode == "CN") + #expect(location.asn == "AS4134") + #expect(location.organization == "CHINANET") + } + + @Test func rejectsFilesWithoutMetadata() { + #expect(throws: MaxMindDatabase.Error.missingMetadata) { try MaxMindDatabase(data: Data(repeating: 0, count: 64)) } + } +} diff --git a/README.md b/README.md index db6555b..8b66e20 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,10 @@ Developed and tested on Apple Silicon. The interface is in Simplified Chinese. -Not released yet · **44** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese) +Not released yet · **45** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
-2026-09-13 · Unreleased · 29 added · 9 style · 6 fixed +2026-09-13 · Unreleased · 30 added · 9 style · 6 fixed **Added** @@ -65,6 +65,7 @@ Not released yet · **44** changes in development · [full changelog](CHANGELOG. - CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。 - 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。 - 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。 +- IP 归属地改为本地 MaxMind GeoLite2 数据库查询:自己实现 .mmdb 读取器(不依赖第三方库),国家、城市与 ASN 完全离线查;数据库由官网服务器每周二、周五用 MaxMind 账号同步,应用每 3 天检查清单、下载并按 sha256 校验后替换,也可以从文件导入;设置中可选是否包含城市库(约 60 MB);数据库就绪前暂用 ipinfo.io 在线查询,网络详情里标明数据来源。 - 本机信息页:机型图与名称、系统版本与版号、芯片与各类核心、内存与图形、存储用量、电池健康与循环次数、每台显示器的尺寸 / 原生分辨率 / 显示分辨率 / 刷新率、机型标识符、序列号(默认遮住,点眼睛图标显示,截图时始终遮住)与启动时间。 - 进程管理器(主窗口“进程”页):包括 root 与其他用户的系统进程(通过系统自带的 ps 读取,不需要辅助工具);列出 CPU、CPU 时间、内存、线程、唤醒次数、磁盘读写与用户,点列标题排序;可搜索名称、PID 或用户,筛选全部 / 我的 / 系统,切换按进程或按应用合并;选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。 - 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。 @@ -222,8 +223,10 @@ There is no analytics and no telemetry. OpenStats only touches the network for two optional features, both of which can be turned off in Settings → Network: -- **Public IP**: when you open network details, one request to `ipinfo.io` for your public address, - region and ASN (IPv6 and fallback via Cloudflare `1.1.1.1` / ipify), cached for 10 minutes. +- **Public IP**: when you open network details, one request to Cloudflare `1.1.1.1` (ipify as fallback) + for your public address, cached for 10 minutes. Region and ASN are looked up on the Mac in a MaxMind + GeoLite2 database downloaded from `getopenstats.com` and verified by sha256; until it is downloaded, + `ipinfo.io` answers instead. - **Connection probe**: an ICMP ping to the target you pick (Cloudflare, Google, Alibaba Cloud, Tencent or your router) every 1, 2 or 5 seconds, only while the network item is in the menu bar or network details are open. diff --git a/README.zh-CN.md b/README.zh-CN.md index 97621fa..87d7c5e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -44,10 +44,10 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/ -尚未发布正式版本 · 开发中 **44** 项改动尚未发布 · [完整更新日志](CHANGELOG.md) +尚未发布正式版本 · 开发中 **45** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
-2026-09-13 · 未发布 · 新增 29 · 样式 9 · 修复 6 +2026-09-13 · 未发布 · 新增 30 · 样式 9 · 修复 6 **新增** @@ -63,6 +63,7 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/ - CPU 详情:顶部是占用、状态(空闲 / 适中 / 繁忙 / 满载)、与 30 秒前相比的变化和温度余量,下面是走势线与用户 / 系统 / 空闲构成条;核心热力图(每行一个核心、每列一次采样);核心分工(各类核心平均占用与最忙的核心);排队程度(平均负载折算到每个核心,并提示负载在上升还是下降);按应用汇总 CPU。 - 内存详情:顶部是还可用多少、压力状态与最近 60 秒的压力走势条;内存水位条(App / 联动 / 压缩 / 缓存 / 空闲);压缩与交换(压缩省下的内存、压缩比、交换区用量与实时换入换出速率,持续写盘时提示);按应用汇总内存(合并辅助进程,显示占已用内存的比例)。 - 用 Apple 智能解释进程:在进程上右键或点进程页的星形按钮,由系统自带的本机大模型说明它是什么、占用是否正常、能否退出;把路径、所属应用和签名方一起交给模型以减少猜测,全程不联网(需要 macOS 26 并开启 Apple 智能)。 +- IP 归属地改为本地 MaxMind GeoLite2 数据库查询:自己实现 .mmdb 读取器(不依赖第三方库),国家、城市与 ASN 完全离线查;数据库由官网服务器每周二、周五用 MaxMind 账号同步,应用每 3 天检查清单、下载并按 sha256 校验后替换,也可以从文件导入;设置中可选是否包含城市库(约 60 MB);数据库就绪前暂用 ipinfo.io 在线查询,网络详情里标明数据来源。 - 本机信息页:机型图与名称、系统版本与版号、芯片与各类核心、内存与图形、存储用量、电池健康与循环次数、每台显示器的尺寸 / 原生分辨率 / 显示分辨率 / 刷新率、机型标识符、序列号(默认遮住,点眼睛图标显示,截图时始终遮住)与启动时间。 - 进程管理器(主窗口“进程”页):包括 root 与其他用户的系统进程(通过系统自带的 ps 读取,不需要辅助工具);列出 CPU、CPU 时间、内存、线程、唤醒次数、磁盘读写与用户,点列标题排序;可搜索名称、PID 或用户,筛选全部 / 我的 / 系统,切换按进程或按应用合并;选中后显示路径与详情,可在访达中显示、用 Apple 智能解释、退出或强制退出(二次确认,系统进程与 loginwindow 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。 - 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。 @@ -201,7 +202,7 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/ 只有两项可选功能会联网,都可以在“设置 → 网络”中关闭: -- **公网 IP**:打开网络详情时向 `ipinfo.io` 请求一次公网地址、归属地与 ASN(IPv6 与回退使用 Cloudflare `1.1.1.1` / ipify),10 分钟内不重复请求。 +- **公网 IP**:打开网络详情时向 Cloudflare `1.1.1.1`(回退 ipify)请求一次公网地址,10 分钟内不重复请求。归属地与 ASN 在本机的 MaxMind GeoLite2 数据库里查询;数据库从 `getopenstats.com` 下载并按 sha256 校验,下载前才临时改用 `ipinfo.io` 在线查询。 - **连接探测**:每 1、2 或 5 秒向你选择的目标(Cloudflare、Google、阿里云、腾讯或路由器)发送一次 ICMP ping,只在菜单栏显示网络项或打开网络详情时运行。 用 Apple 智能解释进程完全在本机完成,进程信息不会离开这台 Mac。 diff --git a/Scripts/deploy_site.sh b/Scripts/deploy_site.sh index 271ba68..1850698 100755 --- a/Scripts/deploy_site.sh +++ b/Scripts/deploy_site.sh @@ -24,7 +24,8 @@ echo "内容指纹 v=$STAMP" # 目录归部署用户所有,Caddy 只需读取 "${SSH[@]}" "$HOST" "sudo mkdir -p $ROOT && sudo chown -R \$(id -un):\$(id -gn) $ROOT" -rsync -az --delete -e "${SSH[*]}" web/ "$HOST:$ROOT/" +# geoip/ 由服务器上的定时任务生成,不在 web/ 里——排除掉,否则 --delete 会把数据库删了 +rsync -az --delete --exclude /geoip/ -e "${SSH[*]}" web/ "$HOST:$ROOT/" "${SSH[@]}" "$HOST" "sudo chmod -R a+rX $ROOT" echo "已同步到 $HOST:$ROOT" diff --git a/ThirdPartyNotices.md b/ThirdPartyNotices.md index f25f042..e21edc6 100644 --- a/ThirdPartyNotices.md +++ b/ThirdPartyNotices.md @@ -1,5 +1,13 @@ # 第三方声明 +## MaxMind GeoLite2 + +IP 归属地与 ASN 查询使用 MaxMind 的 GeoLite2 数据库(不随源码分发,由官网服务器定期同步后供应用下载)。 + +This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com. + +`Packages/OpenStatsKit/Sources/Metrics/MaxMindDatabase.swift` 按 MaxMind DB 文件格式规范 2.0 自行实现,未使用第三方库。 + ## flag-icons `Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Flags/` 中的国旗 SVG 来自 diff --git a/server/geoip/install.sh b/server/geoip/install.sh new file mode 100755 index 0000000..63f2b3c --- /dev/null +++ b/server/geoip/install.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# 把 GeoLite2 更新脚本与定时任务装到服务器(不包含 License Key)。 +# ./server/geoip/install.sh +set -euo pipefail +cd "$(dirname "$0")" +HOST="${SITE_HOST:-debian@15.204.80.137}" +KEY="${SITE_KEY:-$HOME/.ssh/gentpan.pem}" +SSH=(ssh -i "$KEY" -o BatchMode=yes "$HOST") + +scp -i "$KEY" -o BatchMode=yes openstats-geoip-update.sh openstats-geoip.service openstats-geoip.timer "$HOST:/tmp/" +"${SSH[@]}" 'set -e + sudo install -m 755 /tmp/openstats-geoip-update.sh /usr/local/bin/openstats-geoip-update + sudo install -m 644 /tmp/openstats-geoip.service /tmp/openstats-geoip.timer /etc/systemd/system/ + sudo install -d -m 755 /var/www/getopenstats.com/geoip + sudo install -d -m 700 /etc/openstats + sudo systemctl daemon-reload + sudo systemctl enable --now openstats-geoip.timer + rm -f /tmp/openstats-geoip-update.sh /tmp/openstats-geoip.service /tmp/openstats-geoip.timer + systemctl list-timers openstats-geoip.timer --no-pager | head -3 + if sudo test -f /etc/openstats/maxmind.env; then echo "已找到 License Key 配置"; else echo "还没有 /etc/openstats/maxmind.env"; fi' diff --git a/server/geoip/openstats-geoip-update.sh b/server/geoip/openstats-geoip-update.sh new file mode 100755 index 0000000..7c191e4 --- /dev/null +++ b/server/geoip/openstats-geoip-update.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# 在服务器上定时下载 MaxMind GeoLite2 数据库,放到官网 /geoip/ 下供 OpenStats 更新。 +# +# 账号与 License Key 只放在 /etc/openstats/maxmind.env(root 所有,权限 600), +# 不进仓库、不进应用。文件内容: +# MAXMIND_ACCOUNT_ID=你的账号 +# MAXMIND_LICENSE_KEY=你的 Key +# +# 每个库先下载 tar.gz 与官方 sha256 校验,解出 .mmdb 后原子替换,最后重写 manifest.json。 +set -euo pipefail + +ENV_FILE="${ENV_FILE:-/etc/openstats/maxmind.env}" +OUT="${OUT:-/var/www/getopenstats.com/geoip}" +EDITIONS=(${EDITIONS:-GeoLite2-ASN GeoLite2-Country GeoLite2-City}) + +[ -r "$ENV_FILE" ] || { echo "缺少 $ENV_FILE" >&2; exit 1; } +# shellcheck disable=SC1090 +source "$ENV_FILE" +: "${MAXMIND_ACCOUNT_ID:?}" "${MAXMIND_LICENSE_KEY:?}" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$OUT" + +fetch() { + # 凭据通过 curl 配置从标准输入传入,不出现在进程参数里 + printf 'user = "%s:%s"\n' "$MAXMIND_ACCOUNT_ID" "$MAXMIND_LICENSE_KEY" \ + | curl --config - -fsSL --retry 3 -o "$2" "https://download.maxmind.com/geoip/databases/$1/download?suffix=$3" +} + +entries=() +for edition in "${EDITIONS[@]}"; do + fetch "$edition" "$WORK/$edition.tar.gz" "tar.gz" + fetch "$edition" "$WORK/$edition.tar.gz.sha256" "tar.gz.sha256" + expected="$(cut -d' ' -f1 "$WORK/$edition.tar.gz.sha256")" + actual="$(sha256sum "$WORK/$edition.tar.gz" | cut -d' ' -f1)" + [ "$expected" = "$actual" ] || { echo "$edition 校验失败" >&2; exit 1; } + + tar -xzf "$WORK/$edition.tar.gz" -C "$WORK" + folder="$(find "$WORK" -maxdepth 1 -type d -name "${edition}_*" | sort | tail -1)" + build="${folder##*_}" # 目录名形如 GeoLite2-ASN_20260912 + install -m 644 "$folder/$edition.mmdb" "$OUT/.$edition.mmdb.new" + mv -f "$OUT/.$edition.mmdb.new" "$OUT/$edition.mmdb" + + sha="$(sha256sum "$OUT/$edition.mmdb" | cut -d' ' -f1)" + size="$(stat -c %s "$OUT/$edition.mmdb")" + entries+=("{\"edition\":\"$edition\",\"file\":\"$edition.mmdb\",\"build\":\"$build\",\"sha256\":\"$sha\",\"size\":$size}") + echo "$edition $build $size 字节" +done + +joined="$(IFS=,; echo "${entries[*]}")" +printf '{"generated":"%s","attribution":"This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com.","databases":[%s]}\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$joined" > "$OUT/.manifest.json.new" +mv -f "$OUT/.manifest.json.new" "$OUT/manifest.json" +chmod 644 "$OUT/manifest.json" +echo "manifest.json 已更新" diff --git a/server/geoip/openstats-geoip.service b/server/geoip/openstats-geoip.service new file mode 100644 index 0000000..1369b2e --- /dev/null +++ b/server/geoip/openstats-geoip.service @@ -0,0 +1,13 @@ +[Unit] +Description=下载 MaxMind GeoLite2 数据库供 OpenStats 更新 +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/openstats-geoip-update +# 只需要写官网的 geoip 目录 +ProtectSystem=strict +ReadWritePaths=/var/www/getopenstats.com/geoip /tmp +PrivateTmp=true +NoNewPrivileges=true diff --git a/server/geoip/openstats-geoip.timer b/server/geoip/openstats-geoip.timer new file mode 100644 index 0000000..06060c9 --- /dev/null +++ b/server/geoip/openstats-geoip.timer @@ -0,0 +1,10 @@ +[Unit] +Description=每周二、周五更新 GeoLite2(MaxMind 在这两天发布新数据) + +[Timer] +OnCalendar=Tue,Fri *-*-* 08:00:00 UTC +RandomizedDelaySec=30m +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/web/index.html b/web/index.html index 94fb3c6..5ebb5da 100644 --- a/web/index.html +++ b/web/index.html @@ -315,7 +315,8 @@
  • 指标来源内核接口(host_processor_info、host_statistics64、sysctl)、IOKit 与 SMC
  • 账号不需要,也没有
  • 统计上报没有
  • -
  • 公网 IP打开网络详情时向 ipinfo.io 查询一次地址、归属地与 ASN,10 分钟内不重复,可关闭
  • +
  • 公网 IP打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可关闭
  • +
  • 归属地国家、城市与 ASN 在本机的 MaxMind GeoLite2 数据库里查,数据库从本站下载并校验
  • 连接探测按设定间隔 ping 你选择的目标,只在显示网络项时运行,可关闭
  • 偏好设置保存在本机,不含个人信息
  • 源代码MIT 开源,全部公开
  • @@ -347,7 +348,7 @@
    未发布
    -

    新增 29 · 样式 9 · 修复 6

    +

    新增 30 · 样式 9 · 修复 6

    • 新增菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。
    • 新增菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。
    • @@ -358,7 +359,7 @@
    • 新增网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。
    • 新增DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。
    - 还有 36 项 → + 还有 37 项 →
    @@ -379,7 +380,7 @@
    会联网吗? -

    只有两项可关闭的功能会:打开网络详情时向 ipinfo.io 查询一次公网地址、归属地与 ASN(10 分钟内不重复);连接探测按你设定的间隔 ping 你选择的目标。没有账号,没有统计上报。

    +

    只有几项可关闭的功能会:打开网络详情时向 Cloudflare 查询一次公网地址(10 分钟内不重复);连接探测按你设定的间隔 ping 你选择的目标;每 3 天从本站检查 IP 归属地数据库的更新。归属地与 ASN 在本机查询。没有账号,没有统计上报。

    为什么不上 App Store?