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/
- 新增读取器单元测试;更新隐私说明、第三方声明与架构文档
This commit is contained in:
gentpan committed 2026-09-13 06:40:05 +05:00
1 parent 3243570d53
commit 0342074489
21 files changed
+827 -19

No files matched your search

+7 -2
View File
@@ -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
+1
View File
@@ -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 等禁止结束);底部汇总用户 / 系统 / 空闲与全系统进程数、线程数。
- 进程显示应用的本地化名称(如“微信”);按应用汇总时,同一应用的主进程与辅助进程合并计算。
@@ -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..<data.count) else {
throw Error.missingMetadata
}
var decoder = Decoder(data: data, pointerBase: marker.upperBound)
guard case .map(let metadata)? = try? decoder.decode(at: marker.upperBound).value else {
throw Error.invalidMetadata("元数据不是字典")
}
guard let nodes = metadata["node_count"]?.integer, let record = metadata["record_size"]?.integer,
[24, 28, 32].contains(record), let version = metadata["ip_version"]?.integer else {
throw Error.invalidMetadata("缺少 node_count / record_size / ip_version")
}
nodeCount = nodes
recordSize = record
ipVersion = version
databaseType = metadata["database_type"]?.string ?? ""
buildDate = Date(timeIntervalSince1970: TimeInterval(metadata["build_epoch"]?.integer ?? 0))
treeSize = (record * 2 / 8) * nodes
dataSectionStart = treeSize + 16
guard dataSectionStart <= data.count else { throw Error.corrupt }
// IPv6 库里 IPv4 地址位于 ::/96 之下,预先走完前 96 个 0 位
var node = 0
if version == 6 {
for _ in 0..<96 where node < nodes {
node = Self.readRecord(data: data, node: node, bit: 0, recordSize: record)
}
}
ipv4Start = node
}
/// 查询 IP,返回该地址对应的记录;不在库中时返回 nil
public func lookup(_ address: String) -> 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..<bitCount {
guard node < nodeCount else { break }
let bit = Int((bytes[index >> 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..<size {
let key = try decode(at: position)
guard case .string(let name) = key.value else { throw Error.corrupt }
let value = try decode(at: key.next)
map[name] = value.value
position = value.next
}
return (.map(map), position)
case 8:
let raw = try slice(&position, count: size)
let unsigned = UInt32(truncatingIfNeeded: UInt64(bigEndianBytes: raw))
return (.signed(Int64(Int32(bitPattern: unsigned))), position)
case 11:
var array: [Value] = []
array.reserveCapacity(size)
for _ in 0..<size {
let element = try decode(at: position)
array.append(element.value)
position = element.next
}
return (.array(array), position)
case 14:
return (.boolean(size != 0), position)
case 15:
guard size == 4 else { throw Error.corrupt }
let raw = try slice(&position, count: 4)
return (.double(Double(Float(bitPattern: UInt32(truncatingIfNeeded: UInt64(bigEndianBytes: raw))))), position)
default:
throw Error.corrupt
}
}
private func byte(_ position: inout Int) throws -> 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<Bytes: Collection>(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
}
}
@@ -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()
@@ -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
@@ -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: "查询已关闭")
}
@@ -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 {
@@ -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)
}
/// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项
@@ -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"
}
}
@@ -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 }
}
}
@@ -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
@@ -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)) }
}
}
+7 -4
View File
@@ -46,10 +46,10 @@ Developed and tested on Apple Silicon. The interface is in Simplified Chinese.
<!-- changelog:start -->
<!-- Generated from CHANGELOG.md by Scripts/sync_changelog.py. Do not edit by hand. -->
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)
<details open>
<summary><b>2026-09-13</b> · Unreleased · 29 added · 9 style · 6 fixed</summary>
<summary><b>2026-09-13</b> · Unreleased · 30 added · 9 style · 6 fixed</summary>
**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.
+4 -3
View File
@@ -44,10 +44,10 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
<!-- changelog:start -->
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
尚未发布正式版本 · 开发中 **44** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
尚未发布正式版本 · 开发中 **45** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
<details open>
<summary><b>2026-09-13</b> · 未发布 · 新增 29 · 样式 9 · 修复 6</summary>
<summary><b>2026-09-13</b> · 未发布 · 新增 30 · 样式 9 · 修复 6</summary>
**新增**
@@ -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。
+2 -1
View File
@@ -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"
+8
View File
@@ -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 来自
+20
View File
@@ -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'
+56
View File
@@ -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 已更新"
+13
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=每周二、周五更新 GeoLite2(MaxMind 在这两天发布新数据)
[Timer]
OnCalendar=Tue,Fri *-*-* 08:00:00 UTC
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
+5 -4
View File
@@ -315,7 +315,8 @@
<li><b>指标来源</b><span>内核接口(host_processor_info、host_statistics64、sysctl)、IOKit 与 SMC</span></li>
<li><b>账号</b><span>不需要,也没有</span></li>
<li><b>统计上报</b><span>没有</span></li>
<li><b>公网 IP</b><span>打开网络详情时向 ipinfo.io 查询一次地址、归属地与 ASN,10 分钟内不重复,可关闭</span></li>
<li><b>公网 IP</b><span>打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可关闭</span></li>
<li><b>归属地</b><span>国家、城市与 ASN 在本机的 MaxMind GeoLite2 数据库里查,数据库从本站下载并校验</span></li>
<li><b>连接探测</b><span>按设定间隔 ping 你选择的目标,只在显示网络项时运行,可关闭</span></li>
<li><b>偏好设置</b><span>保存在本机,不含个人信息</span></li>
<li><b>源代码</b><span><a href="https://github.com/gentpan/OpenStats">MIT 开源,全部公开</a></span></li>
@@ -347,7 +348,7 @@
<div class="log">
<article class="log__day">
<div class="log__head"><time datetime="2026-09-13">2026-09-13</time><span class="log__tag">未发布</span></div>
<p class="log__counts">新增 29 · 样式 9 · 修复 6</p>
<p class="log__counts">新增 30 · 样式 9 · 修复 6</p>
<ul class="log__list">
<li><span class="log__kind">新增</span>菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。</li>
<li><span class="log__kind">新增</span>菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。</li>
@@ -358,7 +359,7 @@
<li><span class="log__kind">新增</span>网络详情:上下镜像的流量历史;连接探测格子(每 1 / 2 / 5 秒 ping 一次 Cloudflare、Google、阿里云、腾讯或路由器,显示延迟、抖动、丢包);接口、物理地址、Wi-Fi 信号与速率、VPN / 代理;本地 IPv4 / IPv6、路由器、公网 IPv4 / IPv6,点击即可拷贝;归属地显示 SVG 国旗、城市、ASN 与网络运营方;各进程上传下载速率。</li>
<li><span class="log__kind">新增</span>DNS:显示正在使用的 DNS 与配置方式,一键刷新 DNS 缓存,一键切换为自动、Cloudflare、Google、腾讯 DNSPod、阿里云,或手动填写地址(逐个校验为 IPv4 / IPv6)。流量经过 VPN / 代理时提示 DNS 可能被接管。</li>
</ul>
<a class="log__more" href="https://github.com/gentpan/OpenStats/blob/main/CHANGELOG.md">还有 36 项 →</a>
<a class="log__more" href="https://github.com/gentpan/OpenStats/blob/main/CHANGELOG.md">还有 37 项 →</a>
</article>
</div>
<!-- changelog:end -->
@@ -379,7 +380,7 @@
</details>
<details class="faq">
<summary>会联网吗?</summary>
<p>只有两项可关闭的功能会:打开网络详情时向 ipinfo.io 查询一次公网地址、归属地与 ASN(10 分钟内不重复);连接探测按你设定的间隔 ping 你选择的目标。没有账号,没有统计上报。</p>
<p>只有几项可关闭的功能会:打开网络详情时向 Cloudflare 查询一次公网地址(10 分钟内不重复);连接探测按你设定的间隔 ping 你选择的目标;每 3 天从本站检查 IP 归属地数据库的更新。归属地与 ASN 在本机查询。没有账号,没有统计上报。</p>
</details>
<details class="faq">
<summary>为什么不上 App Store?</summary>