Files
Su YijunandClaude Opus 5.5 28135807bd feat: debug3 应用三级分类、图表重做、终端按 MAC/IP 命名(v0.1.4)
- 应用分类参照 Panabit 改为 应用大类 → 小类 → 应用:10 个大类、41 个小类、277 个应用;
  CNAME 指向 CDN 时归到 CDN 厂商;ICMP / GRE / ESP 按协议识别;已发布的应用和分类 ID 全部保留
- 图表重做:流量构成双层环图 + 排行表、按应用大类堆叠的趋势(可切换上下行)、
  带名次和占比的 Top 排行、桑基图中间列改为应用大类;配色按大类固定并通过色盲校验
- 终端命名:按 MAC 或按 IP,新增命名管理,可提前给未上线设备命名,旧备注名自动迁移
- DNS 嗅探卡片说明作用并显示应答来源;补充内网自建 DNS(AdGuard/Pi-hole)的说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:44:17 +08:00

125 lines
6.0 KiB
Python

import time
from datetime import datetime
from types import SimpleNamespace
from zoneinfo import ZoneInfo
from packets import ipfix, tzsp_dns
from app import stats, timeutil
from app.classify import Classifier
from app import database
from app.engine import Engine
Z = ZoneInfo("Asia/Shanghai")
def test_classifier_longest_suffix_and_cname():
c = Classifier()
assert c.classify("1.1.1.1", 6, 443, "v26-web.douyinvod.com", None) == "douyin"
assert c.classify("1.1.1.1", 6, 443, "y.qq.com", None) == "qq_music" # 比 qq.com 更具体
assert c.classify("1.1.1.1", 6, 443, "www.qq.com", None) == "qq"
assert c.classify("1.1.1.1", 6, 443, "unknown.example", "x.bilivideo.com") == "bilibili"
assert c.classify("91.108.4.5", 6, 443, None, None) == "telegram"
assert c.classify("9.9.9.9", 17, 443, None, None) == "quic"
assert c.classify("9.9.9.9", 6, 12345, None, None) == "other"
assert c.classify("9.9.9.9", 1, 0, None, None) == "icmp"
assert c.classify("9.9.9.9", 50, 0, None, None) == "vpn"
# 未识别的站点经 CDN 的 CNAME 至少能归到 CDN 厂商
assert c.classify("1.1.1.1", 6, 443, "img.some-site.cn", "x.kunlunsl.com") == "aliyun"
assert c.classify("1.1.1.1", 6, 443, "finder.video.qq.com", None) == "wechat_channels"
def test_rules_library_consistent():
"""规则库自洽:分类都有大类、应用都有分类,已发布的应用/分类 ID 没被删掉。"""
c = Classifier()
assert all(v["group"] in c.groups for v in c.categories.values())
assert all(a.category in c.categories for a in c.apps.values())
released = {"douyin", "bilibili", "wechat", "qq", "steam", "apple_update", "apple", "microsoft", "cn_ai",
"android_update", "app_market", "cloudflare", "ads", "ezviz", "dns", "vpn", "ssh", "mail", "stun",
"https", "quic", "http"}
assert released <= set(c.apps)
assert {"video", "short_video", "live", "music", "social", "im", "game", "shopping", "news", "search",
"cloud", "download", "update", "ai", "work", "finance", "life", "ads", "iot", "network", "web",
"unknown"} <= set(c.categories)
def test_frontend_colors_cover_categories():
"""前端按大类配色(format.js 的 CAT_GROUP),新增分类时要同步。"""
import os
import re
js = open(os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "src", "format.js"), encoding="utf-8").read()
block = js[js.index("CAT_GROUP"):]
block = block[:block.index("}")]
mapping = dict(re.findall(r"(\w+):\s*'(\w+)'", block))
c = Classifier()
assert mapping == {k: v["group"] for k, v in c.categories.items()}
def test_custom_rule_overrides():
c = Classifier()
c.load_custom([SimpleNamespace(kind="domain", pattern="douyin.com", app_id="myapp", app_name="我的",
category="video")])
assert c.classify("1.1.1.1", 6, 443, "www.douyin.com", None) == "myapp"
assert c.apps["myapp"].name == "我的"
def _engine():
e = Engine()
e.set_lan("192.168.88.0/24,2408:8000:1:2::/64")
return e
def test_direction_dns_and_mac_learning():
e = _engine()
e.on_tzsp(tzsp_dns("192.168.88.10", "www.bilibili.com", ["5.5.5.5"]))
assert list(e.dns_servers) and e.dns_servers[next(iter(e.dns_servers))][0] == 1
now = time.time()
up = e.add_flow(dict(src="192.168.88.10", dst="5.5.5.5", sport=1, dport=443, proto=6, bytes=100,
src_mac="AA:00:00:00:00:01"), now)
down = e.add_flow(dict(src="5.5.5.5", dst="192.168.88.10", sport=443, dport=1, proto=6, bytes=900), now)
assert up == ("AA:00:00:00:00:01", "bilibili", "up")
assert down == ("AA:00:00:00:00:01", "bilibili", "down")
# 内网互访、路由器自身流量不计
assert e.add_flow(dict(src="192.168.88.10", dst="192.168.88.20", proto=6, bytes=5), now) is None
assert e.add_flow(dict(src="100.64.1.2", dst="8.8.8.8", proto=17, bytes=5), now) is None
def test_post_nat_download():
e = _engine()
r = e.add_flow(dict(src="5.5.5.5", dst="100.64.1.2", post_dst="192.168.88.30", sport=443, dport=1, proto=6,
bytes=10), time.time())
assert r == ("IP:192.168.88.30", "https", "down")
def test_flush_and_ranges():
e = _engine()
e.on_tzsp(tzsp_dns("192.168.88.11", "store.steampowered.com", ["6.6.6.6"]))
e.on_ipfix(ipfix([dict(src="192.168.88.11", dst="6.6.6.6", sport=1, dport=443, proto=6, nbytes=1000,
mac="AA:00:00:00:00:11"),
dict(src="192.168.88.11", dst="7.7.7.7", sport=1, dport=443, proto=6, nbytes=50,
mac="AA:00:00:00:00:11")]), "10.0.0.1")
now = time.time()
e.flush(now)
e.add_flow(dict(src="6.6.6.6", dst="192.168.88.11", sport=443, dport=1, proto=6, bytes=4000), now)
e.flush(now) # 同一分钟再写一次,应累加
with database.session() as db:
t = stats.total(db, *timeutil.range_bounds("today"), host="AA:00:00:00:00:11")
assert t == {"up": 1050, "down": 4000, "total": 5050, "flows": 3}
apps = {a["id"]: a for a in stats.top_apps(db, *timeutil.range_bounds("month"), host="AA:00:00:00:00:11")}
assert apps["steam"]["total"] == 5000 and apps["https"]["total"] == 50
hosts = {h["key"]: h for h in stats.all_hosts(db)}
assert hosts["AA:00:00:00:00:11"]["today"]["total"] == 5050
assert hosts["AA:00:00:00:00:11"]["online"]
doms = stats.host_domains(db, "AA:00:00:00:00:11", *timeutil.range_bounds("today"))
assert doms[0]["domain"] == "store.steampowered.com" and doms[0]["bytes"] == 5000
def test_range_bounds_week_starts_monday():
# 2026-10-21 是周三
now = datetime(2026, 10, 21, 15, 0, tzinfo=Z).timestamp()
start, _ = timeutil.range_bounds("week", now)
assert datetime.fromtimestamp(start, Z) == datetime(2026, 10, 19, tzinfo=Z)
start, _ = timeutil.range_bounds("month", now)
assert datetime.fromtimestamp(start, Z) == datetime(2026, 10, 1, tzinfo=Z)
assert timeutil.day_bucket(now) == datetime(2026, 10, 21, tzinfo=Z).timestamp()