feat: 发布 0.6.5,外部数据源接入 AdGuard Home、ASN 识别中国自营内容商、历史分类重算、DPI 配色 17 色去重(debug13)

- 外部数据源新增 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)
This commit is contained in:
Su Yijun committed 2026-10-25 01:24:47 +08:00
1 parent 53eafe8648
commit ff5043bb8e
20 files changed
+594 -64

No files matched your search

+5
View File
@@ -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
+10
View File
@@ -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 协议配色照维盟
+2
View File
@@ -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 段:规则库没认出的域名归「国内网站」,没有域名的国内流量归「国内网络」 |
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.6.4"
__version__ = "0.6.5"
+9 -9
View File
@@ -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]}
+3
View File
@@ -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"]}
+3
View File
@@ -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 段(每天更新)
+152 -5
View File
@@ -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())
+1
View File
@@ -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)
+92
View File
@@ -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)}
+4 -1
View File
@@ -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:
+22 -3
View File
@@ -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")
+177
View File
@@ -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)
+9
View File
@@ -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
+1 -1
View File
@@ -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
+9 -1
View File
@@ -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',
+9 -1
View File
@@ -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} 条应答',
+36 -36
View File
@@ -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;
}
+16 -1
View File
@@ -90,7 +90,10 @@
</div>
<div class="panel">
<div class="panel-h"><h2>{{ t('rules.custom') }}</h2><span class="faint">{{ rules.length }}</span></div>
<div class="panel-h"><h2>{{ t('rules.custom') }}</h2><span class="faint">{{ rules.length }}</span><span class="sp" />
<button class="btn sm" v-if="isAdmin" @click="reclassify" :disabled="reclassing" :title="t('rules.reclassifyHint')">
{{ reclassing ? t('rules.reclassifying') : t('rules.reclassify') }}</button>
<span v-if="reclassRes" class="muted small">{{ reclassRes }}</span></div>
<div class="tbl-wrap">
<table class="tbl">
<thead><tr><th>{{ t('rules.kind') }}</th><th>{{ t('rules.pattern') }}</th><th>{{ t('rules.target') }}</th><th /></tr></thead>
@@ -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 }
}
</script>
<style scoped>
+33 -5
View File
@@ -139,6 +139,28 @@
<span v-if="sys && sys.asn.rows"> {{ t('sources.asnRows', { n: sys.asn.rows.toLocaleString(), d: datetime(sys.asn.updated) }) }}</span>
<span v-else-if="sys && sys.asn.error" class="err"> {{ sys.asn.error }}</span></p>
</div>
<div class="src-box">
<div class="sec">AdGuard Home<span class="sp" />
<span v-if="sys && sys.adguard && sys.adguard.enabled" class="pill" :class="sys.adguard.ok ? 'ok' : 'bad'">{{ sys.adguard.ok ? t('sources.connected') : t('sources.failed') }}</span>
<span v-else class="pill grey">{{ t('sources.off') }}</span></div>
<p class="faint small">{{ t('sources.agWhy') }}</p>
<label for="ag-url">{{ t('sources.agUrl') }}</label>
<input id="ag-url" class="input addr" v-model.trim="src.adguard_url" placeholder="http://192.168.9.2:3000" autocomplete="off" />
<label for="ag-user">{{ t('sources.agUser') }}</label>
<input id="ag-user" class="input" v-model.trim="src.adguard_username" autocomplete="off" />
<label for="ag-password">{{ t('sources.agPassword') }}</label>
<div class="row">
<input id="ag-password" class="input" type="password" v-model="src.adguard_password" autocomplete="new-password"
:placeholder="s.integrations.has_adguard_password ? t('sources.agKept') : t('sources.agNone')" />
<button v-if="s.integrations.has_adguard_password" type="button" class="btn sm" @click="clearAdguardPassword">{{ t('sources.agClear') }}</button>
</div>
<div class="faint small">{{ t('sources.agHint') }}</div>
<div v-if="sys && sys.adguard && sys.adguard.enabled" class="st small">
<span>{{ t('settings.last') }} {{ ago(sys.adguard.last) }}</span>
<span>{{ t('sources.answers', { n: sys.adguard.answers.toLocaleString() }) }}</span>
<span class="err" v-if="sys.adguard.error">{{ sys.adguard.error }}</span>
</div>
</div>
<div class="acts">
<span class="err" v-if="srcErr">{{ srcErr }}</span><span class="sp" />
<button type="button" class="btn" @click="testSources" :disabled="testing">{{ testing ? t('setup.testing') : t('settings.test') }}</button>
@@ -236,7 +258,7 @@ const setTab = k => router.replace({ query: k === 'status' ? {} : { tab: k } })
// 有问题的标签页上标一个红点
const badge = computed(() => ({
access: !!(sys.value && sys.value.ros_check && sys.value.ros_check.checked && sys.value.ros_check.rules.some(r => r.problems.length)),
sources: !!(sys.value && ((sys.value.singbox.enabled && !sys.value.singbox.ok) || (sys.value.mosdns.enabled && !sys.value.mosdns.ok))),
sources: !!(sys.value && ((sys.value.singbox.enabled && !sys.value.singbox.ok) || (sys.value.mosdns.enabled && !sys.value.mosdns.ok) || (sys.value.adguard && sys.value.adguard.enabled && !sys.value.adguard.ok))),
}))
async function loadSys() {
@@ -260,20 +282,23 @@ onMounted(async () => {
})
// ---------- 外部数据源 ----------
const src = reactive({ singbox_url: '', singbox_secret: '', proxy_ips: '', mosdns_url: '' })
const src = reactive({ singbox_url: '', singbox_secret: '', proxy_ips: '', mosdns_url: '', adguard_url: '', adguard_username: '', adguard_password: '' })
const srcErr = ref('')
const srcSaved = ref(false)
const testing = ref(false)
function fillSources() {
const i = s.value.integrations
Object.assign(src, { singbox_url: i.singbox_url, singbox_secret: '', proxy_ips: i.proxy_ips, mosdns_url: i.mosdns_url })
Object.assign(src, { singbox_url: i.singbox_url, singbox_secret: '', proxy_ips: i.proxy_ips, mosdns_url: i.mosdns_url,
adguard_url: i.adguard_url, adguard_username: i.adguard_username, adguard_password: '' })
}
async function saveSources() {
srcErr.value = ''
try {
// 密钥留空表示不修改;要清除用“清除密钥”按钮
const body = { singbox_url: src.singbox_url, proxy_ips: src.proxy_ips, mosdns_url: src.mosdns_url }
// 密钥留空表示不修改;要清除用“清除密钥/密码”按钮
const body = { singbox_url: src.singbox_url, proxy_ips: src.proxy_ips, mosdns_url: src.mosdns_url,
adguard_url: src.adguard_url, adguard_username: src.adguard_username }
if (src.singbox_secret) body.singbox_secret = src.singbox_secret
if (src.adguard_password) body.adguard_password = src.adguard_password
s.value = await http.put('/settings', body)
fillSources()
srcSaved.value = true
@@ -284,6 +309,9 @@ async function saveSources() {
async function clearSecret() {
try { s.value = await http.put('/settings', { singbox_secret: '' }); fillSources() } catch (e) { srcErr.value = e.message }
}
async function clearAdguardPassword() {
try { s.value = await http.put('/settings', { adguard_password: '' }); fillSources() } catch (e) { srcErr.value = e.message }
}
async function testSources() {
testing.value = true
try {