fix: 发布 0.6.7,修复历史重算反复搬移 / 降级、识别库自动登记误判、手动深色主题配色重叠(debug13、debug14 复查)

- 重算只处理今天以前的数据:0.6.6 每次自动重算都把当天累计字节再搬一遍
- 重算只升级不降级:不再把实时按 IP 段 / 端口认出的应用改回 HTTPS、把 HTTPS 改成其他
- 自动重算按识别库变化或过零点触发,不再看 DNS 缓存行数;启动 15 分钟后才首跑
- 上游新目录下载后按现有识别库投票定归属(并入已有应用 / 建该类应用),名字整词匹配兜底;跳过公司级大杂烩和广告隐私列表
- 自动登记的新应用无需重启生效
- 手动深色主题换成 17 色;操作日志脱敏 AdGuard 密码

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Su YijunandClaude Opus 5.5 committed 2026-10-25 02:27:57 +08:00
1 parent 636281f492
commit f2d4f4145a
14 files changed
+491 -163

No files matched your search

+12
View File
@@ -1,5 +1,17 @@
# Changelog
## [0.6.7] - 2026-10-25
修复 0.6.5 / 0.6.6 的历史重算和识别库自动登记(debug13、debug14 复查)
- **自动重算不再反复搬走今天的流量**:今天的行每分钟落库都会把应用改回实时分类、字节继续累加,0.6.6 每次自动重算都把当天累计的字节再搬一遍(实测 3 次后某应用当天流量被搬空)。现在重算(含手动按钮)只处理今天以前的数据;今天的流量本来就按最新规则实时统计,过了零点自动补算。顺带避免了重算与落库同时写同一行。
- **重算只升级、不降级**:历史行只存了域名或 IP,没有端口、目标 IP、sing-box 规则这些实时信号,0.6.6 用 `0.0.0.0:443`(域名行)和端口 0(IP 行)重跑,会把实时按 IP 段认出的应用改成 HTTPS、把 HTTPS 改成「其他」,反而抬高「其他 / 未识别」。现在新结果是笼统分类(HTTPS / 国内网站 / 国内网络 / 未识别 / CDN)时不搬;原本是笼统分类的认出具体应用才搬;原本已是具体应用的,只有域名规则直接命中另一个应用时才改。
- **自动重算的触发条件**:不再看 DNS 缓存行数(它几乎时刻在变,0.6.6 等于每 6 小时全量重算一次 31 天),改为识别库版本 / 规模变化或过了零点;启动后等 15 分钟再跑第一次,不会用还没加载完的识别库去算历史。
- **上游新目录改为“投票”定归属**:0.6.6 按目录名子串猜类别,会把 Facebook 认成银行(含 ceb)、Stripe 认成旅行、HeroesoftheStorm 认成代理,还会把 Tencent、Google、Alibaba 这类公司级大杂烩和 AdGuardSDNSFilter、EasyPrivacy 等广告 / 隐私列表登记成应用。现在新目录先下载,再拿它的域名和 IP 段问现有识别库:多数属于同一个已有应用就并入(如 Facebook、TelegramSG),多数属于同一大类就建该类应用,都不够才按目录名猜(按整词匹配),猜不出归「常用网站」;公司级大杂烩和广告 / 隐私 / 拦截 / 纯 IP 汇总列表不登记。Hagezi / UT1 新列表名字能明确猜出类别才登记,下载后投票明显不符的作废;登记的列表并到该类的「其他 xx 网站」,不多建应用。
- 自动登记的新应用不用重启即生效(0.6.6 要等重启或改自定义规则后分类器才认得)。登记结果在 `data/lists/bm7/rules_auto.yaml`。
- **手动选「深色」主题时 DPI 大类配色仍是旧的 9 色循环**(0.6.5 只改了浅色和跟随系统的深色),HTTP协议 与 流量代理 等 8 对同色;改为同一套 17 色。
- 操作日志不再明文记录 AdGuard Home 密码。
## [0.6.6] - 2026-10-25
识别库智能化:上游更新自动建规则库、自动重算历史;修复 AdGuard Home 读到 0 条(debug14)
+3
View File
@@ -8,6 +8,9 @@ Source & docs: https://github.com/suyijun8182/ros-dpi
## What's new
- **0.6.7**: fixes for the 0.6.5 / 0.6.6 history reclassification and rule auto-registration. Reclassification now only touches days before today (0.6.6 kept re-moving today's bytes on every run because the live writer resets each row's app every minute) and only upgrades vague rows (HTTPS / Chinese websites / unknown / CDN) to a specific app — it no longer downgrades apps that were identified live by IP range, port or ASN. It runs when a rule library changes or after midnight, starting 15 minutes after boot. New upstream blackmatrix7 directories are downloaded first and then assigned by a vote of the existing libraries (merge into an existing app, or create an app in the majority group), falling back to whole-word name matching; company-wide bundles (Tencent, Google…) and ad / privacy lists are skipped. Also: the manual dark theme now uses the 17 distinct group colors, and the AdGuard Home password is masked in the operation log.
- **0.6.6**: AdGuard Home's newer query-log format (`{"data": [...]}`) is now read (0.6.5 always got 0 answers); new upstream rule directories / lists are registered automatically; history reclassification looks up the DNS cache for IP-only rows and runs automatically after library updates.
- **0.6.5**: AdGuard Home as an external DNS source (for double-NAT setups where the router can't see downstream DNS); ASN ranges for Chinese first-party content providers (ByteDance, Bilibili, iQIYI, miHoYo, JD, Taobao, NetEase…); "Reclassify history" on the Rules page re-assigns the last 31 days with current rules; 17 distinct DPI group colors.
- **0.6.4**: app-group colors now match WayOS's DPI pie (ECharts' 9 default colors cycled over the 17 groups), and the traffic-mix pie is drawn in the fixed group order like WayOS; the rule-library panel is grouped by tier (apps by domain, apps by IP, categories by domain, fallbacks) with a one-line explanation per tier; the four official IP-range feeds are merged into one row.
- **0.6.3**: app groups now follow WayOS's 17 DPI classes (HTTP, online games, video/TV, P2P, common protocols, messaging, music, stock trading, VoIP, proxies, databases, mobile apps, special protocols, mining pools, PCDN, other, unknown), always listed in that order; new categories HTTPS/QUIC, databases (by port), mining pools and PCDN. Category ids are unchanged, so history is kept; saved analysis settings and group-based audit rules are migrated automatically. On the Applications page, "Details" now opens a WayOS-style "DPI protocol details" dialog that drills down group → category → app.
- **0.6.2**: fixes external sources (sing-box / MosDNS) showing "not enabled" after 0.6.0 — a broken upgrade backfill aborted startup initialisation, so saved sing-box / MosDNS settings, LAN ranges and custom rules were not loaded after a restart; no need to re-enter anything. Three more rule sources, downloaded at runtime (not in the image): **blackmatrix7** per-app rules (GPL-2.0, weekly) add domains / IP ranges to ~460 existing apps and ~100 new apps; **Hagezi** (GPL-3.0) and **UT1** (CC BY-SA 4.0) category lists (adult, gambling, VPN/proxy, device telemetry, games, shopping, news, job search, banks…). Order and weights: app-level domain sources (built-in, v2fly, blackmatrix7) are compared by longest matching suffix, ties go to built-in > v2fly > blackmatrix7; then app-level IP (CIDR, ports, ASN); then category lists (Hagezi > UT1); then the China domain list and IP-range fallbacks. On the Tranco top-10k, the share mapped to a specific category went from 19.6% to 35.7%. New category: Gambling. Disable with `BM7_DB` / `HAGEZI_DB` / `UT1_DB=false`. Also: notification channels now match EasySub exactly — 14 channels with the same fields: Telegram (API proxy, HTTP proxy, check bot / get Chat ID), Feishu app bot, new QQ bot, Bark icon, email SSL switch, Pushplus channel, WeCom group robot **or self-built app** (CorpID / AgentId / Secret, API proxy, user / department / tag, text card, "check app"), webhook timeout and retries. The scenario (Home / Business) is now a global setting under **Settings → Scenario & network**; in Business mode the menu says "Employees" instead of "Family members", and the last scenario is cached so the menu no longer flashes the wrong label. Identification order: domain fallbacks (China domain list) are tried before IP-range fallbacks, and overlapping IP ranges use longest-prefix match (before, a small range inside a big one could hide the big one). The "Auto update" switch now also controls the ASN and fallback list downloads. Note: Feishu now uses an app bot (App ID / Secret / Chat IDs) like EasySub; an old Feishu group-webhook config has to be re-entered.
+4 -2
View File
@@ -27,7 +27,7 @@
- **日报 / 周报**和**操作日志**。
- **管控命令**:在人员或终端详情里,按应用、时段生成 RouterOS 命令(封禁应用、定时断网、限速),附撤销命令。ROS-DPI 只生成命令,不会修改路由器。
- **识别规则**:按域名后缀、IP 段、端口自定义识别规则;列出“未识别域名 Top”,一键归类;查看识别库的版本和更新状态。
- **系统设置**:接入与自检(检查 RouterOS 上的镜像规则)、外部数据源(sing-box / MosDNS)、**日志与留存**(Syslog 外发、各类数据保留天数、数据库占用)、**用户管理**(管理员可以修改一切;普通用户只读,可以改自己的密码)。
- **系统设置**:接入与自检(检查 RouterOS 上的镜像规则)、外部数据源(sing-box / MosDNS / AdGuard Home)、**日志与留存**(Syslog 外发、各类数据保留天数、数据库占用)、**用户管理**(管理员可以修改一切;普通用户只读,可以改自己的密码)。
- 顶栏功能搜索(终端、IP、MAC、应用、页面),按 / 键聚焦。中英双语,深浅色主题,手机可用。
![应用统计](docs/images/apps-light.png)
@@ -205,6 +205,8 @@ MySQL 第一次初始化需要 20~60 秒。
应用识别靠三份库:内置规则库(手工维护,随版本更新)、[v2fly/domain-list-community](https://github.com/v2fly/domain-list-community)(每天自动检查更新,自检通过后热加载)、[iptoasn.com](https://iptoasn.com) 的 IP 归属库(每天自动下载,也用来显示国家 / 地区)。三者的版本和状态在 **识别规则 → 识别库** 查看,也可以关闭自动更新。终端类型识别另外用 IEEE 公开的 MAC 厂商库(OUI,每 30 天更新)。自动更新需要容器能访问外网;文件保存在 `./data` 卷里。没有外网时设 `ASN_DB=false`、`OUI_DB=false`,其余功能不受影响。
blackmatrix7 上游新增的规则目录会自动登记:先下载,再用现有识别库给它的域名和 IP 段“投票”——多数属于某个已有应用就并入,多数属于同一大类就建该类应用,都不够才按目录名猜;公司级大杂烩(Tencent、Google 等)和广告 / 隐私列表不登记。识别库有变化或过了零点,会自动把今天以前的历史流量按新规则重算(只把 HTTPS / 国内网站 / 未识别这类笼统分类升级成具体应用,不会降级);**识别规则** 页也可以手动执行。
世界地图使用 [Natural Earth](https://www.naturalearthdata.com/)(公有领域)的简化数据。
### 环境变量
@@ -277,7 +279,7 @@ backend/.venv/bin/python scripts/simulate.py --minutes 5
- **Daily / weekly reports** and an **operation log**.
- **Control commands**: generate RouterOS commands that block apps, block internet on a schedule or set rate limits for a person or device, with undo commands. ROS-DPI never writes to the router.
- **Rules**: custom domain / CIDR / port rules, top unidentified domains, and library versions and update status.
- **Settings**: setup & self-check of the RouterOS mirror rules, external sources (sing-box / MosDNS), **logging & retention** (Syslog forwarding, retention per data type, database usage), and **users** (admins change everything; regular users are read-only).
- **Settings**: setup & self-check of the RouterOS mirror rules, external sources (sing-box / MosDNS / AdGuard Home), **logging & retention** (Syslog forwarding, retention per data type, database usage), and **users** (admins change everything; regular users are read-only).
- Chinese and English UI, light and dark themes, works on phones.
**How it works.** RouterOS exports IPFIX (`/ip traffic-flow`) for byte counts, and mirrors DNS answers plus the first packets of each HTTPS/QUIC connection via `sniff-tzsp`. The TLS / QUIC handshake carries the target domain (SNI) per connection; DNS answers map remote IPs back to the domain the client looked up. A rule library maps domains to apps. An optional read-only REST user provides hostnames, IP-to-MAC, WAN rate and LAN prefixes. Data is aggregated per minute, hour and day into MySQL.
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.6.6"
__version__ = "0.6.7"
+10 -5
View File
@@ -108,11 +108,7 @@ class Classifier:
self._add(a["id"], a.get("domains", []), [], [], first_wins=True)
for d in a.get("full", []):
self.full.setdefault(d, a["id"])
# blackmatrix7 新增的应用(规则运行时下载,见 lists.py;对应关系在 bm7_map.yaml)
from app.classify.lists import lists as bm7
for aid, a in bm7.map["apps"].items():
if aid not in self.apps and a.get("category") in self.categories:
self.apps[aid] = App(aid, str(a["name"]), str(a.get("name_en", a["name"])), a["category"])
self._sync_list_apps()
self.apps[UNKNOWN_APP] = App(UNKNOWN_APP, "其他", "Other", "unknown")
custom_nets, custom_ports = [], []
@@ -136,6 +132,14 @@ class Classifier:
self._cache: dict[tuple, str] = {}
self._ver = None
def _sync_list_apps(self):
"""blackmatrix7 新增的应用(规则运行时下载,见 lists.py;对应关系在 bm7_map.yaml,另有上游新目录自动登记的)。
识别库重新加载后也调一次:自动登记的新应用不用重启就生效"""
from app.classify.lists import lists as bm7
for aid, a in bm7.map["apps"].items():
if aid not in self.apps and a.get("category") in self.categories:
self.apps[aid] = App(aid, str(a["name"]), str(a.get("name_en", a["name"])), a["category"])
def _add(self, app_id, domains, cidrs, ports, first_wins):
for d in domains:
d = d.lower().strip(".")
@@ -181,6 +185,7 @@ class Classifier:
if ver != self._ver: # 第三方库重新加载过:之前的结果作废
self._cache.clear()
self._ver = ver
self._sync_list_apps()
key = (remote_ip, proto, port, qname, cname)
hit = self._cache.get(key)
if hit:
+200 -78
View File
@@ -5,8 +5,11 @@
- Hagezi DNS blocklists(GPL-3.0):成人、博彩、VPN/代理、社交、广告、各厂商设备遥测等专项分类列表。每天更新。
- UT1(Université Toulouse Capitole,CC BY-SA 4.0):游戏、购物、新闻、求职、银行等分类列表。每天更新。
上游出现新的目录 / 列表时自动登记成新应用(debug14):类别按目录名猜测,登记结果存在
data/lists/rules_auto.yaml,下次更新自动带上;手工映射仍在 bm7_map.yaml,优先级更高。
上游出现新的目录 / 列表时自动登记(debug14),结果存在 data/lists/bm7/rules_auto.yaml,手工映射(bm7_map.yaml)优先:
- blackmatrix7 新目录先下载,再拿它的域名去问现有识别库“投票”:多数域名属于同一个已有应用 → 并入该应用;
多数属于同一大类 → 新建该类应用;投票不够(多是没人认识的新应用)才按目录名猜类别,猜不出归「常用网站」。
公司级大杂烩(Tencent、Google…)、广告 / 隐私 / 汇总列表不登记。
- Hagezi / UT1 新列表只有列表名能明确猜出类别时才登记,下载后投票明显不符的作废。
识别顺序和权重(详见 app/classify/__init__.py):
- blackmatrix7 是应用级:和内置库 / v2fly 一起按域名匹配,匹配到的后缀更长者赢,一样长时内置库和 v2fly 优先。
@@ -25,6 +28,7 @@ import time
import urllib.parse
from array import array
from bisect import bisect_left
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
import httpx
@@ -41,41 +45,55 @@ BM7_LIST_API = "https://api.github.com/repos/blackmatrix7/ios_rule_script/conten
HAGEZI_LIST_API = "https://api.github.com/repos/hagezi/dns-blocklists/contents/wildcard"
UT1_LIST_API = "https://api.github.com/repos/olbat/ut1-blacklists/contents/blacklists"
LIST_API = {"bm7": BM7_LIST_API, "hagezi": HAGEZI_LIST_API, "ut1": UT1_LIST_API}
# bm7 的汇总 / 区域 / 安全类目录:不是单个应用,不自动登记
# bm7 的汇总 / 区域 / 安全类目录、公司级大杂烩:不是单个应用,不自动登记(同 bm7_map.yaml 开头的说明)
BM7_SKIP = {
"Global", "Proxy", "Direct", "Lan", "Reject", "AdBlock", "Advertising", "AdvertisingLite", "AntiAd",
"Global", "Proxy", "ProxyLite", "Direct", "Lan", "Reject", "AdBlock", "Advertising", "AdvertisingLite", "AntiAd",
"China", "ChinaMedia", "ChinaTest", "ChinaMax", "ChinaMaxNoIP", "ChinaMaxNoMedia", "ChinaNoMedia",
"ChinaIPs", "ChinaIPsBGP", "ChinaDNS", "DomesticIPs", "PublicDNS", "LocalDNS", "PrivateDNS", "DNS",
"GoogleDNS", "ProxyGFW", "GFW", "GreatFire", "AntiPolicy", "Hijacking", "PotentialMalware", "Safety",
"Spam", "TrackersList", "ZoneTelekom", "Classic", "3Type", "Cloud", "GlobalMedia", "IPTVOther",
"Speedtest", "MicrosoftEdge", "AllAI", "Download", "Crypto", "Cryptocurrency", "Developer", "Anime",
"AsianMedia", "Game", "Mail", "GoogleSearch", "Antutu", "Region", "TldCheap", "TldFake",
"Alibaba", "Baidu", "ByteDance", "Tencent", "Google", "Microsoft", "Apple", "Huawei", "Amazon",
"BlockHttpDNS", "SystemOTA", "STUN", "Scholar", "GlobalScholar", "UKMedia", "USMedia",
}
# 目录名 / 列表名 → 类别的猜测规则(debug14:上游新增内容自动归类;猜不出 bm7 归「常用网站」、分类列表跳过)
# 名字像广告 / 隐私 / 拦截 / 纯 IP 列表的也跳过(AdGuardSDNSFilter、EasyPrivacy、AdvertisingMiTV、AmazonIP…)
BM7_SKIP_RE = re.compile(r"^(Ad[A-Z]|Advertising|AdGuard|Easy)|Privacy|Marketing|Block|Tracker|Filter|IPs?$")
# 投票:至少这么多条被现有库认出才算数(小目录认出一半以上也算);同一应用占 ≥60% 并入该应用,同一大类占 ≥50% 定为该类
VOTE_MIN, VOTE_APP, VOTE_CAT, VOTE_SAMPLE = 5, 0.6, 0.5, 5000
# 目录名 / 列表名 → 类别(投票不够时才用)。按单词匹配:短词(<5 个字母)必须是完整的单词,
# 免得 Facebook 里的 ceb 认成银行、Minecraft 里的 mine 认成挖矿、HeroesoftheStorm 里的 tor 认成代理
GUESS = [
(r"adult|porn|nsfw|xxx", "adult"), (r"gambl|bet|casino|lotter", "gambling"),
(r"spotify|music|radio|podcast|tidal|deezer|netease_music|kugou|kuwo", "music"),
(r"(^|[-_])(live|stream)([-_]|$)|twitch|douyu|huya|yy", "live"),
(r"video|tv|movie|film|cinema|anime|manga|mgtv|youku|iqiyi|bilibili|netflix|hbo|disney|emby|iptv|cctv", "video"),
(r"game|steam|epic|blizzard|riot|xbox|playstation|nintendo|4399|9game|taptap|mihoyo", "game"),
(r"shop|taobao|tmall|jd|jingdong|pinduoduo|suning|vipshop|aliexpress|amazon|ebay|shopee|gome", "shopping"),
(r"bank|icbc|ccb|cmb|abc|psbc|boc|bocom|citic|spdb|cib|ceb|cgb|spdb|unionpay|alipay|tenpay|paypal|pay", "bank"),
(r"mail|smtp|imap|webmail", "mail"),
(r"news|media|press|bbc|cnn|reuters|nytimes", "news"),
(r"social|weibo|twitter|facebook|instagram|tiktok|douyin|reddit|forum|snapchat|linkedin|pin", "social"),
(r"wechat|weixin|whatsapp|telegram|line|slack|dingtalk|feishu|skype|zoom|meeting", "im"),
(r"openai|claude|gemini|copilot|chatgpt|anthropic|perplexity|midjourney|hf|\bai\b", "ai"),
(r"cloud|drive|pan|onedrive|dropbox|aliyundrive|quark|s3|oss", "cloud"),
(r"github|gitlab|gitcode|gitee|docker|npm|python|jetbrains|dev", "dev"),
(r"proxy|vpn|tor|gfw|warp|outline|shadowsocks|v2ray|trojan|clash", "proxy"),
(r"travel|trip|hotel|flight|airline|booking|ctrip|qunar|fliggy|12306|ticket", "travel"),
(r"edu|mooc|university|course|learn|school|xueersi|yuanfudao|zuoyebang", "education"),
(r"health|hospital|medic|pharma|doctor|clinic", "life"),
(r"food|takeaway|waimai|meituan|eleme|restaurant|recipe", "life"),
(r"cdn|static|cache|cloudfront|akamai|cloudflare|fastly|edge", "cdn"),
(r"tracker|warez|pt|download|thunder|xunlei", "download"),
(r"mine|mining|pool|nicehash", "mining"),
(r"remote|ssh|rdp|vnc|teamviewer|todesk|rustdesk|anydesk", "remote"),
(("adult", "porn", "nsfw", "xxx"), "adult"), (("gambl", "bet", "betting", "casino", "lotter"), "gambling"),
(("spotify", "music", "radio", "podcast", "tidal", "deezer", "kugou", "kuwo"), "music"),
(("live", "stream", "twitch", "douyu", "huya", "yy"), "live"),
(("video", "tv", "movie", "film", "cinema", "anime", "manga", "mgtv", "youku", "iqiyi", "bilibili", "netflix",
"hbo", "disney", "emby", "iptv", "cctv"), "video"),
(("game", "games", "steam", "epic", "blizzard", "riot", "xbox", "playstation", "nintendo", "4399", "taptap",
"mihoyo"), "game"),
(("shop", "shopping", "taobao", "tmall", "jd", "jingdong", "pinduoduo", "suning", "vipshop", "aliexpress",
"amazon", "ebay", "shopee", "gome"), "shopping"),
(("bank", "icbc", "ccb", "cmb", "abc", "psbc", "boc", "bocom", "citic", "spdb", "cib", "ceb", "cgb",
"unionpay", "alipay", "tenpay", "paypal", "pay"), "bank"),
(("mail", "smtp", "imap", "webmail"), "mail"),
(("news", "media", "press", "bbc", "cnn", "reuters", "nytimes"), "news"),
(("social", "weibo", "twitter", "facebook", "instagram", "tiktok", "douyin", "reddit", "forum", "forums",
"snapchat", "linkedin", "pinterest"), "social"),
(("wechat", "weixin", "whatsapp", "telegram", "line", "slack", "dingtalk", "feishu", "skype", "zoom",
"meeting", "chat"), "im"),
(("openai", "claude", "gemini", "copilot", "chatgpt", "anthropic", "perplexity", "midjourney", "hf", "ai"), "ai"),
(("cdn", "static", "cache", "cloudfront", "akamai", "cloudflare", "fastly", "edge"), "cdn"),
(("cloud", "drive", "pan", "onedrive", "dropbox", "aliyundrive", "quark", "s3", "oss"), "cloud"),
(("github", "gitlab", "gitcode", "gitee", "docker", "npm", "python", "jetbrains", "dev"), "dev"),
(("proxy", "vpn", "tor", "gfw", "warp", "outline", "shadowsocks", "v2ray", "trojan", "clash"), "proxy"),
(("travel", "trip", "hotel", "flight", "airline", "booking", "ctrip", "qunar", "fliggy", "12306", "ticket"),
"travel"),
(("edu", "mooc", "university", "course", "learn", "school", "xueersi", "yuanfudao", "zuoyebang"), "education"),
(("health", "hospital", "medic", "pharma", "doctor", "clinic", "food", "takeaway", "waimai", "meituan",
"eleme", "restaurant", "recipe"), "life"),
(("tracker", "warez", "pt", "download", "thunder", "xunlei"), "download"),
(("mining", "mine", "pool", "nicehash"), "mining"),
(("remote", "ssh", "rdp", "vnc", "teamviewer", "todesk", "rustdesk", "anydesk"), "remote"),
]
# 分类列表:(列表名, 归到的应用)。顺序就是同一域名在多个列表里时的优先级
@@ -183,15 +201,43 @@ def load_map() -> dict:
return {"owner": owner, "apps": m.get("apps") or {}}
def _words(name: str) -> set[str]:
"""MangoTV → {mango, tv};ICBCBank → {icbc, bank};native.tiktok → {native, tiktok}"""
return {w.lower() for w in re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+", name)}
def guess_category(name: str) -> tuple[str, bool]:
"""按目录名 / 列表名猜类别(debug14 自动登记用)。返回 (类别, 是否有把握);猜不出归「常用网站」"""
low = name.lower()
for pat, cat in GUESS:
if re.search(pat, low):
return cat, True
"""按目录名 / 列表名猜类别(debug14 自动登记、投票不够时用)。返回 (类别, 是否有把握);猜不出归「常用网站」"""
low, words = name.lower(), _words(name)
for keys, cat in GUESS:
for k in keys:
if (k in low) if len(k) >= 5 else (k in words):
return cat, True
return "site", False
def _vote(classifier, cat_of, domains: list[str], ips: list[str] = ()) -> tuple[str | None, float, str | None, float, int]:
"""拿一份新列表的域名(和 IP 段)问现有识别库:返回 (最多的应用, 占比, 最多的大类, 占比, 认出的条数)。
协议 / CDN / 未识别这类笼统结果不算票。IP 段取网络地址按 IP 段 / ASN 识别(TelegramSG 这类只有 IP 的目录)"""
from app.classify import GENERIC_APPS, UNIDENTIFIED_CATEGORIES, WEAK_PREFIXES
apps, cats = Counter(), Counter()
found = [classifier.match_domain(d) or cat_of(d) for d in domains[:VOTE_SAMPLE]]
found += [classifier.classify(ip, 6, 0, None, None) for ip in ips[:VOTE_SAMPLE]]
for aid in found:
if not aid or aid in GENERIC_APPS or aid.startswith(WEAK_PREFIXES):
continue
cat = classifier.app_info(aid).category
if cat in UNIDENTIFIED_CATEGORIES:
continue
apps[aid] += 1
cats[cat] += 1
n = sum(apps.values())
if not n:
return None, 0.0, None, 0.0, 0
(app, na), (cat, nc) = apps.most_common(1)[0], cats.most_common(1)[0]
return app, na / n, cat, nc / n, n
class Lists:
def __init__(self):
self.lock = threading.Lock()
@@ -210,9 +256,12 @@ class Lists:
if self._map is None:
base = load_map()
for aid, a in self._auto.items():
base["apps"].setdefault(aid, {"category": a["category"], "name": a["name"], "name_en": a["name_en"]})
if a.get("pending") or a.get("rejected"):
continue # 还没投票 / 投票作废的不进映射
if not a.get("extend") and not a.get("app"):
base["apps"].setdefault(aid, {"category": a["category"], "name": a["name"], "name_en": a["name_en"]})
if a.get("source") == "bm7" and a.get("dir"):
base["owner"].setdefault(a["dir"], aid)
base["owner"].setdefault(a["dir"], a.get("extend") or aid)
self._map = base
return self._map
@@ -241,14 +290,21 @@ class Lists:
def _spec(self, sid: str) -> list[tuple[str, str]]:
"""分类列表的 (列表名, 应用):手工映射在前,自动登记的追加在后面"""
base = HAGEZI if sid == "hagezi" else UT1
extra = [(a["list"], aid) for aid, a in self._auto.items() if a.get("source") == sid]
extra = [(a["list"], a.get("app") or aid) for aid, a in self._auto.items()
if a.get("source") == sid and not a.get("pending") and not a.get("rejected")]
return list(base) + extra
def _pending(self, sid: str) -> list[str]:
"""已发现、等下载后投票的目录 / 列表名"""
return [a.get("dir") or a.get("list") for a in self._auto.values() if a.get("source") == sid and a.get("pending")]
def _files(self, sid: str) -> list[tuple[str, str]]:
"""(本地文件名, 下载地址)"""
"""(本地文件名, 下载地址);待投票的也下载"""
if sid == "bm7":
return [(f"{n}.list", BM7_URL.format(urllib.parse.quote(n))) for n in sorted(self.map["owner"])]
return [(f"{n}.txt", (HAGEZI_URL if sid == "hagezi" else UT1_URL).format(n)) for n, _app in self._spec(sid)]
names = set(self.map["owner"]) | set(self._pending("bm7"))
return [(f"{n}.list", BM7_URL.format(urllib.parse.quote(n))) for n in sorted(names)]
names = [n for n, _app in self._spec(sid)] + self._pending(sid)
return [(f"{n}.txt", (HAGEZI_URL if sid == "hagezi" else UT1_URL).format(n)) for n in names]
# ---------- 加载 ----------
@staticmethod
@@ -265,11 +321,7 @@ class Lists:
d = self._dir("bm7")
for name, app in self.map["owner"].items():
got = False
for line in self._read(os.path.join(d, f"{name}.list")):
p = [x.strip() for x in line.split(",")]
if len(p) < 2 or line.startswith("#"):
continue
kind, val = p[0].upper(), p[1].lower().strip(".")
for kind, val in self._bm7_rules(os.path.join(d, f"{name}.list")):
if kind == "DOMAIN-SUFFIX" and _is_domain(val):
suffix.setdefault(val, app)
elif kind == "DOMAIN" and _is_domain(val):
@@ -285,16 +337,23 @@ class Lists:
ips.build()
return suffix, full, ips, n, len(apps)
def _bm7_rules(self, path: str):
for line in self._read(path):
p = [x.strip() for x in line.split(",")]
if len(p) < 2 or line.startswith("#"):
continue
yield p[0].upper(), p[1].lower().strip(".")
def _cat_domains(self, path: str) -> list[str]:
doms = []
for line in self._read(path):
s = line.strip().lower().lstrip("*.").strip(".")
if s and not s.startswith("#") and _is_domain(s):
doms.append(s)
return doms
def _load_cat(self, sid: str) -> list[tuple[str, list[str]]]:
out = []
for name, app in self._spec(sid):
doms = []
for line in self._read(os.path.join(self._dir(sid), f"{name}.txt")):
s = line.strip().lower().lstrip("*.").strip(".")
if s and not s.startswith("#") and _is_domain(s):
doms.append(s)
out.append((app, doms))
return out
return [(app, self._cat_domains(os.path.join(self._dir(sid), f"{name}.txt"))) for name, app in self._spec(sid)]
def _stamp(self, sid: str) -> int:
try:
@@ -348,16 +407,20 @@ class Lists:
changed = changed or ok > 0
err = f"{failed} 个文件下载失败,继续用旧数据" if failed else ""
self.state[sid] = {**self.state.get(sid, {}), "error": err}
try:
if self.resolve(): # 刚下载的新目录 / 列表投票定归属
changed = True
except Exception as e: # noqa: BLE001 投票失败下次再来,不影响本轮更新
print(f"[lists] 新规则投票失败:{type(e).__name__}: {e}", flush=True)
if changed or not self.state:
self.load()
return changed
def discover(self) -> int:
"""上游仓库出现新的规则目录 / 分类列表时自动登记成应用(debug14)。返回新登记个数。"""
"""上游仓库出现新的规则目录 / 分类列表时先登记为待定,下载后由 resolve() 投票定归属(debug14)。返回新登记个数。"""
added = self._discover_bm7() + self._discover_spec("hagezi") + self._discover_spec("ut1")
if added:
self._save_auto()
self._map = None # 让 map 重新合并,新应用 / 新目录立即生效
return added
@staticmethod
@@ -374,36 +437,95 @@ class Lists:
out.add(name)
return out
def _register(self, source: str, names: set[str], strict: bool) -> int:
@staticmethod
def _aid(source: str, name: str) -> str:
return ("bm7_" if source == "bm7" else "gen_") + re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
def _discover_bm7(self) -> int:
known = set(self.map["owner"]) | {a.get("dir") for a in self._auto.values() if a.get("source") == "bm7"}
added = 0
known = set(self.map["owner"])
for n in sorted(names):
if n in known or n in BM7_SKIP:
for n in sorted(self._list_names(LIST_API["bm7"], "dir")):
if n in known or n in BM7_SKIP or BM7_SKIP_RE.search(n):
continue
cat, confident = guess_category(n)
if strict and not confident:
continue # 分类列表猜不出类别就跳过:宁缺毋滥,等手工映射
aid = "gen_" + re.sub(r"[^a-z0-9]+", "_", n.lower()).strip("_") if source != "bm7" \
else "bm7_" + re.sub(r"[^a-z0-9]+", "_", n.lower()).strip("_")
if aid in self._auto or aid in self.map["apps"] or aid in self.map["owner"].values():
aid = self._aid("bm7", n)
if aid in self._auto or aid in self.map["apps"]:
continue
entry = {"category": cat, "name": n, "name_en": n, "source": source}
if source == "bm7":
entry["dir"] = n
else:
entry["list"] = n
self._auto[aid] = entry
known.add(n)
self._auto[aid] = {"source": "bm7", "dir": n, "pending": True}
added += 1
return added
def _discover_bm7(self) -> int:
return self._register("bm7", self._list_names(LIST_API["bm7"], "dir"), strict=False)
def _discover_spec(self, sid: str) -> int:
if sid == "hagezi":
return self._register("hagezi", self._list_names(LIST_API[sid], "file"), strict=True)
return self._register("ut1", self._list_names(LIST_API[sid], "dir"), strict=True)
kind = "file" if sid == "hagezi" else "dir"
base = HAGEZI if sid == "hagezi" else UT1
known = {n for n, _app in base} | {a.get("list") for a in self._auto.values() if a.get("source") == sid}
added = 0
for n in sorted(self._list_names(LIST_API[sid], kind)):
if n in known:
continue
cat, confident = guess_category(n)
if not confident:
continue # 分类列表猜不出类别就跳过:宁缺毋滥,等手工映射
aid = self._aid(sid, n)
if aid in self._auto or aid in self.map["apps"]:
continue
self._auto[aid] = {"source": sid, "list": n, "category": cat, "pending": True}
added += 1
return added
def resolve(self, classifier=None) -> int:
"""给已下载的待定目录 / 列表投票定归属(见模块说明)。返回定下来的个数;没下载到的留到下次"""
pend = [(aid, a) for aid, a in self._auto.items() if a.get("pending")]
if not pend:
return 0
if classifier is None:
from app.engine import engine
classifier = engine.classifier
done = 0
for aid, a in pend:
sid = a["source"]
if sid == "bm7":
path = os.path.join(self._dir("bm7"), f"{a['dir']}.list")
rules = list(self._bm7_rules(path))
doms = [v for k, v in rules if k in ("DOMAIN", "DOMAIN-SUFFIX") and _is_domain(v)]
ips = []
for k, v in rules:
if k in ("IP-CIDR", "IP-CIDR6"):
try:
ips.append(str(ipaddress.ip_network(v, strict=False).network_address))
except ValueError:
pass
else:
path = os.path.join(self._dir(sid), f"{a['list']}.txt")
doms, ips = self._cat_domains(path), []
if not os.path.exists(path):
continue
app, app_share, cat, cat_share, n = _vote(classifier, self.category, doms, ips)
# 小目录(只有几条)认出一半以上也算数
enough = n >= VOTE_MIN or (n >= 2 and n * 2 >= len(doms) + len(ips))
if sid == "bm7":
name = a["dir"]
if enough and app_share >= VOTE_APP and app in classifier.apps:
entry = {"extend": app, "by": "vote"}
elif enough and cat_share >= VOTE_CAT:
entry = {"category": cat, "name": name, "name_en": name, "by": "vote"}
else:
entry = {"category": guess_category(name)[0], "name": name, "name_en": name, "by": "name"}
self._auto[aid] = {"source": "bm7", "dir": name, **entry}
else:
name, guess = a["list"], a["category"]
if enough and cat != guess and cat_share >= VOTE_CAT:
self._auto[aid] = {"source": sid, "list": name, "rejected": True, "vote": cat}
else:
entry = {"source": sid, "list": name, "category": guess, "name": name, "name_en": name}
gen = classifier.apps.get(f"gen_{guess}")
if gen is not None and gen.category == guess:
entry["app"] = gen.id # 并到该类的“其他 xx 网站”,不多建应用
self._auto[aid] = entry
done += 1
if done:
self._save_auto()
self._map = None # 让 map 重新合并,新应用 / 新目录立即生效
return done
def _fetch_all(self, sid: str) -> tuple[int, int]:
d = self._dir(sid)
+7 -5
View File
@@ -1,6 +1,7 @@
import asyncio
import os
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from apscheduler.schedulers.background import BackgroundScheduler
from fastapi import FastAPI, Request
@@ -46,8 +47,7 @@ def _on_db_ready():
"""数据库连接成功后(启动时或安装向导保存后)执行。各步互不依赖:一步出错只记日志,不挡住后面的
(0.6.0 / 0.6.1 的主域名回填出错时,后面的外部数据源配置、自定义规则都没加载,外部数据源显示“未启用”)"""
ensure_admin() # 建管理员失败要报出来(安装向导靠这个提示)
for name, fn in (("load_state", engine.load_state), ("apply_settings", _apply_saved),
("auto_reclass", _auto_reclassify)):
for name, fn in (("load_state", engine.load_state), ("apply_settings", _apply_saved)):
try:
fn()
except Exception as e: # noqa: BLE001
@@ -72,7 +72,7 @@ def _auto_update_rules():
def _auto_reclassify():
"""识别库更新 / DNS 映射积累后自动重算历史分类(debug14):指纹没变就不动,变了才重算"""
"""识别库更新或过了零点后自动重算历史分类(debug14):指纹没变就不动,变了才重算"""
if not _auto_update_on():
return
from app.reclass import auto_reclassify
@@ -118,8 +118,10 @@ async def lifespan(app: FastAPI):
sched.add_job(_safe(integrations.singbox.poll, "singbox"), "interval", seconds=5, max_instances=1, coalesce=True)
sched.add_job(_safe(integrations.mosdns.poll, "mosdns"), "interval", seconds=5, max_instances=1, coalesce=True)
sched.add_job(_safe(integrations.adguard.poll, "adguard"), "interval", seconds=5, max_instances=1, coalesce=True)
# 识别库更新 / DNS 映射积累后的自动重算:跟在规则更新任务后面跑,指纹没变时开销只有几条计数查询
sched.add_job(_safe(_auto_reclassify, "auto-reclass"), "interval", hours=6, max_instances=1, coalesce=True)
# 识别库更新 / 过了零点后的自动重算:指纹没变时开销只有一次设置查询。启动后等 15 分钟再跑第一次,
# 第三方库、ASN、国内列表是在后台线程加载的,不能用没加载完的识别器去算历史
sched.add_job(_safe(_auto_reclassify, "auto-reclass"), "interval", hours=1, max_instances=1, coalesce=True,
next_run_time=datetime.now(sched.timezone) + timedelta(minutes=15))
sched.add_job(_safe(_auto_update_rules, "rules-update"), "interval", hours=6, max_instances=1, coalesce=True)
# 审计报表:日报每天 08:00 推送昨天的,周报每周一 08:30 推送近 7 天的(在审计 → 报表里开启)
sched.add_job(_safe(lambda: audit.push_report("daily"), "report-daily"), "cron", hour=8, minute=0)
+1 -1
View File
@@ -6,7 +6,7 @@ from fastapi import Request
from app.models import OpLog
SECRET_KEYS = {"singbox_secret", "password", "old_password", "new_password", "token", "secret"}
SECRET_KEYS = {"singbox_secret", "adguard_password", "password", "old_password", "new_password", "token", "secret"}
def client_ip(request: Request | None) -> str:
+66 -26
View File
@@ -5,23 +5,38 @@
app 标记同步改掉(保证重复执行幂等)。host_domain_day 只覆盖有域名、或进了当分钟 Top30 IP 的流量,
其余流量保持原分类;traffic_minute / traffic_hour 不动,随保留期自然滚动。
纯 IP 的历史行会用 dns_cache 反查域名再分类(嗅探 / MosDNS / AdGuard Home 积累的映射)——DNS 缓存
会过期,所以已经归到具体应用、又拿不到新域名证据的行保持不动,避免缓存过期让历史分类倒退。
只重算今天以前的数据:今天的行每分钟落库都会把 app 改回实时分类、字节继续累加,重算今天会把同一批字节
反复搬走,还会和落库争写同一行。今天的流量本来就按最新规则实时统计,过了零点由自动重算补上。
识别库更新 / DNS 映射积累后自动重算:fingerprint() 覆盖各层库的规模和 DNS 缓存行数,指纹变了才执行
(auto_reclassify),识别规则页的手动「重算历史分类」直接调 reclassify()。
历史行只存了域名(或 IP),没有端口、目标 IP、sing-box 规则这些实时信号,所以只“升级”不“降级”:
- 新结果是笼统分类(HTTPS / 国内网站 / 未识别…)的不搬,免得把实时认出的应用改回笼统分类;
- 原来就是笼统分类的,认出具体应用就搬;
- 原来已是具体应用的,只有域名规则直接命中另一个应用时才改(例如识别库把某域名改归了别的应用)。
纯 IP 的历史行用 dns_cache 反查域名(嗅探 / MosDNS / AdGuard Home 积累的映射)再按上面的规则判断。
自动重算(auto_reclassify):fingerprint() 覆盖各层库的版本 / 规模和日期,识别库变了或过了零点才执行;
识别规则页的手动「重算历史分类」直接调 reclassify()。
"""
import ipaddress
import time
from collections import defaultdict
from sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.exc import OperationalError
from app import timeutil
from app.classify import GENERIC_APPS, UNIDENTIFIED_CATEGORIES, WEAK_PREFIXES
from app.models import DnsCache, HostDomainDay, Setting, TrafficDay
FP_KEY = "reclass_fp"
# 纯 IP 行没有域名证据时,只有原本就归在这些笼统应用里的才重算(ASN / IP 段升级的直接受益者)
GENERIC_OLD = {"cn_net", "cn_web", "other", "unknown", "https", "quic", "http", "web", "site"}
# 只说明协议、地区或“是个网站”的笼统分类:可以被升级成具体应用,但重算不会把行改成它们
VAGUE_APPS = GENERIC_APPS | {"cn_net", "cn_web", "unknown"}
def _vague(classifier, app: str | None) -> bool:
if not app or app in VAGUE_APPS or app.startswith(WEAK_PREFIXES):
return True
return classifier.app_info(app).category in UNIDENTIFIED_CATEGORIES
def _is_ip(s: str) -> bool:
@@ -32,6 +47,24 @@ def _is_ip(s: str) -> bool:
return False
def _deadlock(e: Exception) -> bool:
"""MySQL 死锁(1213)或锁等待超时(1205):落库任务每分钟在写同一批表,撞上重试即可"""
s = str(e)
return "(1213" in s or "(1205" in s
def reclassify(engine, db, before: int | None = None, attempts: int = 3) -> dict:
"""重算 before(默认今天零点)以前的历史分类"""
for i in range(attempts):
try:
return _reclassify_once(engine, db, before)
except OperationalError as e:
db.rollback()
if i == attempts - 1 or not _deadlock(e):
raise
time.sleep(2 + i * 2)
def _dns_domain_map(db) -> dict[str, str]:
"""未过期的 IP→域名映射(嗅探 / MosDNS / AdGuard Home 积累的),给纯 IP 的历史行反查域名"""
now = int(time.time())
@@ -39,30 +72,38 @@ def _dns_domain_map(db) -> dict[str, str]:
return dict(rows)
def reclassify(engine, db) -> dict:
def _new_app(classifier, dom: str, old_app: str, cache: dict) -> str | None:
"""按现在的规则给一行历史重新归类;不该改时返回 None(规则见模块说明)"""
if _is_ip(dom):
qname = cache.get(dom)
new_app = classifier.classify(dom, 6, 0, qname, None)
else:
qname = dom
new_app = classifier.classify("0.0.0.0", 6, 443, dom, None)
if not new_app or new_app == old_app or _vague(classifier, new_app):
return None
if _vague(classifier, old_app):
return new_app
# 原来已是具体应用:只认域名规则的直接证据(IP 段 / ASN / 端口当时按真实目标判断,历史行里没有这些信息)
return new_app if qname and classifier.match_domain(qname) == new_app else None
def _reclassify_once(engine, db, before: int | None) -> dict:
t0 = time.time()
classifier = engine.classifier
before = timeutil.day_bucket(time.time()) if before is None else before
cache = _dns_domain_map(db)
moves: dict = defaultdict(lambda: defaultdict(int)) # (ts, host, 旧应用) -> {新应用: 字节}
marks: list[dict] = []
rows = db.execute(
select(HostDomainDay.ts, HostDomainDay.host, HostDomainDay.domain, HostDomainDay.app, HostDomainDay.bytes)
.where(HostDomainDay.ts < before)
).all()
for ts, host, dom, old_app, b in rows:
if not b:
continue
if _is_ip(dom):
qname = cache.get(dom)
if qname:
new_app = classifier.classify(dom, 6, 0, qname, None)
elif old_app in GENERIC_OLD:
new_app = classifier.classify(dom, 6, 0, None, None)
else:
continue # 已归到具体应用、又没有新的域名证据:保持不动(DNS 缓存过期不应让历史倒退)
else:
new_app = classifier.classify("0.0.0.0", 6, 443, dom, None)
new_app = new_app or "other"
if new_app != old_app:
new_app = _new_app(classifier, dom, old_app, cache)
if new_app:
moves[(int(ts), host, old_app)][new_app] += int(b)
marks.append({"ts": int(ts), "host": host, "domain": dom, "app": new_app})
if marks:
@@ -117,19 +158,18 @@ def reclassify(engine, db) -> dict:
def fingerprint(engine, db) -> str:
"""识别规则的有效指纹:任何一层库变了(v2fly 提交、第三方库规模、ASN 行数)或 DNS 映射积累变多都不一样"""
"""识别规则的有效指纹:任何一层库变了(v2fly 提交、第三方库规模、ASN 行数)或过了零点(昨天成了可重算的历史,
期间积累的 DNS 映射也一并用上)都不一样。不再用 DNS 缓存行数:它几乎时刻在变,等于每次都全量重算"""
from app.asn import asndb
from app.classify.lists import lists as extra_lists
from app.classify.updater import updater
now = int(time.time())
dns_n = db.scalar(select(func.count()).select_from(DnsCache).where(DnsCache.expires > now)) or 0
libs = "|".join(f"{r['id']}:{r.get('domains', r.get('rules', 0))}" for r in extra_lists.rows())
return "|".join([f"dns{dns_n}", f"asn{asndb.rows}", f"v2{updater.state.get('latest') or ''}",
f"apps{len(engine.classifier.apps)}", libs])
return "|".join([f"day{timeutil.day_bucket(time.time())}", f"asn{asndb.rows}",
f"v2{updater.state.get('latest') or ''}", f"apps{len(engine.classifier.apps)}", libs])
def auto_reclassify(engine, db) -> dict | None:
"""识别库更新 / DNS 映射积累后自动重算(debug14):指纹没变就什么都不做,变了才重算并记住新指纹"""
"""识别库更新或过了零点后自动重算(debug14):指纹没变就什么都不做,变了才重算并记住新指纹"""
fp = fingerprint(engine, db)
row = db.get(Setting, FP_KEY)
if row is not None and row.value == fp:
+3 -4
View File
@@ -9,7 +9,7 @@ from app.classify import Classifier
from app.engine import Engine
from app.reclass import reclassify
DAY = 1792900000 # 2026-10-25 附近
DAY = 1792900000 - 7 * 86400 # 一周前(重算只处理今天以前的数据)
HOST = "IP:192.168.8.20"
@@ -135,9 +135,8 @@ def test_reclass_moves_history():
s.add(HostDomainDay(ts=DAY, host=HOST, domain="portal.example.cn", app="cn_net", base=None, bytes=100))
s.commit()
class _C:
@staticmethod
def classify(ip, proto, port, qname, cname):
class _C(Classifier):
def classify(self, ip, proto, port, qname, cname):
if qname == "v.douyin.com":
return "douyin"
return "cn_net"
+163 -20
View File
@@ -6,11 +6,12 @@ import pytest
from app.classify import lists as L
from app.classify.lists import guess_category, lists
from app.classify import Classifier
from app.engine import Engine
from app.models import DnsCache, HostDomainDay, Setting, TrafficDay
from app.reclass import auto_reclassify, reclassify
DAY = 1792900000
DAY = 1792900000 - 7 * 86400 # 一周前(重算只处理今天以前的数据)
HOST = "IP:192.168.8.31"
@@ -34,9 +35,27 @@ def test_guess_category():
assert guess_category("RandomCorp") == ("site", False)
def test_discover_registers_new_dirs(tmp_path, monkeypatch):
def test_guess_category_whole_words():
# 0.6.6 按子串猜:Facebook 含 ceb → 银行、Minecraft 含 mine → 挖矿、HeroesoftheStorm 含 tor → 代理
assert guess_category("Facebook")[0] == "social"
assert guess_category("Minecraft")[0] != "mining"
assert guess_category("HeroesoftheStorm")[0] != "proxy"
assert guess_category("Stripe")[0] != "travel"
assert guess_category("Alphabet")[0] != "gambling"
assert guess_category("Airline")[0] == "travel"
def _write_bm7(tmp_path, name, domains):
d = tmp_path / "lists" / "bm7"
d.mkdir(parents=True, exist_ok=True)
(d / f"{name}.list").write_text("".join(f"DOMAIN-SUFFIX,{x}\n" for x in domains), encoding="utf-8")
def test_discover_then_vote(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
fake = [{"name": n, "type": "dir"} for n in ["BiliBili", "MangoTV", "RandomCorp", "Global", "HotMovies"]]
names = ["BiliBili", "FbMirror", "VideoBundle", "HotMovies", "RandomCorp", "Missing",
"Global", "Tencent", "AdGuardSDNSFilter", "EasyPrivacy", "AmazonIP"]
fake = [{"name": n, "type": "dir"} for n in names]
called = []
monkeypatch.setattr(L.httpx, "get",
lambda url, **kw: called.append(url) or (_Resp(fake) if "ios_rule_script" in url else _Resp([])))
@@ -45,19 +64,67 @@ def test_discover_registers_new_dirs(tmp_path, monkeypatch):
lists._map = None
lists._auto = {}
lists._load_auto()
added = lists.discover()
assert added == 3 # BiliBili 已映射、Global 在跳过名单;其余 3 个自动登记
m = lists.map
assert m["apps"]["bm7_mangotv"]["category"] == "video"
assert m["apps"]["bm7_randomcorp"]["category"] == "site"
assert m["owner"]["MangoTV"] == "bm7_mangotv" and m["owner"]["HotMovies"] == "bm7_hotmovies"
assert os.path.exists(lists._auto_file())
assert called and "rule/Clash" in called[0]
assert lists.discover() == 0 # 再发现一次:没有新增
# 自动登记的应用分类器认得出(和手工映射同一条管线)
from app.classify import Classifier
c = Classifier()
assert "bm7_mangotv" in c.apps and c.apps["bm7_mangotv"].category == "video"
fb = c.match_domain("facebook.com")
video = ["youtube.com", "netflix.com", "iqiyi.com", "youku.com", "mgtv.com", "hulu.com"]
assert fb and all(c.app_info(c.match_domain(x)).category == "video" for x in video)
# BiliBili 已映射;Global / Tencent / 广告隐私 / 纯 IP 列表不登记;其余先登记为待定,不进映射
assert lists.discover() == 5
assert called and "rule/Clash" in called[0]
assert "FbMirror" not in lists.map["owner"]
assert sorted(lists._pending("bm7")) == ["FbMirror", "HotMovies", "Missing", "RandomCorp", "VideoBundle"]
assert {f for f, _u in lists._files("bm7")} >= {"FbMirror.list", "Missing.list"} # 待定的也下载
_write_bm7(tmp_path, "FbMirror", [f"s{i}.facebook.com" for i in range(8)])
_write_bm7(tmp_path, "VideoBundle", [f"x.{d}" for d in video])
_write_bm7(tmp_path, "HotMovies", [f"zz{i}.example-movies.test" for i in range(8)])
_write_bm7(tmp_path, "RandomCorp", ["random-corp.test"])
assert lists.resolve(c) == 4 # Missing 没下载到:留到下次
m = lists.map
assert m["owner"]["FbMirror"] == fb # 多数域名属于已有应用:并入
assert "bm7_fbmirror" not in m["apps"]
assert m["apps"]["bm7_videobundle"]["category"] == "video" # 多数属于同一大类:新建该类应用
assert m["apps"]["bm7_hotmovies"]["category"] == "video" # 没人认识:按名字猜
assert m["apps"]["bm7_randomcorp"]["category"] == "site" # 名字也猜不出:常用网站
assert lists._pending("bm7") == ["Missing"]
assert os.path.exists(lists._auto_file())
assert lists.discover() == 0 # 再发现一次:没有新增
# 自动登记的应用不用重启就生效:识别库重新加载(version 变化)后分类器补上新应用
assert "bm7_videobundle" not in c.apps
lists.load()
c.classify("1.1.1.1", 6, 443, "x.youtube.com", None)
assert c.apps["bm7_videobundle"].category == "video"
assert Classifier().apps["bm7_hotmovies"].category == "video"
finally:
lists.reset()
lists._map = None
lists._auto = {}
def test_discover_category_lists(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
ut1 = [{"name": n, "type": "dir"} for n in ["shopping", "cooking", "games_online", "sports_betting"]]
monkeypatch.setattr(L.httpx, "get", lambda url, **kw: _Resp(ut1) if "ut1" in url else _Resp([]))
try:
lists.reset()
lists._map = None
lists._auto = {}
c = Classifier()
assert lists.discover() == 2 # shopping 已映射、cooking 猜不出;其余两个待定
d = tmp_path / "lists" / "ut1"
d.mkdir(parents=True)
(d / "games_online.txt").write_text("\n".join(f"g{i}.unknown-games.test" for i in range(6)), encoding="utf-8")
# 名字像博彩,内容却全是视频网站:投票不符,作废
(d / "sports_betting.txt").write_text("\n".join(["youtube.com", "netflix.com", "iqiyi.com", "youku.com",
"mgtv.com", "hulu.com"]), encoding="utf-8")
assert lists.resolve(c) == 2
spec = dict(lists._spec("ut1"))
assert spec["games_online"] == "gen_game" # 并到该类的“其他游戏网站”
assert "sports_betting" not in spec
assert lists._auto["gen_sports_betting"]["rejected"]
assert lists.discover() == 0 # 作废的不再登记
finally:
lists.reset()
lists._map = None
@@ -75,9 +142,8 @@ def test_reclass_uses_dns_cache_for_ip_rows():
s.add(TrafficDay(ts=DAY, host=HOST, app="cn_net", up=100, down=900, flows=10))
s.commit()
class _C:
@staticmethod
def classify(ip, proto, port, qname, cname):
class _C(Classifier):
def classify(self, ip, proto, port, qname, cname):
return "douyin" if qname == "v.douyin.com" else "cn_net"
eng = Engine()
@@ -104,8 +170,9 @@ def test_reclass_uses_dns_cache_for_ip_rows():
s.close()
def test_auto_reclassify_runs_on_fingerprint_change():
def test_auto_reclassify_runs_on_fingerprint_change(monkeypatch):
import app.database as database
from app import reclass, timeutil
database.connect_from_saved(force=True)
s = database.SessionLocal()
try:
@@ -118,9 +185,85 @@ def test_auto_reclassify_runs_on_fingerprint_change():
assert auto_reclassify(eng, s) is None # 指纹没变:什么都不做
s.add(DnsCache(ip="198.51.100.1", name="x.example.com", expires=int(time.time()) + 600))
s.commit()
assert auto_reclassify(eng, s) is not None # DNS 映射变多:指纹变化 → 重算
assert auto_reclassify(eng, s) is None # DNS 映射变多不再触发(0.6.6 每次都全量重算)
real = timeutil.day_bucket
monkeypatch.setattr(reclass.timeutil, "day_bucket", lambda ts: real(ts) + 86400)
assert auto_reclassify(eng, s) is not None # 过了零点:昨天成了可重算的历史
finally:
s.query(Setting).filter(Setting.key == "reclass_fp").delete()
s.query(DnsCache).filter(DnsCache.ip == "198.51.100.1").delete()
s.commit()
s.close()
# ---------- 0.6.7:重算只升级、不碰今天(用真实识别器) ----------
def _rows(s, ts):
return {r.app: r.up + r.down for r in s.query(TrafficDay).filter(TrafficDay.ts == ts, TrafficDay.host == HOST).all()}
def _cleanup(s):
s.query(TrafficDay).filter(TrafficDay.host == HOST).delete()
s.query(HostDomainDay).filter(HostDomainDay.host == HOST).delete()
s.commit()
def test_reclass_never_downgrades():
import app.database as database
database.connect_from_saved(force=True)
s = database.SessionLocal()
eng = Engine()
c = eng.classifier
# 前提:实时按端口 443 认成 HTTPS 的纯 IP、实时按 Telegram IP 段认出的无名域名,历史行里都没有这些信号
assert c.classify("203.0.113.7", 6, 443, None, None) == "https"
assert c.classify("91.108.4.10", 6, 443, "x.unknown-zz.test", None) == "telegram"
assert c.classify("0.0.0.0", 6, 443, "x.unknown-zz.test", None) != "telegram"
try:
s.add(TrafficDay(ts=DAY, host=HOST, app="https", up=0, down=1000, flows=10))
s.add(TrafficDay(ts=DAY, host=HOST, app="telegram", up=0, down=2000, flows=20))
s.add(HostDomainDay(ts=DAY, host=HOST, domain="203.0.113.7", app="https", bytes=1000))
s.add(HostDomainDay(ts=DAY, host=HOST, domain="x.unknown-zz.test", app="telegram", bytes=2000))
s.commit()
res = reclassify(eng, s)
s.commit()
assert _rows(s, DAY) == {"https": 1000, "telegram": 2000} # 0.6.6:https → other、telegram → https
assert res["bytes_moved"] == 0
finally:
_cleanup(s)
s.close()
def test_reclass_skips_today():
"""今天的行每分钟落库都会把 app 改回实时分类、字节累加;0.6.6 重算今天会把同一批字节反复搬走"""
import app.database as database
from app import timeutil
database.connect_from_saved(force=True)
s = database.SessionLocal()
today = timeutil.day_bucket(time.time())
class _C(Classifier):
def classify(self, ip, proto, port, qname, cname):
return "douyin" if qname == "v.douyin.com" else "cn_net"
eng = Engine()
eng.classifier = _C()
try:
s.add(TrafficDay(ts=today, host=HOST, app="cn_net", up=0, down=4000, flows=40))
s.add(HostDomainDay(ts=today, host=HOST, domain="v.douyin.com", app="cn_net", bytes=2000))
s.commit()
for _ in range(3):
assert reclassify(eng, s)["bytes_moved"] == 0
s.commit()
assert _rows(s, today) == {"cn_net": 4000}
# 显式包含今天(before=明天零点)时才会动
assert reclassify(eng, s, before=today + 86400)["bytes_moved"] == 2000
s.commit()
finally:
_cleanup(s)
s.close()
def test_oplog_scrubs_adguard_password():
from app.oplog import scrub
assert scrub({"adguard_password": "p@ss", "adguard_url": "http://x"}) == {"adguard_password": "***",
"adguard_url": "http://x"}
+2 -2
View File
@@ -109,8 +109,8 @@ export default {
patternHint: { domain: 'e.g. example.com (includes subdomains)', cidr: 'e.g. 1.2.3.0/24', port: 'e.g. tcp/8080 or udp/3000-3010' },
existingApp: 'Existing app', newApp: 'New app', appId: 'App ID (lowercase, digits, underscore)', appName: 'App name',
classify: 'Classify', test: 'Test a domain', testResult: 'Matches: {app}', testNone: 'No domain rule matches', bytes: 'Traffic',
reclassify: 'Re-classify history', reclassifyHint: 'After rules or libraries change, re-attribute the last 31 days of traffic under the new rules (daily stats)',
reclassifyConfirm: 'Re-classify history under the current rules? Traffic whose classification changed will be moved to the new app (may take a while).',
reclassify: 'Re-classify history', reclassifyHint: 'After rules or libraries change, re-attribute the last 31 days (excluding today) under the new rules (daily stats); only upgrades vague categories to specific apps',
reclassifyConfirm: 'Re-classify history under the current rules? Vague traffic from the last 31 days (excluding today, which is already live-classified) that now maps to a specific app will be moved (may take a while).',
reclassifying: 'Re-classifying…', reclassifyDone: 'Done: moved {b} ({s}s)',
},
lib: {
+2 -2
View File
@@ -109,8 +109,8 @@ export default {
patternHint: { domain: '如 example.com(含子域名)', cidr: '如 1.2.3.0/24', port: '如 tcp/8080 或 udp/3000-3010' },
existingApp: '已有应用', newApp: '新建应用', appId: '应用 ID(小写字母/数字/下划线)', appName: '应用名称',
classify: '归类', test: '测试域名', testResult: '识别为:{app}', testNone: '未命中任何域名规则', bytes: '流量',
reclassify: '重算历史分类', reclassifyHint: '规则或识别库升级后,把近 31 天的历史流量按新规则重新归类(按天统计)',
reclassifyConfirm: '按当前规则重算历史分类?会把近 31 天里归类变化的历史流量搬到新的应用(可能需要十几秒)。',
reclassify: '重算历史分类', reclassifyHint: '规则或识别库升级后,把近 31 天(不含今天)的历史流量按新规则重新归类(按天统计);只把笼统分类升级成具体应用',
reclassifyConfirm: '按当前规则重算历史分类?会把近 31 天(不含今天,今天的流量已按新规则实时统计)里能认出具体应用的笼统分类流量搬过去(可能需要十几秒)。',
reclassifying: '重算中…', reclassifyDone: '已重算:搬移 {b} 字节({s} 秒)',
},
lib: {
+17 -17
View File
@@ -133,23 +133,23 @@
--line-up: var(--up);
--line-down: var(--down);
--down: #3fc4c8;
--g-http: #5470c6;
--g-game: #91cc75;
--g-tv: #fac858;
--g-p2p: #ee6666;
--g-common: #73c0de;
--g-im: #3ba272;
--g-music: #fc8452;
--g-stock: #9a60b4;
--g-phone: #ea7ccc;
--g-proxy: #5470c6;
--g-database: #91cc75;
--g-mobile: #fac858;
--g-special: #ee6666;
--g-mining: #73c0de;
--g-pcdn: #3ba272;
--g-other: #fc8452;
--g-unknown: #9a60b4;
--g-http: #4a7dc4;
--g-game: #55a754;
--g-tv: #e0762f;
--g-p2p: #dd5a54;
--g-common: #43b0c6;
--g-im: #2f9d6e;
--g-music: #d9a41f;
--g-stock: #9a55d4;
--g-phone: #e56c9f;
--g-proxy: #6b62d9;
--g-database: #a97c50;
--g-mobile: #c24fb0;
--g-special: #9cba32;
--g-mining: #b3ae54;
--g-pcdn: #3cb9a1;
--g-other: #8a97a5;
--g-unknown: #5d6870;
--g-social: var(--g-im);
color-scheme: dark;
}