From ff5043bb8eb248b700adf3c039e0c00641d71172 Mon Sep 17 00:00:00 2001 From: Su Yijun Date: Sun, 25 Oct 2026 01:24:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=91=E5=B8=83=200.6.5=EF=BC=8C?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E6=95=B0=E6=8D=AE=E6=BA=90=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=20AdGuard=20Home=E3=80=81ASN=20=E8=AF=86=E5=88=AB=E4=B8=AD?= =?UTF-8?q?=E5=9B=BD=E8=87=AA=E8=90=A5=E5=86=85=E5=AE=B9=E5=95=86=E3=80=81?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=88=86=E7=B1=BB=E9=87=8D=E7=AE=97=E3=80=81?= =?UTF-8?q?DPI=20=E9=85=8D=E8=89=B2=2017=20=E8=89=B2=E5=8E=BB=E9=87=8D?= =?UTF-8?q?=EF=BC=88debug13=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 外部数据源新增 AdGuard Home 查询日志源(双层 NAT 场景补全下级终端的域名),与 MosDNS 同管线,可同时启用 - apps.yaml 给抖音/B站/爱奇艺/米哈游/京东/淘宝/网易等中国自营内容商补 ASN 段,无域名流量按自营网段归类 - blackmatrix7 新增 GovCN(政务网站)、IPTVMainland(IPTV 电视)、EncoreTVB(TVB 频道)三个来源 - 识别规则页新增「重算历史分类」:规则升级后近 31 天历史流量按新规则重新归类(host_domain_day 重算 traffic_day) - DPI 协议 17 个大类配色互异(等距色相+语义锚定,其他/未识别中性灰),替换 0.6.4 的 9 色循环 - 修复 ROS 轮询在设备上报 GBK 主机名时整个轮询失败(坏字节替换为 U+FFFD) --- .env.example | 5 + CHANGELOG.md | 10 ++ README.md | 2 + backend/app/__init__.py | 2 +- backend/app/classify/apps.yaml | 18 +-- backend/app/classify/bm7_map.yaml | 3 + backend/app/config.py | 3 + backend/app/integrations.py | 157 ++++++++++++++++++++- backend/app/main.py | 1 + backend/app/reclass.py | 92 ++++++++++++ backend/app/ros_poller.py | 5 +- backend/app/routers/admin.py | 25 +++- backend/tests/test_debug13.py | 177 ++++++++++++++++++++++++ docs/RouterOS配置.md | 9 ++ frontend/src/components/Composition.vue | 2 +- frontend/src/i18n/en.js | 10 +- frontend/src/i18n/zh.js | 10 +- frontend/src/style.css | 72 +++++----- frontend/src/views/Rules.vue | 17 ++- frontend/src/views/Settings.vue | 38 ++++- 20 files changed, 594 insertions(+), 64 deletions(-) create mode 100644 backend/app/reclass.py create mode 100644 backend/tests/test_debug13.py diff --git a/.env.example b/.env.example index 0f1b5c0..11171f5 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,11 @@ LAN_CIDRS=192.168.88.0/24 # FAKEIP_CIDRS=198.18.0.0/15,fc00::/18 # MosDNS 审计 API 地址:知道每台终端查了哪个域名 # MOSDNS_URL=http://192.168.1.3:9099 +# 自有 DNS 服务器(AdGuard Home):双层 NAT(路由器下面又挂了爱快等二级路由)时, +# 下级客户端的 DNS 查询在路由器侧看不到,从它们真正使用的 DNS 服务器的查询日志补全域名 +# ADGUARD_URL=http://192.168.9.2:3000 +# ADGUARD_USERNAME=admin +# ADGUARD_PASSWORD= # ---------- 识别库 ---------- # 自动下载 iptoasn.com 的 IP→ASN 库(公有领域,每天更新);没有外网时设为 false diff --git a/CHANGELOG.md b/CHANGELOG.md index 6939ec8..e4e9622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.6.5] - 2026-10-25 + +外部数据源支持自有 DNS 服务器(AdGuard Home)、ASN 识别中国自营内容商、历史分类重算;DPI 协议 17 色去重(debug13) + +- **外部数据源新增 AdGuard Home**:双层 NAT(路由器下面又挂了爱快等二级路由)时,下级客户端的 DNS 查询被上游代理,路由器侧看不到,流量只能归「国内网络 / 未识别」。在 系统设置 → 外部数据源 填入下级网络真正使用的 DNS 服务器(AdGuard Home)的地址和密码后,从它的查询日志读取「谁查了哪个域名、解析出哪些 IP」,与 MosDNS 源走同一条管线,下级终端的流量也能按域名识别。两个 DNS 源可同时启用;账号和密码都留空表示免认证访问(仅限内网监听)。 +- **ASN 识别中国自营内容商**:字节跳动/抖音、哔哩哔哩、爱奇艺、米哈游、京东、淘宝、网易、腾讯云的自治系统段加进对应应用的 `asns` 规则,没有域名的流量命中这些自营网段时直接归入正确应用(此前只有 Netflix、Telegram 等国外服务有)。 +- **blackmatrix7 新增 3 个来源**:政务网站(GovCN → 常用网站)、运营商 IPTV(IPTVMainland → 直播)、海外 TVB 频道(EncoreTVB → 在线视频)。 +- **识别规则页新增「重算历史分类」**:规则或识别库升级后,近 31 天的历史流量按当前规则重新归类——新的识别结果立刻反映在应用统计里,不用等旧数据滚出保留期。只搬移归类发生变化的部分(按天统计;分钟/小时曲线不动,随保留期自然对齐),重复执行安全。 +- **DPI 协议 17 个大类配色去重**:0.6.4 的 9 色循环在第 10 类起重复(流量代理和 HTTP协议 同为蓝色等),改回 17 个互异的颜色——等距色相加语义锚定,相邻大类色相错开 20° 以上,「其他 / 未识别」用深浅两种中性灰。饼图、矩形树图、历史趋势、图例、详情弹窗同源生效。 + ## [0.6.4] - 2026-10-24 识别库面板按层级分组;DPI 协议配色照维盟 diff --git a/README.md b/README.md index 17243e0..3888366 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ MySQL 第一次初始化需要 20~60 秒。 - **sing-box Clash API**(`experimental.clash_api.external_controller` 和 `secret`):按“哪台终端、哪个域名、多少字节”统计走代理的流量,代理自身的隧道流量不再重复计算;域名认不出具体应用时按命中的规则集 / 策略组名识别;流量流向 → 代理分流 按策略组和出口节点统计。 - **MosDNS 审计 API**:知道每台终端查了哪个域名、解析出哪些 IP。 +- **AdGuard Home(自有 DNS 服务器)**:双层 NAT(路由器下面又挂了爱快等二级路由)时,下级客户端的 DNS 查询被上游代理,路由器侧看不到,这些流量只能归「国内网络 / 未识别」。把下级网络真正使用的 DNS 服务器(AdGuard Home)的地址和密码填进来,从它的查询日志读取「谁查了哪个域名、解析出哪些 IP」,下级终端的流量同样按域名识别。前提:AdGuard Home 开启查询日志(默认开启);账号和密码都留空表示免认证访问(仅限内网监听时)。 地址每个人都不一样,都不填就不启用。 @@ -218,6 +219,7 @@ MySQL 第一次初始化需要 20~60 秒。 | `ROS_POLL_SECONDS` / `ROS_RATE_SECONDS` | 30 / 5 | RouterOS 完整轮询间隔 / WAN 口速率采样间隔(实时流量图) | | `SINGBOX_URL` / `SINGBOX_SECRET` / `PROXY_IPS` | 空 | 旁路代理 sing-box 的 Clash API(也可在网页填写,网页优先) | | `MOSDNS_URL` | 空 | MosDNS 审计 API 地址(也可在网页填写) | +| `ADGUARD_URL` / `ADGUARD_USERNAME` / `ADGUARD_PASSWORD` | 空 | 自有 DNS 服务器 AdGuard Home 的查询日志(双层 NAT 场景,也可在网页填写) | | `FAKEIP_CIDRS` | `198.18.0.0/15,fc00::/18` | FakeIP 地址段 | | `ASN_DB` | `true` | 自动下载 IP→ASN 库;没有外网时设为 `false` | | `CHINA_DB` | `true` | 自动下载国内域名列表(dnsmasq-china-list,约 11 万条)与国内 IP 段:规则库没认出的域名归「国内网站」,没有域名的国内流量归「国内网络」 | diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 364e7ba..7bbb2ef 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1 +1 @@ -__version__ = "0.6.4" +__version__ = "0.6.5" diff --git a/backend/app/classify/apps.yaml b/backend/app/classify/apps.yaml index b4bd369..29cedee 100644 --- a/backend/app/classify/apps.yaml +++ b/backend/app/classify/apps.yaml @@ -101,7 +101,7 @@ categories: apps: # ================= 影音娱乐 ================= # ---------- 短视频 ---------- - - {id: douyin, name: 抖音, name_en: Douyin, category: short_video, + - {id: douyin, asns: [396986, 138699], name: 抖音, name_en: Douyin, category: short_video, domains: [douyin.com, douyinpic.com, douyinvod.com, douyincdn.com, douyinstatic.com, iesdouyin.com, amemv.com, snssdk.com, bytecdn.cn, byteimg.com, pstatp.com, zjcdn.com, bytedance.com, bytegoofy.com, ibytedtos.com, volces.com]} - {id: kuaishou, name: 快手, name_en: Kuaishou, category: short_video, domains: [kuaishou.com, gifshow.com, ksapisrv.com, kwaicdn.com, kwimgs.com, yximgs.com, kspkg.com, kuaishouzt.com, ndcimgs.com]} @@ -114,9 +114,9 @@ apps: domains: [ixigua.com, ixiguavideo.com]} - {id: haokan, name: 好看视频, name_en: Haokan, category: short_video, domains: [haokan.baidu.com, haokanvideo.com]} # ---------- 在线视频 ---------- - - {id: bilibili, name: 哔哩哔哩, name_en: Bilibili, category: video, + - {id: bilibili, asns: [140633], name: 哔哩哔哩, name_en: Bilibili, category: video, domains: [bilibili.com, bilivideo.com, bilivideo.cn, hdslb.com, biliapi.net, biliapi.com, bilicdn1.com, bilicdn2.com, b23.tv, acgvideo.com]} - - {id: iqiyi, name: 爱奇艺, name_en: iQIYI, category: video, + - {id: iqiyi, asns: [133865, 136250], name: 爱奇艺, name_en: iQIYI, category: video, domains: [iqiyi.com, iqiyipic.com, qiyi.com, qy.net, 71.am, iq.com, ppstream.com]} - {id: tencent_video, name: 腾讯视频, name_en: Tencent Video, category: video, domains: [v.qq.com, video.qq.com, vqq.com, gtimg.com, ugcvod.qq.com, apd-vlive.apdcdn.tc.qq.com, vod-tencent.com, tencentvideo.com]} @@ -193,7 +193,7 @@ apps: - {id: perfect_world, name: 完美世界, name_en: Perfect World, category: game, domains: [wanmei.com, pwrd.com, perfectworld.com]} - {id: kingsoft_games, name: 西山居/盛趣/巨人, name_en: Seasun/Shengqu/Giant, category: game, domains: [xoyo.com, seasun.com, sdo.com, ztgame.com]} - {id: game_4399, name: "4399", name_en: "4399", category: game, domains: [4399.com, img4399.com, 4399sj.com]} - - {id: mihoyo, name: 米哈游, name_en: HoYoverse, category: mobile_game, + - {id: mihoyo, asns: [215543], name: 米哈游, name_en: HoYoverse, category: mobile_game, domains: [mihoyo.com, hoyoverse.com, yuanshen.com, genshinimpact.com, hoyolab.com, bhsr.com, starrails.com, mhyurl.cn]} - {id: hypergryph, name: 鹰角网络, name_en: Hypergryph, category: mobile_game, domains: [hypergryph.com, hycdn.cn]} - {id: lilith, name: 莉莉丝, name_en: Lilith Games, category: mobile_game, domains: [lilith.com, lilithgame.com, lilithgames.com]} @@ -370,9 +370,9 @@ apps: - {id: coursera, name: Coursera/Duolingo, name_en: Coursera/Duolingo, category: education, domains: [coursera.org, duolingo.com, khanacademy.org, udemy.com]} # ================= 购物生活 ================= - - {id: taobao, name: 淘宝/天猫, name_en: Taobao/Tmall, category: shopping, + - {id: taobao, asns: [24429], name: 淘宝/天猫, name_en: Taobao/Tmall, category: shopping, domains: [taobao.com, tmall.com, alicdn.com, tbcdn.cn, taobaocdn.com, mmstat.com, tanx.com, alibaba.com, aliexpress.com, 1688.com, alipayobjects.com, goofish.com]} - - {id: jd, name: 京东, name_en: JD, category: shopping, + - {id: jd, asns: [137753, 137787], name: 京东, name_en: JD, category: shopping, domains: [jd.com, 360buyimg.com, jd.hk, jdpay.com, jcloud.com, jdcloud.com, 3.cn]} - {id: pinduoduo, name: 拼多多, name_en: Pinduoduo, category: shopping, domains: [pinduoduo.com, yangkeduo.com, pddpic.com, pddugc.com, temu.com, kwcdn.com]} @@ -424,7 +424,7 @@ apps: - {id: toutiao, name: 今日头条, name_en: Toutiao, category: news, domains: [toutiao.com, toutiaocdn.com, toutiaoimg.cn]} - {id: tencent_news, name: 腾讯新闻, name_en: Tencent News, category: news, domains: [news.qq.com, inews.qq.com, new.qq.com]} - - {id: netease, name: 网易, name_en: NetEase, category: news, + - {id: netease, asns: [45062], name: 网易, name_en: NetEase, category: news, domains: [163.com, 126.net, 127.net, netease.com]} - {id: sina, name: 新浪, name_en: Sina, category: news, domains: [sina.com.cn, sina.cn, sina.com]} - {id: sohu, name: 搜狐, name_en: Sohu, category: news, domains: [sohu.com, sohucs.com]} @@ -492,9 +492,9 @@ apps: domains: [cloudflare.com, cloudflareinsights.com, cloudflare.net, workers.dev, pages.dev, cdnjs.cloudflare.com]} - {id: akamai, asns: [20940, 16625], name: Akamai, name_en: Akamai, category: cdn, domains: [akamai.net, akamaiedge.net, akamaihd.net, akamaized.net, edgekey.net, edgesuite.net, akadns.net, akamaitechnologies.com]} - - {id: aliyun, asns: [37963, 45102], name: 阿里云/CDN, name_en: Alibaba Cloud, category: cdn, + - {id: aliyun, asns: [37963, 45102, 203513], name: 阿里云/CDN, name_en: Alibaba Cloud, category: cdn, domains: [aliyun.com, aliyuncs.com, alikunlun.com, alikunlun.net, kunlunca.com, kunlunsl.com, kunlungr.com, kunlunle.com, alicloudccp.com, aliyundrive.cloud]} - - {id: tencent_cloud, asns: [45090, 132203], name: 腾讯云/CDN, name_en: Tencent Cloud, category: cdn, + - {id: tencent_cloud, asns: [45090, 132203, 133478, 137876], name: 腾讯云/CDN, name_en: Tencent Cloud, category: cdn, domains: [myqcloud.com, qcloud.com, tencent-cloud.net, tencentcs.com, tencentcloud.com, cdntip.com, tdnsv5.com, tdnsv6.com, dnsv1.com, qcloudcdn.com, qcloudcdn.cn]} - {id: wangsu, name: 网宿 CDN, name_en: Wangsu CDN, category: cdn, domains: [wscdns.com, wsglb0.com, wsdvs.com, lxdns.com, chinanetcenter.com, wswebcdn.com, cdn20.com]} diff --git a/backend/app/classify/bm7_map.yaml b/backend/app/classify/bm7_map.yaml index 7138739..41a999c 100644 --- a/backend/app/classify/bm7_map.yaml +++ b/backend/app/classify/bm7_map.yaml @@ -497,6 +497,7 @@ apps: bm7_dood: {category: video, name: "DoodStream", name_en: "DoodStream", lists: ["Dood"]} bm7_duowan: {category: game, name: "多玩", name_en: "Duowan", lists: ["DuoWan"]} bm7_dxy: {category: life, name: "丁香园", name_en: "DXY", lists: ["DingXiangYuan"]} + bm7_encoretvb: {category: video, name: "TVB++", name_en: "Encore TVB", lists: ["EncoreTVB"]} bm7_emby: {category: video, name: "Emby", name_en: "Emby", lists: ["Emby"]} bm7_fanfou: {category: social, name: "饭否", name_en: "Fanfou", lists: ["FanFou"]} bm7_flipboard: {category: news, name: "Flipboard", name_en: "Flipboard", lists: ["FlipBoard"]} @@ -505,7 +506,9 @@ apps: bm7_friday: {category: video, name: "friDay 影音", name_en: "friDay Video", lists: ["friDay"]} bm7_fubo: {category: video, name: "fuboTV", name_en: "fuboTV", lists: ["FuboTV"]} bm7_garena: {category: game, name: "Garena", name_en: "Garena", lists: ["Garena"]} + bm7_govcn: {category: site, name: "政务网站", name_en: "Government portals", lists: ["GovCN"]} bm7_geely: {category: iot, name: "吉利汽车", name_en: "Geely", lists: ["Geely"]} + bm7_iptv: {category: live, name: "IPTV 电视", name_en: "IPTV", lists: ["IPTVMainland"]} bm7_hiby: {category: music, name: "海贝音乐", name_en: "HiBy Music", lists: ["HibyMusic"]} bm7_hpplay: {category: iot, name: "乐播投屏", name_en: "Hpplay", lists: ["Hpplay"]} bm7_iflytek: {category: ai, name: "科大讯飞", name_en: "iFlytek", lists: ["iFlytek"]} diff --git a/backend/app/config.py b/backend/app/config.py index 496df78..516238a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -38,6 +38,9 @@ class Settings(BaseSettings): proxy_ips: str = "" # 旁路代理自身的 IP(逗号分隔);留空时取 singbox_url 里的地址 fakeip_cidrs: str = "198.18.0.0/15,fc00::/18" mosdns_url: str = "" # MosDNS 审计 API 地址,如 http://192.168.1.3:9099 + adguard_url: str = "" # 自有 DNS 服务器 AdGuard Home 的地址,如 http://192.168.9.2:3000(双层 NAT 时爱快下游的 DNS) + adguard_username: str = "" # AdGuard Home 登录用户名;和密码都留空表示免认证(只在内网监听时) + adguard_password: str = "" oui_db: bool = True # 自动下载 IEEE 的 MAC 厂商库(OUI,识别终端类型用,每 30 天更新) asn_db: bool = True # 自动下载 iptoasn 的 IP→ASN 库(公有领域,每天更新) china_db: bool = True # 自动下载国内域名列表(dnsmasq-china-list)与国内 IP 段(每天更新) diff --git a/backend/app/integrations.py b/backend/app/integrations.py index 51a8869..d16ef01 100644 --- a/backend/app/integrations.py +++ b/backend/app/integrations.py @@ -1,9 +1,11 @@ -"""外部数据源(可选):旁路代理 sing-box 的 Clash API、内网 DNS MosDNS 的审计日志。 +"""外部数据源(可选):旁路代理 sing-box 的 Clash API、内网 DNS MosDNS 的审计日志、自有 DNS 服务器 AdGuard Home 的查询日志。 为什么需要: - 终端走旁路代理时,代理回给终端的下载流量常常走二层直达、不经过路由器;路由器只看到代理自己连服务器的加密隧道, 认不出任何应用。Clash API 的 /connections 直接给出每条连接的来源终端、域名和上下行字节,最准。 - 终端向内网 DNS(同一网段)查询不经过路由器,DNS 嗅探看不到;MosDNS 审计日志给出“哪台终端查了哪个域名、解析出哪些 IP”。 +- 双层 NAT(如爱快在路由器下面又挂一层内网)时,下级客户端的 DNS 被爱快代理,路由器侧更看不到; + 它们真正的 DNS 服务器(如爱快下游的 AdGuard Home)的查询日志就是域名的来源。 地址、密钥每个人都不一样:在网页 系统设置 → 外部数据源 里填写(存在数据库 settings 表),环境变量作为默认值。都不填就不启用。 """ @@ -16,7 +18,7 @@ import httpx from app.config import get_settings -KEYS = ("singbox_url", "singbox_secret", "proxy_ips", "mosdns_url") +KEYS = ("singbox_url", "singbox_secret", "proxy_ips", "mosdns_url", "adguard_url", "adguard_username", "adguard_password") def effective(db_values: dict) -> dict: @@ -196,19 +198,164 @@ class MosdnsPoller: "last": self.last, "answers": self.answers} +class AdguardPoller: + """读 AdGuard Home 的查询日志(最新的在前),把新出现的应答交给引擎:终端 + IP → 域名。 + + 用于双层 NAT(如爱快下游):路由器看不到下级客户端的 DNS 查询,但它们的查询都记录在 + 下游自己的 DNS 服务器上。应答会进引擎的全网 IP→域名表(add_dns_answer),与客户端在哪层 NAT 无关。 + """ + + PAGE = 500 + # 查询日志 answer 字段的资源记录行:「域名. TTL IN A|AAAA|CNAME 值」 + _RR = re.compile(r"(\S+)\s+(\d+)\s+IN\s+(A|AAAA|CNAME)\s+(\S+)", re.IGNORECASE) + + def __init__(self, engine): + self.engine = engine + self.url = "" + self.username = "" + self.password = "" + self.ok = False + self.error = "" + self.last = 0 + self.answers = 0 + self._mark: tuple[str, str, str] | None = None # 上次处理到的 (time, domain, client) + self._client: httpx.Client | None = None + + @property + def enabled(self) -> bool: + return bool(self.url) + + def configure(self, url: str, username: str, password: str): + url = (url or "").strip().rstrip("/") + username, password = (username or "").strip(), password or "" + if (url, username, password) != (self.url, self.username, self.password): + self._mark = None + self._close_client() + self.ok, self.error = False, "" + self.url, self.username, self.password = url, username, password + + def _close_client(self): + if self._client is not None: + try: + self._client.close() + except Exception: # noqa: BLE001 + pass + self._client = None + + def _connect(self): + """登录拿会话;没配账号密码时跳过登录(AdGuard 只在内网监听时可以免认证)""" + self._close_client() + c = httpx.Client(base_url=self.url, timeout=10.0) + if self.username or self.password: + r = c.post("/control/login", json={"name": self.username, "password": self.password}) + r.raise_for_status() + self._client = c + + def _fetch(self) -> list: + if self._client is None: + self._connect() + r = self._client.get("/control/querylog", params={"limit": self.PAGE, "response_status": "all"}) + if r.status_code in (401, 403): # 会话过期:重登一次再取 + self._connect() + r = self._client.get("/control/querylog", params={"limit": self.PAGE, "response_status": "all"}) + r.raise_for_status() + data = r.json() + # 新版本返回 {"entries": [...]},旧版本直接返回数组 + return (data.get("entries") if isinstance(data, dict) else data) or [] + + def poll(self): + if not self.enabled: + return + try: + entries = self._fetch() + except Exception as e: # noqa: BLE001 + self._close_client() + self.ok, self.error, self.last = False, _err(e), int(time.time()) + return + new = [] + for x in entries: # 最新的在前,遇到上次处理过的就停 + mark = (str(x.get("time") or ""), str(x.get("domain") or ""), str(x.get("client") or "")) + if self._mark and mark <= self._mark: + break + new.append(x) + if entries: + self._mark = max((str(x.get("time") or ""), str(x.get("domain") or ""), str(x.get("client") or "")) + for x in entries) + for x in reversed(new): + parsed = self._parse_entry(x) + if parsed: + name, ips, cnames, client = parsed + self.engine.add_dns_answer(client, name, ips, cnames, source="adguard") + self.answers += 1 + self.ok, self.error, self.last = True, "", int(time.time()) + + @classmethod + def _parse_entry(cls, x: dict): + """返回 (域名, [(IP, TTL)...], [CNAME...], 客户端IP);没有 A/AAAA 应答(被拦截、NXDOMAIN)时返回 None""" + name = (x.get("domain") or (x.get("question") or {}).get("name") or "").strip().lower().rstrip(".") + client = (x.get("client_ip") or x.get("client") or "").strip() + answer = x.get("answer") + ips, cnames = [], [] + if isinstance(answer, (list, tuple)): + for v in answer: + if isinstance(v, dict): + typ = str(v.get("type") or "").upper() + val = str(v.get("value") or "").strip().rstrip(".") + ttl = int(v.get("ttl") or 0) + if typ == "CNAME" and val: + cnames.append(val.lower()) + elif typ in ("A", "AAAA") and val: + try: + ipaddress.ip_address(val) + except ValueError: + continue + ips.append((val, ttl)) + else: + cls._parse_rr(str(v), ips, cnames) + else: + if isinstance(answer, dict): + answer = "\n".join(str(v) for v in answer.values()) + cls._parse_rr(str(answer or ""), ips, cnames) + if not ips or not name or not client: + return None + return name, ips, cnames, client + + @classmethod + def _parse_rr(cls, text: str, ips: list, cnames: list): + for line in text.splitlines(): + m = cls._RR.search(line) + if not m: + continue + typ, val = m.group(3).upper(), m.group(4).rstrip(".") + ttl = int(m.group(2) or 0) + if typ == "CNAME": + cnames.append(val.lower()) + elif typ in ("A", "AAAA"): + try: + ipaddress.ip_address(val) + except ValueError: + continue + ips.append((val, ttl)) + + def status(self) -> dict: + return {"enabled": self.enabled, "url": self.url, "ok": self.ok, "error": self.error, + "last": self.last, "answers": self.answers} + + def _make(): from app.engine import engine - return SingboxPoller(engine), MosdnsPoller(engine) + return SingboxPoller(engine), MosdnsPoller(engine), AdguardPoller(engine) -singbox, mosdns = _make() +singbox, mosdns, adguard = _make() def apply(db_values: dict): - """按网页 / 环境变量的配置更新两个轮询器和引擎里的旁路代理地址""" + """按网页 / 环境变量的配置更新三个轮询器和引擎里的旁路代理地址""" cfg = effective(db_values) singbox.configure(cfg["singbox_url"], cfg["singbox_secret"]) mosdns.configure(cfg["mosdns_url"]) + adguard.configure(cfg["adguard_url"], cfg["adguard_username"], cfg["adguard_password"]) ips = {x.strip() for x in cfg["proxy_ips"].replace("\n", ",").split(",") if x.strip()} if not ips and singbox.host_ip(): ips.add(singbox.host_ip()) diff --git a/backend/app/main.py b/backend/app/main.py index 3125941..a8f3f6b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -103,6 +103,7 @@ async def lifespan(app: FastAPI): # 外部数据源:没配置时 poll() 直接返回 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) 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) diff --git a/backend/app/reclass.py b/backend/app/reclass.py new file mode 100644 index 0000000..6319c9d --- /dev/null +++ b/backend/app/reclass.py @@ -0,0 +1,92 @@ +"""按当前规则重算历史分类。 + +以 host_domain_day(每天每终端每域名,31 天)为源:对每一行用现在的规则重新分类,分类结果变化的行, +把 traffic_day 里对应 (天, 终端, 旧应用) 的上下行与连接数按字节占比搬到新应用,并把 host_domain_day 的 +app 标记同步改掉(保证重复执行幂等)。host_domain_day 只覆盖有域名、或进了当分钟 Top30 IP 的流量, +其余流量保持原分类;traffic_minute / traffic_hour 不动,随保留期自然滚动。 +""" +import ipaddress +import time +from collections import defaultdict + +from sqlalchemy import select + +from app.models import HostDomainDay, TrafficDay + + +def _is_ip(s: str) -> bool: + try: + ipaddress.ip_address(s) + return True + except ValueError: + return False + + +def reclassify(engine, db) -> dict: + t0 = time.time() + classifier = engine.classifier + 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) + ).all() + for ts, host, dom, old_app, b in rows: + if not b: + continue + if _is_ip(dom): + new_app = classifier.classify(dom, 6, 0, None, None) + else: + new_app = classifier.classify("0.0.0.0", 6, 443, dom, None) + new_app = new_app or "other" + if new_app != old_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: + db.bulk_update_mappings(HostDomainDay, marks) + + # 先在内存里把搬移汇总(同一个目标行可能来自多个源、同一个源可能拆给多个目标),再统一落库 + srcs: dict = defaultdict(lambda: [0, 0, 0]) # (ts, host, 旧应用) -> 要减掉的 [up, down, flows] + dsts: dict = defaultdict(lambda: [0, 0, 0]) # (ts, host, 新应用) -> 要加上的 [up, down, flows] + for (ts, host, old_app), tgts in moves.items(): + src = db.get(TrafficDay, {"ts": ts, "host": host, "app": old_app}) + if src is None: + continue + total_old = src.up + src.down + if total_old <= 0: + continue + ratio_up = src.up / total_old + flows_per_byte = (src.flows or 0) / total_old + remain = total_old + for new_app, b in tgts.items(): + mv = min(b, remain) + if mv <= 0: + break + remain -= mv + up = round(mv * ratio_up) + down = mv - up + flows = min(round(mv * flows_per_byte), src.flows) + d = dsts[(ts, host, new_app)] + d[0] += up + d[1] += down + d[2] += flows + s_ = srcs[(ts, host, old_app)] + s_[0] += up + s_[1] += down + s_[2] += flows + for key, (up, down, flows) in srcs.items(): + src = db.get(TrafficDay, {"ts": key[0], "host": key[1], "app": key[2]}) + if src is None: + continue + src.up = max(src.up - up, 0) + src.down = max(src.down - down, 0) + src.flows = max(src.flows - flows, 0) + for key, (up, down, flows) in dsts.items(): + dst = db.get(TrafficDay, {"ts": key[0], "host": key[1], "app": key[2]}) + if dst is None: + db.add(TrafficDay(ts=key[0], host=key[1], app=key[2], up=up, down=down, flows=flows)) + else: + dst.up += up + dst.down += down + dst.flows += flows + return {"scanned": len(rows), "rows_moved": len(dsts), "bytes_moved": sum(v[0] + v[1] for v in dsts.values()), + "flows_moved": sum(v[2] for v in dsts.values()), "seconds": round(time.time() - t0, 1)} diff --git a/backend/app/ros_poller.py b/backend/app/ros_poller.py index d8b3925..3c62a8e 100644 --- a/backend/app/ros_poller.py +++ b/backend/app/ros_poller.py @@ -1,5 +1,6 @@ """通过 RouterOS REST API(只读,仅 GET)获取终端名称、IP↔MAC、LAN 前缀、接口速率、WAN 线路、DHCP 地址池和系统状态。""" import ipaddress +import json import time from collections import deque @@ -57,7 +58,9 @@ class RosPoller: # 注意:httpx 的 raise_for_status() 成功时返回响应对象本身,不能写成 `raise_for_status() or r.json()` r = c.get(path) r.raise_for_status() - return r.json() + # RouterOS 原样返回客户端上报的 host-name 等字节,可能是 GBK 等非法 UTF-8; + # 坏字节替换成 U+FFFD,别让一台设备把整个轮询搞挂 + return json.loads(r.content.decode("utf-8", "replace")) def poll(self): if not self.enabled: diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 85d4c84..c3a551c 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -103,6 +103,16 @@ def unknown_domains(range: str = Query("week", pattern="^(today|week|month|last7 return stats.unknown_domains(db, *timeutil.range_bounds(range)) +@router.post("/rules/reclassify") +def rules_reclassify(request: Request, user: str = Depends(current_user), db: Session = Depends(get_db)): + """按当前规则重算历史分类:规则或识别库升级后,把历史流量搬到新归属(host_domain_day 覆盖的天数)。""" + from app.reclass import reclassify + res = reclassify(engine, db) + db.commit() + oplog.log(db, request, user, "reclassify", res) + return res + + @router.get("/rules/test") def test_rule(domain: str): """查看一个域名当前会被识别成什么。""" @@ -119,6 +129,9 @@ class SettingsIn(BaseModel): singbox_secret: str | None = Field(None, max_length=255) proxy_ips: str | None = Field(None, max_length=500) mosdns_url: str | None = Field(None, max_length=255) + adguard_url: str | None = Field(None, max_length=255) + adguard_username: str | None = Field(None, max_length=255) + adguard_password: str | None = Field(None, max_length=255) rules_auto_update: bool | None = None @@ -158,7 +171,10 @@ def _integrations(db): saved = {k: _get(db, k, None) for k in integrations.KEYS} cfg = integrations.effective(saved) return {"singbox_url": cfg["singbox_url"], "has_secret": bool(cfg["singbox_secret"]), "proxy_ips": cfg["proxy_ips"], - "mosdns_url": cfg["mosdns_url"], "proxy_auto": integrations.singbox.host_ip()} + "mosdns_url": cfg["mosdns_url"], + "adguard_url": cfg["adguard_url"], "adguard_username": cfg["adguard_username"], + "has_adguard_password": bool(cfg["adguard_password"]), + "proxy_auto": integrations.singbox.host_ip()} def _db_info(): @@ -228,6 +244,7 @@ def system(): "ros_check": poller.check(), "singbox": integrations.singbox.status(), "mosdns": integrations.mosdns.status(), + "adguard": integrations.adguard.status(), "asn": asndb.status(), "proxied_skipped": st["proxied_skipped"], "dns_mosdns": st["dns_mosdns"], "generic_apps": sorted(GENERIC_APPS), @@ -285,10 +302,12 @@ def library_update(request: Request, user: str = Depends(current_user), db: Sess @router.post("/integrations/test") def integrations_test(): - """立即读一次 sing-box 和 MosDNS,返回各自状态(用于“测试连接”按钮)""" + """立即读一次 sing-box / MosDNS / AdGuard Home,返回各自状态(用于“测试连接”按钮)""" integrations.singbox.poll() integrations.mosdns.poll() - return {"singbox": integrations.singbox.status(), "mosdns": integrations.mosdns.status()} + integrations.adguard.poll() + return {"singbox": integrations.singbox.status(), "mosdns": integrations.mosdns.status(), + "adguard": integrations.adguard.status()} @router.post("/ros/test") diff --git a/backend/tests/test_debug13.py b/backend/tests/test_debug13.py new file mode 100644 index 0000000..fdd9448 --- /dev/null +++ b/backend/tests/test_debug13.py @@ -0,0 +1,177 @@ +"""debug13:自有 DNS 服务器(AdGuard Home)接入、中国自营内容商 ASN、历史重算。""" +import gzip + +import pytest + +from app import integrations +from app.asn import asndb +from app.classify import Classifier +from app.engine import Engine +from app.reclass import reclassify + +DAY = 1792900000 # 2026-10-25 附近 +HOST = "IP:192.168.8.20" + + +def _engine(): + e = Engine() + e.set_lan("192.168.8.0/24") + return e + + +# ---------- A2:中国自营内容商的 ASN 识别(无域名流量) ---------- + +def test_asn_chinese_providers(tmp_path): + p = tmp_path / "ip2asn.tsv.gz" + with gzip.open(p, "wt") as f: + # 注意:asndb.load 不排序(真实 iptoasn 文件本身有序),测试数据也要按起始 IP 排好 + f.write("10.11.0.0\t10.11.255.255\t140633\tHK\tBILIBILI HK LIMITED\n") + f.write("10.12.0.0\t10.12.255.255\t215543\tSG\tHOYOVERSE\n") + f.write("10.13.0.0\t10.13.255.255\t133478\tCN\tTENCENT-AS-AP Tencent Cloud Computing Beijing\n") + f.write("10.14.0.0\t10.14.255.255\t137753\tCN\tJD Beijing Jingdong Shangke\n") + f.write("10.15.0.0\t10.15.255.255\t24429\tCN\tTAOBAO Zhejiang Taobao Network\n") + f.write("42.186.0.0\t42.186.186.255\t45062\tCN\tNETEASE-NETWORK NetEase Building No.16\n") + f.write("71.18.1.0\t71.18.5.255\t396986\tUS\tBYTEDANCE\n") + saved = (asndb._v4, asndb._v6, asndb.names, asndb.rows) + try: + assert asndb.load(str(p)) and asndb.rows == 7 + c = Classifier() + assert c.classify("71.18.2.1", 6, 443, None, None) == "douyin" # 字节跳动 → 抖音 + assert c.classify("10.11.1.1", 6, 443, None, None) == "bilibili" + assert c.classify("10.12.1.1", 17, 443, None, None) == "mihoyo" + assert c.classify("10.13.1.1", 6, 443, None, None) == "tencent_cloud" + assert c.classify("10.14.1.1", 6, 443, None, None) == "jd" + assert c.classify("10.15.1.1", 6, 443, None, None) == "taobao" + assert c.classify("42.186.1.1", 6, 443, None, None) == "netease" + assert c.classify("9.9.9.9", 6, 443, None, None) == "https" # 没命中的仍是通用 + finally: + asndb._v4, asndb._v6, asndb.names, asndb.rows = saved + asndb._cache = {} + + +# ---------- A1:AdGuard Home 查询日志 ---------- + +def test_adguard_parse_entry(): + p = integrations.AdguardPoller(_engine()) + row = {"time": "2026-10-25T10:00:00.123+08:00", "domain": "www.iqiyi.com.", "client": "192.168.9.6", + "answer": "www.iqiyi.com. 180 IN CNAME iqiyi.com.\niqiyi.com. 180 IN A 133.86.5.1", "status": "NOERROR"} + assert p._parse_entry(row) == ("www.iqiyi.com", [("133.86.5.1", 180)], ["iqiyi.com"], "192.168.9.6") + # 列表格式的 answer(部分版本) + row2 = {"domain": "a.bilibili.com", "client": "192.168.9.7", + "answer": [{"type": "A", "value": "10.11.0.9", "ttl": 120}]} + assert p._parse_entry(row2) == ("a.bilibili.com", [("10.11.0.9", 120)], [], "192.168.9.7") + # 被拦截 / NXDOMAIN:没有 A/AAAA 应答 + assert p._parse_entry({"domain": "ads.example.com", "client": "192.168.9.6", "answer": ""}) is None + assert p._parse_entry({"domain": "nx.example.com", "client": "192.168.9.6"}) is None + + +class _FakeEngine: + def __init__(self): + self.calls = [] + + def add_dns_answer(self, client, qname, ips, cnames, source="sniff"): + self.calls.append((client, qname, [ip for ip, _ in ips], list(cnames), source)) + + +def test_adguard_poll_delta(monkeypatch): + e = _FakeEngine() + p = integrations.AdguardPoller(e) + p.configure("http://192.168.9.2:3000", "admin", "pw") + assert p.enabled + pages = [ + # 第一页:最新的在前 + [{"time": "T2", "domain": "b.example.com", "client": "192.168.9.6", + "answer": "b.example.com. 60 IN A 10.0.0.2"}, + {"time": "T1", "domain": "a.example.com", "client": "192.168.9.6", + "answer": "a.example.com. 60 IN A 10.0.0.1"}], + # 第二页:一条新的(T3)+ 两条旧的(应被 mark 挡住) + [{"time": "T3", "domain": "c.example.com", "client": "192.168.9.6", + "answer": "c.example.com. 60 IN A 10.0.0.3"}, + {"time": "T2", "domain": "b.example.com", "client": "192.168.9.6", + "answer": "b.example.com. 60 IN A 10.0.0.2"}], + ] + monkeypatch.setattr(p, "_fetch", lambda: pages.pop(0)) + p.poll() + assert [(c, n) for c, n, *_ in e.calls] == [("192.168.9.6", "a.example.com"), ("192.168.9.6", "b.example.com")] + assert e.calls[0][4] == "adguard" + p.poll() + assert [(c, n) for c, n, *_ in e.calls] == [("192.168.9.6", "a.example.com"), ("192.168.9.6", "b.example.com"), + ("192.168.9.6", "c.example.com")] + assert p.ok and p.answers == 3 and not p.error + + +def test_adguard_configure_reset_and_off(): + e = _FakeEngine() + p = integrations.AdguardPoller(e) + p.poll() # 未配置:直接返回 + assert not p.enabled and not p.ok + p.configure("http://192.168.9.2:3000", "", "") + p._mark = ("T1", "d", "c") + p.configure("http://192.168.9.2:3000", "", "") # 相同配置:不重置 + assert p._mark == ("T1", "d", "c") + p.configure("http://192.168.9.3:3000", "", "") # 换地址:重置断点和会话 + assert p._mark is None and p._client is None + + +# ---------- A4:历史重算 ---------- + +def test_reclass_moves_history(): + from app.database import SessionLocal + from app.models import HostDomainDay, TrafficDay + s = SessionLocal() + try: + s.add(TrafficDay(ts=DAY, host=HOST, app="cn_net", up=100, down=900, flows=10)) + s.add(HostDomainDay(ts=DAY, host=HOST, domain="v.douyin.com", app="cn_net", base=None, bytes=300)) + 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): + if qname == "v.douyin.com": + return "douyin" + return "cn_net" + + eng = _engine() + eng.classifier = _C() + res = reclassify(eng, s) + s.commit() + assert res["bytes_moved"] >= 300 # 全套件里还有其它测试遗留的行也会被重算,这里只下限 + rows = {(r.app): r for r in s.query(TrafficDay).filter(TrafficDay.ts == DAY, TrafficDay.host == HOST).all()} + assert set(rows) == {"cn_net", "douyin"} + assert (rows["cn_net"].up, rows["cn_net"].down, rows["cn_net"].flows) == (70, 630, 7) + assert (rows["douyin"].up, rows["douyin"].down, rows["douyin"].flows) == (30, 270, 3) + marks = {d.domain: d.app for d in s.query(HostDomainDay).filter(HostDomainDay.host == HOST).all()} + assert marks["v.douyin.com"] == "douyin" and marks["portal.example.cn"] == "cn_net" + # 幂等:再跑一次没有变化 + res2 = reclassify(eng, s) + s.commit() + assert res2["bytes_moved"] == 0 + finally: + s.query(TrafficDay).filter(TrafficDay.host == HOST).delete() + s.query(HostDomainDay).filter(HostDomainDay.host == HOST).delete() + s.commit() + s.close() + + +# ---------- 设置 API ---------- + +def test_settings_api_adguard_fields(): + from fastapi.testclient import TestClient + from app.main import app + with TestClient(app) as c: + if not c.get("/api/auth/status").json()["initialized"]: + c.post("/api/auth/setup", json={"username": "admin", "password": "secret1"}) + h = {"Authorization": "Bearer " + c.post("/api/auth/login", json={"username": "admin", "password": "secret1"}).json()["token"]} + try: + s = c.put("/api/settings", json={"adguard_url": "http://192.168.9.2:3000", + "adguard_username": "admin", "adguard_password": "pw1"}, headers=h).json() + assert s["integrations"]["adguard_url"] == "http://192.168.9.2:3000" + assert s["integrations"]["adguard_username"] == "admin" + assert s["integrations"]["has_adguard_password"] is True + assert "pw1" not in str(s) + assert integrations.adguard.enabled + sysinfo = c.get("/api/system", headers=h).json() + assert sysinfo["adguard"]["enabled"] is True + finally: + c.put("/api/settings", json={"adguard_url": "", "adguard_username": "", "adguard_password": ""}, headers=h) diff --git a/docs/RouterOS配置.md b/docs/RouterOS配置.md index b37802f..d98e24e 100644 --- a/docs/RouterOS配置.md +++ b/docs/RouterOS配置.md @@ -41,6 +41,15 @@ - 在 DHCP 里把 DNS 下发为路由器地址,并开启 `/ip dns set allow-remote-requests=yes`。 - 浏览器 / 系统里的“安全 DNS(DoH)”会绕过嗅探,可以在终端上关闭,或用防火墙拦截常见 DoH 地址。 +### 双层 NAT(路由器下面又挂了爱快等二级路由) + +二级路由的 WAN 口在 ROS-DPI 看来是一台“终端”,它后面的客户端全部藏在二次 NAT 后面:这些客户端的 DNS 查询被二级路由代理,路由器侧看不到,它们的流量只能按 IP 段归成「国内网络 / 未识别」。两步解决: + +1. 让下级客户端的查询落到一台有记录的 DNS 服务器上:在二级路由的 DHCP 里把 DNS 指向下级网络自己的 DNS 服务器(如 AdGuard Home),不要用二级路由自带的 DNS 代理。 +2. 在 **系统设置 → 外部数据源 → AdGuard Home** 填下级那台 DNS 服务器的地址和密码,ROS-DPI 从它的查询日志读取“谁查了哪个域名、解析出哪些 IP”,补全被 NAT 藏掉的域名。 + +注意:下级 DNS 服务器的上游也要用普通 DNS(udp/53),上游设成 DoH 时查询日志里仍能看到域名和结果,但应答延迟和拦截行为会不同。浏览器的“安全 DNS”同样会绕过下级 DNS,需要在下级网络里拦截常见 DoH 地址。 + ## 3. SNI 识别(推荐) ```routeros diff --git a/frontend/src/components/Composition.vue b/frontend/src/components/Composition.vue index 64174e7..4d1212c 100644 --- a/frontend/src/components/Composition.vue +++ b/frontend/src/components/Composition.vue @@ -30,7 +30,7 @@ const { t, locale } = useI18n() const gs = computed(() => props.groups.filter(g => g.total > 0)) const sum = computed(() => gs.value.reduce((s, g) => s + g.total, 0) || 1) const group = computed(() => (props.drill && props.active ? gs.value.find(g => g.id === props.active) : null)) -// 和维盟一样按固定顺序画(不按大小排):配色是 9 色循环,同色的两个大类相隔 9 位,扇区不会挨在一起 +// 和维盟一样按固定顺序画(不按大小排):17 个大类各有专属颜色(--g-*,debug13 去重),相邻扇区不会同色 const CAT_ORDER = Object.keys(CAT_GROUP) const byOrder = (list, order) => [...list].sort((x, y) => order.indexOf(x.id) - order.indexOf(y.id)) const items = computed(() => (group.value diff --git a/frontend/src/i18n/en.js b/frontend/src/i18n/en.js index 5ef884b..011efd6 100644 --- a/frontend/src/i18n/en.js +++ b/frontend/src/i18n/en.js @@ -109,6 +109,9 @@ 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).', + reclassifying: 'Re-classifying…', reclassifyDone: 'Done: moved {b} ({s}s)', }, lib: { title: 'Identification library', total: '{apps} apps, {domains} domain rules', auto: 'Auto update', checkNow: 'Check for updates', checking: 'Checking…', @@ -140,7 +143,12 @@ export default { sbUrl: 'Clash API address (sing-box external_controller)', sbSecret: 'Secret', secretKept: 'Set; leave empty to keep', secretNone: 'Leave empty if none', clearSecret: 'Clear secret', proxyIps: 'Proxy gateway IP', proxyAuto: 'Empty uses {ip}', proxyIpsHint: 'Traffic from this machine to its proxy servers is labeled “Proxy gateway tunnel”; separate several with commas.', mdWhy: 'Queries to a LAN DNS server never cross the router, so DNS sniffing cannot see them. Reading MosDNS audit logs tells which device looked up which domain and the IPs it got.', - mdUrl: 'MosDNS audit API address', asn: 'IP ownership (ASN)', asnOn: 'On', + mdUrl: 'MosDNS audit API address', + agWhy: 'Behind double NAT (an iKuai/router below your main router), upstream DNS of inner clients is proxied away and invisible to the router. Reading the query log of the DNS server they really use (AdGuard Home) identifies inner devices by domain too.', + agUrl: 'AdGuard Home address', agUser: 'Login username (leave empty if none)', agPassword: 'Login password', + agKept: 'Set; leave empty to keep', agNone: 'Leave empty if none', agClear: 'Clear password', + agHint: 'AdGuard Home query log must be on (enabled by default); empty username and password mean unauthenticated access.', + asn: 'IP ownership (ASN)', asnOn: 'On', asnWhy: 'Domain-less traffic is attributed to the owning company by IP (Tencent, Alibaba, Apple, Netflix…). Data from iptoasn.com (public domain), refreshed daily; off without internet access.', asnRows: '{n} ranges, updated {d}.', connected: 'Connected', failed: 'Failed', off: 'Off', conns: '{n} connections now', counted: '{b} counted', answers: '{n} answers read', diff --git a/frontend/src/i18n/zh.js b/frontend/src/i18n/zh.js index 9b4a3a3..2a9f967 100644 --- a/frontend/src/i18n/zh.js +++ b/frontend/src/i18n/zh.js @@ -109,6 +109,9 @@ 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 天里归类变化的历史流量搬到新的应用(可能需要十几秒)。', + reclassifying: '重算中…', reclassifyDone: '已重算:搬移 {b} 字节({s} 秒)', }, lib: { title: '识别库', total: '共 {apps} 个应用、{domains} 条域名规则', auto: '自动更新', checkNow: '立即检查更新', checking: '正在检查…', @@ -140,7 +143,12 @@ export default { sbUrl: 'Clash API 地址(sing-box 的 external_controller)', sbSecret: '密钥(secret)', secretKept: '已设置,不修改请留空', secretNone: '没有设置可留空', clearSecret: '清除密钥', proxyIps: '旁路代理自身的 IP', proxyAuto: '留空则使用 {ip}', proxyIpsHint: '这台机器自己连代理服务器的流量会被识别为“旁路代理隧道”;多个用逗号分隔。', mdWhy: '终端向内网 DNS 查询不经过路由器,DNS 嗅探看不到。读取 MosDNS 审计日志后,能知道每台终端查了哪个域名、解析出了哪些 IP。', - mdUrl: 'MosDNS 审计 API 地址', asn: 'IP 归属库(ASN)', asnOn: '已启用', + mdUrl: 'MosDNS 审计 API 地址', + agWhy: '双层 NAT(路由器下面又挂了爱快等路由)时,下级客户端的 DNS 查询被上游代理,路由器侧看不到。接入它们真正使用的 DNS 服务器(AdGuard Home)的查询日志后,下级终端的流量也能按域名识别。', + agUrl: 'AdGuard Home 地址', agUser: '登录用户名(没有可留空)', agPassword: '登录密码', + agKept: '已设置,不修改请留空', agNone: '没有设置可留空', agClear: '清除密码', + agHint: '需要 AdGuard Home 开启查询日志(默认开启);账号和密码都留空表示免认证访问。', + asn: 'IP 归属库(ASN)', asnOn: '已启用', asnWhy: '没有域名的流量按 IP 归属认出公司(腾讯、阿里、苹果、Netflix……)。数据来自 iptoasn.com(公有领域),容器每天自动更新;没有外网时不启用。', asnRows: '共 {n} 条,更新于 {d}。', connected: '已连接', failed: '连接失败', off: '未启用', conns: '当前 {n} 条连接', counted: '已记 {b}', answers: '已读 {n} 条应答', diff --git a/frontend/src/style.css b/frontend/src/style.css index 768099e..ac9be62 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -35,25 +35,25 @@ --focus: #2b6583; /* 应用大类配色(dataviz validate_palette.js 校验:白底 CVD 相邻 ΔE ≥ 9.1;三色对比度 < 3:1,故图表一律带图例与数值表) */ - /* 应用大类配色照维盟 WayOS「DPI 协议」饼图:ECharts 默认 9 色按 17 个大类的顺序循环(第 10 类起重复),深浅主题相同。 - 颜色有重复,所以图例、表格始终带名称(debug12) */ - --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; + /* 应用大类配色:17 类互异(debug13)——等距色相 + 语义锚定,other/unknown 用中性灰;深浅主题同值。 + 相邻大类色相错开 ≥20°;图例、表格始终带名称(debug12) */ + --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); /* 兼容:设置页的识别来源条形图沿用 */ --r-sm: 8px; @@ -89,23 +89,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; } diff --git a/frontend/src/views/Rules.vue b/frontend/src/views/Rules.vue index 2cb0e9f..3ff9aa0 100644 --- a/frontend/src/views/Rules.vue +++ b/frontend/src/views/Rules.vue @@ -90,7 +90,10 @@
-

{{ t('rules.custom') }}

{{ rules.length }}
+

{{ t('rules.custom') }}

{{ rules.length }} + + {{ reclassRes }}
@@ -250,6 +253,18 @@ async function test() { try { testRes.value = await http.get('/rules/test', { params: { domain: testDomain.value.trim() } }) } catch (e) { formErr.value = e.message } } + +const reclassing = ref(false) +const reclassRes = ref('') +async function reclassify() { + if (!confirm(t('rules.reclassifyConfirm'))) return + reclassing.value = true + reclassRes.value = '' + try { + const r = await http.post('/rules/reclassify') + reclassRes.value = t('rules.reclassifyDone', { b: bytes(r.bytes_moved), s: r.seconds }) + } catch (e) { reclassRes.value = e.message } finally { reclassing.value = false } +}
{{ t('rules.kind') }}{{ t('rules.pattern') }}{{ t('rules.target') }}