diff --git a/CHANGELOG.md b/CHANGELOG.md index ae90227..9aa96bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.1.4] - 2026-10-22 + +debug3 + +- 应用分类改成 Panabit 那样的三级:**应用大类 → 小类 → 应用**。10 个大类(影音娱乐、网络游戏、社交通讯、下载更新、办公学习、购物生活、资讯阅读、智能设备、网络基础、未识别),41 个小类(新增手机游戏、主机游戏、游戏加速、语音通话、电子邮件、应用商店、视频会议、开发工具、远程控制、在线教育、出行地图、银行、证券理财、阅读漫画、监控摄像、系统服务、CDN 云服务、代理隧道、安全软件等)。 +- 内置应用从 100 多个增加到 277 个:微信视频号、B 站直播、咪咕、央视频、各大银行和券商、ToDesk / 向日葵、DeepSeek / Kimi / 豆包 / 通义 等。 +- 没法识别具体应用的流量,如果 CNAME 指向阿里云、腾讯云、网宿、Akamai、Cloudflare 等 CDN,会归到对应的 CDN 厂商,不再是“其他”。ICMP、GRE / ESP(VPN)按 IP 协议识别。 +- 已有的应用 ID 和分类 ID 都保留,历史数据不受影响。 +- 图表全部重做: + - 流量构成:双层环图(内环大类、外环小类)加带数值的排行表,点击可筛选应用。 + - 流量趋势:可切换“按应用大类”堆叠图和“上行 / 下行”图;标出峰值。 + - Top 应用 / Top 终端:带名次和占比。 + - 流量流向:中间一列改成应用大类,连线颜色表示大类。 + - 配色按应用大类固定,用 dataviz 校验脚本检查过色盲区分度,深浅色主题各一套。 +- 终端可以重命名,**按 MAC**(换 IP 名字不变)或**按 IP**(适合随机 MAC 的手机、拿不到 MAC 的终端)。主机监控页新增“命名管理”,可以给还没上线的设备提前命名;两种都设置时 MAC 优先。旧版本的备注名会自动迁移。 +- 系统设置的 DNS 嗅探卡片说明了它的作用,并显示应答来自哪台 DNS 服务器(内网 / 外网),方便确认内网自建 DNS 的情况是否嗅探到了。接入向导和文档补充了内网自建 DNS(AdGuard Home / Pi-hole 等)的说明。 + ## [0.1.3] - 2026-10-22 - 修复:配置 RouterOS API(`ROS_URL`)后,概览页报“服务器内部错误:AttributeError: 'Response' object has no attribute 'get'”,页面打不开。原因是读取 RouterOS 返回数据时把 HTTP 响应对象当成了 JSON。 diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 7239023..145ff81 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -8,6 +8,7 @@ Source & docs: https://github.com/suyijun8182/ros-dpi ## What's new +- **0.1.4**: Panabit-style three-level app library (10 groups, 41 categories, 277 apps; CDN attribution via CNAME); redesigned charts (traffic-mix donut, trend stacked by app group, ranked lists, Sankey by group); name hosts by MAC or IP; DNS sniffer card shows which DNS servers answers come from. - **0.1.3**: fix the 500 error on the overview page when the RouterOS API (`ROS_URL`) is configured; clearer RouterOS connection errors. - **0.1.2**: first-run setup wizard: enter your MySQL connection in the browser, like EasySub. The compose file no longer needs database settings. A README guide covers creating the database. - **0.1.1**: full `docker-compose.yml` on this page (no `.env` needed), first-run notes, database auto-created on external MySQL. @@ -16,7 +17,8 @@ Source & docs: https://github.com/suyijun8182/ros-dpi ## Features - IPFIX / NetFlow v9 collector (udp/2055), TZSP DNS sniffer (udp/37008), optional read-only RouterOS REST polling -- 100+ built-in apps in 20+ categories; custom domain / CIDR / port rules +- 277 built-in apps in 41 categories under 10 app groups (Panabit-style); custom domain / CIDR / port rules +- Name hosts by MAC or by IP - Per-minute / hour / day aggregation in MySQL ## Quick start diff --git a/README.md b/README.md index 1a25fd2..7a58f4e 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,15 @@ ### 能做什么 -- **全网概览**:今日 / 本周 / 本月流量,此刻的上下行速率,流量构成(按应用分类),Top 应用与 Top 终端,24 小时与 30 天趋势。 -- **主机监控**:所有终端的 IP、MAC、主机名、在线状态、当前速率、今日 / 本周 / 本月流量,可以给设备起备注名;详情页可看该终端的应用排行、分类占比和访问过的域名。 -- **应用统计**:100 多个内置应用(抖音、B 站、微信、Steam、Apple 更新、ChatGPT……)分 20 多个类别,可以查看每个应用被哪些终端使用。 -- **流量流向**:桑基图展示 终端 → 应用分类 → 应用 的流向。 +- **全网概览**:今日 / 本周 / 本月流量,此刻的上下行速率,流量构成环图(应用大类 → 小类),按应用大类堆叠的流量趋势,Top 应用与 Top 终端排行。 +- **主机监控**:所有终端的 IP、MAC、主机名、在线状态、当前速率、今日 / 本周 / 本月流量,可以按 MAC 或按 IP 给设备命名(也能提前给还没上线的设备命名);详情页可看该终端的应用排行、应用构成、按大类的趋势和访问过的域名。 +- **应用统计**:参照 Panabit 分三级(应用大类 → 小类 → 应用):10 个大类、41 个小类、270 多个内置应用(抖音、B 站、微信视频号、Steam、银行、券商、ToDesk、DeepSeek……)。识别不出具体应用的流量,会按 CDN 的 CNAME 归到阿里云 / 腾讯云 / Akamai 等厂商。可以查看每个应用被哪些终端使用。 +- **流量流向**:桑基图展示 终端 → 应用大类 → 应用 的流向。 - **识别规则**:按域名后缀、IP 段、端口自定义识别规则;列出“未识别域名 Top”,一键归类。 - 中英双语,深浅色主题,手机可用。 +![应用统计](docs/images/apps-light.png) + ![流量流向](docs/images/flow-light.png) ### 工作原理 @@ -37,7 +39,9 @@ RouterOS ◀─REST 只读(可选)── 主机名、IP↔MAC、WAN 口速 - 应用识别靠 DNS:终端访问 `v26.douyinvod.com` 时,路由器把 DNS 应答镜像一份过来,之后发往该 IP 的流量就归到“抖音”。查不到域名时,依次按 IP 段、端口兜底(HTTPS / QUIC 等)。 - 终端用 MAC 区分(来自 ROS 的 ARP / DHCP,或流记录中的源 MAC),IP 变化不影响统计。 -**已知局限**:终端使用 DoH / DoT 加密 DNS,或者在路由器之外解析 DNS 时,这部分流量只能识别为 HTTPS / QUIC。可以在终端或路由器上禁用加密 DNS 来提高识别率。 +**已知局限**:终端使用 DoH / DoT 加密 DNS 时,这部分流量只能识别为 HTTPS / QUIC。可以在终端或路由器上禁用加密 DNS 来提高识别率。 + +**内网有自己的 DNS 服务器(AdGuard Home / Pi-hole 等)时**:DNS 嗅探照样能用。终端查询内网 DNS 走的是交换机,路由器看不到;但内网 DNS 向上游(如 223.5.5.5)查询时,应答要经过路由器,会被 `forward` 那条规则嗅探到,同样能得到“IP → 域名”的对应关系。前提是内网 DNS 的上游用普通 DNS(udp/53);上游设成 DoH / DoT 时路由器看不到域名,可以把上游改成普通 DNS,或者改成路由器地址。**系统设置 → DNS 嗅探** 里的“应答来源”会显示应答来自哪台 DNS 服务器。 ### 部署 @@ -189,16 +193,16 @@ backend/.venv/bin/python scripts/simulate.py --minutes 5 **ROS-DPI** shows per-device traffic and per-application usage (today / this week / this month) for networks behind a MikroTik RouterOS router, in the spirit of Panabit-style DPI dashboards. -- **Overview**: totals, live rate, traffic mix by category, top apps and hosts, trends. -- **Hosts**: every device with IP, MAC, hostname, online state, live rate and daily / weekly / monthly totals; per-host app breakdown and visited domains. -- **Applications**: 100+ built-in apps in 20+ categories. -- **Flows**: a Sankey diagram from hosts through categories to apps. +- **Overview**: totals, live rate, a two-ring traffic-mix donut (app group → category), trends stacked by app group, ranked top apps and hosts. +- **Hosts**: every device with IP, MAC, hostname, online state, live rate and daily / weekly / monthly totals; name devices by MAC or by IP (even before they appear); per-host app breakdown and visited domains. +- **Applications**: a Panabit-style three-level library: 10 app groups, 41 categories, 270+ built-in apps. Traffic that can't be tied to an app is attributed to its CDN (Alibaba Cloud, Tencent Cloud, Akamai, Cloudflare…) via CNAME. +- **Flows**: a Sankey diagram from hosts through app groups to apps. - **Rules**: custom domain / CIDR / port rules, and a list of top unidentified domains to classify. - Chinese and English UI, light and dark themes, works on phones. **How it works.** RouterOS exports IPFIX (`/ip traffic-flow`) for byte counts, and mirrors DNS answers via `sniff-tzsp` so each remote IP can be mapped back to the domain the client looked up. A rule library maps domains to apps. An optional read-only REST user provides hostnames, IP-to-MAC, WAN rate and LAN prefixes. Data is aggregated per minute, hour and day into MySQL. -**Limitations.** Clients using DoH/DoT (or resolving DNS elsewhere) show up as generic HTTPS/QUIC. +**Limitations.** Clients using DoH/DoT show up as generic HTTPS/QUIC. A LAN DNS server (AdGuard Home, Pi-hole…) works fine as long as its upstream is plain DNS on udp/53: its upstream answers pass through the router and are sniffed by the `forward` rule. **Deploy.** ROS-DPI stores data in **MySQL** (5.7 / 8.x) and assumes you already have one. Create a database and user for it: diff --git a/backend/app/__init__.py b/backend/app/__init__.py index ae73625..bbab024 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1 +1 @@ -__version__ = "0.1.3" +__version__ = "0.1.4" diff --git a/backend/app/classify/__init__.py b/backend/app/classify/__init__.py index d8ff9e2..cb3274a 100644 --- a/backend/app/classify/__init__.py +++ b/backend/app/classify/__init__.py @@ -1,6 +1,7 @@ """把一条流(远端 IP + 端口 + 域名)识别为应用。 -优先级:自定义规则 > 域名后缀(先查询名再 CNAME,最长后缀优先)> CIDR > 端口 > other。 +分三级:应用大类(group)→ 应用小类(category)→ 应用(app),与 Panabit 的应用分类方式一致。 +优先级:自定义规则 > 域名后缀(先查询名再 CNAME,最长后缀优先)> CIDR > 端口 > IP 协议号 > other。 """ import ipaddress import os @@ -11,6 +12,8 @@ import yaml RULES_FILE = os.path.join(os.path.dirname(__file__), "apps.yaml") UNKNOWN_APP = "other" GENERIC_APPS = {"https", "quic", "http", UNKNOWN_APP} # 这些应用下的域名算"未识别" +# 只能说明协议或 CDN 厂商、说明不了具体应用的分类,不计入"应用识别率" +UNIDENTIFIED_CATEGORIES = {"unknown", "web", "network", "cdn"} @dataclass @@ -33,6 +36,7 @@ class Classifier: def __init__(self, rules_file: str = RULES_FILE): with open(rules_file, encoding="utf-8") as f: data = yaml.safe_load(f) + self.groups: dict[str, dict] = data["groups"] self.categories: dict[str, dict] = data["categories"] self._base_apps = data["apps"] self.load_custom([]) @@ -43,9 +47,12 @@ class Classifier: self.domains: dict[str, str] = {} self.nets: list[tuple[ipaddress._BaseNetwork, str]] = [] self.ports: list[tuple[int, int, int, str]] = [] + self.protos: dict[int, str] = {} for a in self._base_apps: self.apps[a["id"]] = App(a["id"], a["name"], a.get("name_en", a["name"]), a["category"]) self._add(a["id"], a.get("domains", []), a.get("cidrs", []), a.get("ports", []), first_wins=True) + for p in a.get("protos", []): + self.protos.setdefault(int(p), a["id"]) self.apps[UNKNOWN_APP] = App(UNKNOWN_APP, "其他", "Other", "unknown") custom_nets, custom_ports = [], [] @@ -84,6 +91,7 @@ class Classifier: if not name: return None parts = name.lower().strip(".").split(".") + # 从完整域名开始逐级去掉左边一段;单段名(如 dns.google 的 google)不参与匹配 for i in range(len(parts) - 1): hit = self.domains.get(".".join(parts[i:])) if hit: @@ -110,6 +118,8 @@ class Classifier: if p == proto and lo <= port <= hi: app = aid break + if app is None: + app = self.protos.get(proto) app = app or UNKNOWN_APP if len(self._cache) > 200_000: self._cache.clear() @@ -120,4 +130,7 @@ class Classifier: return self.apps.get(app_id) or App(app_id, app_id, app_id, "unknown") def category_info(self, cat: str) -> dict: - return self.categories.get(cat) or {"name": cat, "name_en": cat} + return self.categories.get(cat) or {"group": "unknown", "name": cat, "name_en": cat} + + def group_info(self, group: str) -> dict: + return self.groups.get(group) or {"name": group, "name_en": group} diff --git a/backend/app/classify/apps.yaml b/backend/app/classify/apps.yaml index e870c41..291b0af 100644 --- a/backend/app/classify/apps.yaml +++ b/backend/app/classify/apps.yaml @@ -1,41 +1,84 @@ -# 应用识别规则库 +# 应用识别规则库(参照 Panabit 的 应用大类 → 应用小类 → 应用 三级结构) # domains: 后缀匹配(douyin.com 同时匹配 www.douyin.com)。先匹配终端查询的域名,再匹配 CNAME。 # cidrs: 目的 IP 段(没有 DNS 记录时使用) # ports: "tcp/443"、"udp/3478-3481" 形式,最后兜底 -# 欢迎通过 PR 补充;用户也可以在 设置 → 识别规则 里添加自定义规则(优先级最高)。 +# protos: IP 协议号(1=ICMP、47=GRE、50=ESP、58=ICMPv6) +# 欢迎通过 PR 补充;用户也可以在 识别规则 页添加自定义规则(优先级最高)。 +# +# 已发布过的应用 ID 和分类 ID 不要删除或改名:历史流量按应用 ID 存储。 +# 应用不再使用时,保留条目、清空 domains 即可。 + +groups: + media: {name: 影音娱乐, name_en: Media} + game: {name: 网络游戏, name_en: Gaming} + social: {name: 社交通讯, name_en: Social & messaging} + transfer: {name: 下载更新, name_en: Downloads & updates} + work: {name: 办公学习, name_en: Work & study} + life: {name: 购物生活, name_en: Shopping & life} + info: {name: 资讯阅读, name_en: News & reading} + iot: {name: 智能设备, name_en: Smart devices} + base: {name: 网络基础, name_en: Infrastructure} + unknown: {name: 未识别, name_en: Unknown} categories: - video: {name: 视频, name_en: Video} - short_video: {name: 短视频, name_en: Short video} - live: {name: 直播, name_en: Live} - music: {name: 音乐音频, name_en: Audio} - social: {name: 社交社区, name_en: Social} - im: {name: 即时通讯, name_en: Messaging} - game: {name: 游戏, name_en: Gaming} - shopping: {name: 购物, name_en: Shopping} - news: {name: 资讯阅读, name_en: News} - search: {name: 搜索门户, name_en: Search & portal} - cloud: {name: 网盘存储, name_en: Cloud storage} - download: {name: 下载P2P, name_en: Download & P2P} - update: {name: 系统更新, name_en: Software update} - ai: {name: AI 工具, name_en: AI} - work: {name: 办公开发, name_en: Work & dev} - finance: {name: 金融支付, name_en: Finance} - life: {name: 生活出行, name_en: Lifestyle} - ads: {name: 广告统计, name_en: Ads & analytics} - iot: {name: 智能家居, name_en: Smart home} - network: {name: 网络协议, name_en: Network protocol} - web: {name: 通用网页, name_en: Web} - unknown: {name: 未识别, name_en: Unknown} + video: {group: media, name: 在线视频, name_en: Video} + short_video: {group: media, name: 短视频, name_en: Short video} + live: {group: media, name: 直播, name_en: Live streaming} + music: {group: media, name: 音乐音频, name_en: Music & audio} + game: {group: game, name: 游戏平台, name_en: Game platforms} + mobile_game: {group: game, name: 手机游戏, name_en: Mobile games} + console_game: {group: game, name: 主机游戏, name_en: Console games} + game_tool: {group: game, name: 游戏加速, name_en: Game boosters} + im: {group: social, name: 即时通讯, name_en: Messaging} + social: {group: social, name: 社交社区, name_en: Social} + voip: {group: social, name: 语音通话, name_en: Voice & calls} + mail: {group: social, name: 电子邮件, name_en: Mail} + download: {group: transfer, name: 下载P2P, name_en: Download & P2P} + cloud: {group: transfer, name: 网盘存储, name_en: Cloud storage} + update: {group: transfer, name: 软件更新, name_en: Software update} + app_store: {group: transfer, name: 应用商店, name_en: App stores} + work: {group: work, name: 办公协作, name_en: Office & collaboration} + meeting: {group: work, name: 视频会议, name_en: Meetings} + dev: {group: work, name: 开发工具, name_en: Developer} + remote: {group: work, name: 远程控制, name_en: Remote access} + ai: {group: work, name: AI 工具, name_en: AI} + education: {group: work, name: 在线教育, name_en: Education} + shopping: {group: life, name: 网络购物, name_en: Shopping} + life: {group: life, name: 生活服务, name_en: Lifestyle} + travel: {group: life, name: 出行地图, name_en: Travel & maps} + finance: {group: life, name: 支付, name_en: Payments} + bank: {group: life, name: 银行, name_en: Banking} + stock: {group: life, name: 证券理财, name_en: Investing} + news: {group: info, name: 新闻资讯, name_en: News} + search: {group: info, name: 搜索门户, name_en: Search & portal} + reading: {group: info, name: 阅读漫画, name_en: Reading & comics} + iot: {group: iot, name: 智能家居, name_en: Smart home} + camera: {group: iot, name: 监控摄像, name_en: Cameras} + network: {group: base, name: 网络协议, name_en: Network protocol} + system: {group: base, name: 系统服务, name_en: OS services} + cdn: {group: base, name: CDN 云服务, name_en: CDN & cloud} + proxy: {group: base, name: 代理隧道, name_en: VPN & tunnels} + security: {group: base, name: 安全软件, name_en: Security} + ads: {group: base, name: 广告统计, name_en: Ads & analytics} + web: {group: base, name: 通用网页, name_en: Web} + unknown: {group: unknown, name: 未识别, name_en: Unknown} apps: - # ---------- 短视频 / 视频 ---------- + # ================= 影音娱乐 ================= + # ---------- 短视频 ---------- - {id: douyin, 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]} - {id: tiktok, name: TikTok, name_en: TikTok, category: short_video, domains: [tiktok.com, tiktokv.com, tiktokcdn.com, tiktokcdn-us.com, musical.ly, byteoversea.com, ibyteimg.com, ttwstatic.com]} + - {id: wechat_channels, name: 微信视频号, name_en: WeChat Channels, category: short_video, + domains: [finder.video.qq.com, channels.weixin.qq.com]} + - {id: weishi, name: 微视, name_en: Weishi, category: short_video, domains: [weishi.qq.com]} + - {id: xigua, name: 西瓜视频, name_en: Xigua, category: short_video, + 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, 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, @@ -46,25 +89,32 @@ apps: domains: [youku.com, ykimg.com, tudou.com, soku.com, cibntv.net, youku.tv]} - {id: mgtv, name: 芒果TV, name_en: Mango TV, category: video, domains: [mgtv.com, hitv.com, hunantv.com]} - - {id: xiaohongshu, name: 小红书, name_en: Xiaohongshu, category: social, - domains: [xiaohongshu.com, xhscdn.com, xhslink.com, xhscdn.net]} + - {id: sohu_video, name: 搜狐视频, name_en: Sohu Video, category: video, domains: [tv.sohu.com, film.sohu.com]} + - {id: migu_video, name: 咪咕视频, name_en: Migu Video, category: video, domains: [miguvideo.com, cmvideo.cn]} + - {id: cctv, name: 央视频/CCTV, name_en: CCTV, category: video, domains: [cctv.com, cntv.cn, cctvpic.com, yangshipin.cn]} - {id: youtube, name: YouTube, name_en: YouTube, category: video, domains: [youtube.com, googlevideo.com, ytimg.com, youtu.be, youtube-nocookie.com, yt3.ggpht.com, youtubei.googleapis.com]} - {id: netflix, name: Netflix, name_en: Netflix, category: video, domains: [netflix.com, nflxvideo.net, nflximg.net, nflxext.com, nflxso.net, nflximg.com]} - {id: disney, name: Disney+, name_en: Disney+, category: video, domains: [disneyplus.com, disney-plus.net, dssott.com, bamgrid.com, disneystreaming.com]} + - {id: prime_video, name: Prime Video, name_en: Prime Video, category: video, + domains: [primevideo.com, aiv-cdn.net, aiv-delivery.net, pv-cdn.net]} + - {id: hbo, name: HBO Max, name_en: HBO Max, category: video, domains: [max.com, hbomax.com, hbo.com]} + - {id: hulu, name: Hulu, name_en: Hulu, category: video, domains: [hulu.com, hulustream.com, huluim.com]} - {id: apple_tv, name: Apple TV+, name_en: Apple TV+, category: video, domains: [tv.apple.com, hls.itunes.apple.com, play-edge.itunes.apple.com, np-edge.itunes.apple.com]} + # ---------- 直播 ---------- - {id: twitch, name: Twitch, name_en: Twitch, category: live, domains: [twitch.tv, ttvnw.net, jtvnw.net, twitchcdn.net]} - {id: huya, name: 虎牙直播, name_en: Huya, category: live, domains: [huya.com, msstatic.com, huyaimg.com, hy-cdn.com]} - {id: douyu, name: 斗鱼直播, name_en: Douyu, category: live, domains: [douyu.com, douyucdn.cn, douyucdn2.cn, douyuscdn.com]} - - {id: xigua, name: 西瓜视频, name_en: Xigua, category: video, - domains: [ixigua.com, ixiguavideo.com]} - + - {id: bili_live, name: B站直播, name_en: Bilibili Live, category: live, domains: [live.bilibili.com, api.live.bilibili.com]} + - {id: yy, name: YY直播, name_en: YY Live, category: live, domains: [yy.com, yystatic.com, bigo.tv]} + - {id: inke, name: 映客/花椒, name_en: Inke/Huajiao, category: live, domains: [inke.cn, inke.com, huajiao.com]} + - {id: kick, name: Kick, name_en: Kick, category: live, domains: [kick.com]} # ---------- 音乐音频 ---------- - {id: netease_music, name: 网易云音乐, name_en: NetEase Music, category: music, domains: [music.163.com, music.126.net, music.netease.com, iplay.163.com]} @@ -72,47 +122,18 @@ apps: domains: [y.qq.com, music.qq.com, stream.qqmusic.qq.com, qqmusic.qq.com, ws.stream.qqmusic.qq.com]} - {id: kugou, name: 酷狗/酷我, name_en: Kugou/Kuwo, category: music, domains: [kugou.com, kgimg.com, kuwo.cn]} + - {id: migu_music, name: 咪咕音乐, name_en: Migu Music, category: music, domains: [music.migu.cn]} - {id: ximalaya, name: 喜马拉雅, name_en: Ximalaya, category: music, domains: [ximalaya.com, xmcdn.com]} + - {id: lizhi, name: 荔枝/蜻蜓FM, name_en: Lizhi/Qingting FM, category: music, domains: [lizhi.fm, lizhifm.com, qingting.fm, qtfm.cn]} - {id: spotify, name: Spotify, name_en: Spotify, category: music, domains: [spotify.com, scdn.co, spotifycdn.com, spotify.design, pscdn.co]} - {id: apple_music, name: Apple Music, name_en: Apple Music, category: music, - domains: [music.apple.com, aod.itunes.apple.com, audio-ssl.itunes.apple.com, mvod.itunes.apple.com]} + domains: [music.apple.com, aod.itunes.apple.com, audio-ssl.itunes.apple.com, mvod.itunes.apple.com, podcasts.apple.com]} + - {id: youtube_music, name: YouTube Music, name_en: YouTube Music, category: music, domains: [music.youtube.com]} + - {id: soundcloud, name: SoundCloud, name_en: SoundCloud, category: music, domains: [soundcloud.com, sndcdn.com]} - # ---------- 即时通讯 / 社交 ---------- - - {id: wechat, name: 微信, name_en: WeChat, category: im, - domains: [weixin.qq.com, wechat.com, servicewechat.com, weixinbridge.com, wx.qq.com, qpic.cn, qlogo.cn, wxs.qq.com, mmbiz.qpic.cn, tenpay.com, wxapp.tc.qq.com, finder.video.qq.com, channels.weixin.qq.com, szextshort.weixin.qq.com, szshort.weixin.qq.com, szminorshort.weixin.qq.com, extshort.weixin.qq.com, short.weixin.qq.com, dns.weixin.qq.com]} - - {id: qq, name: QQ, name_en: QQ, category: im, - domains: [qq.com, qzone.qq.com, im.qq.com, idqqimg.com, gtimg.cn, qqmail.com, tencent.com, myqcloud.com, qcloud.com, tencent-cloud.net, tencentcs.com]} - - {id: dingtalk, name: 钉钉, name_en: DingTalk, category: work, - domains: [dingtalk.com, dingtalkapps.com, alicdn-dingtalk.com, dingtalk.cn]} - - {id: feishu, name: 飞书, name_en: Lark/Feishu, category: work, - domains: [feishu.cn, larksuite.com, feishucdn.com, larkoffice.com, feishu.net]} - - {id: wework, name: 企业微信, name_en: WeCom, category: work, - domains: [work.weixin.qq.com, wework.qpic.cn, qyapi.weixin.qq.com]} - - {id: weibo, name: 微博, name_en: Weibo, category: social, - domains: [weibo.com, weibo.cn, sinaimg.cn, weibocdn.com, sina.com.cn, sina.cn]} - - {id: zhihu, name: 知乎, name_en: Zhihu, category: social, - domains: [zhihu.com, zhimg.com, zhihu.cn]} - - {id: douban, name: 豆瓣, name_en: Douban, category: social, - domains: [douban.com, doubanio.com]} - - {id: tieba, name: 百度贴吧, name_en: Tieba, category: social, - domains: [tieba.baidu.com, tiebapic.baidu.com]} - - {id: telegram, name: Telegram, name_en: Telegram, category: im, - domains: [telegram.org, t.me, telegram.me, tdesktop.com, telesco.pe, telegra.ph], - cidrs: [91.108.4.0/22, 91.108.8.0/22, 91.108.12.0/22, 91.108.16.0/22, 91.108.56.0/22, 149.154.160.0/20, 95.161.64.0/20, "2001:b28:f23d::/48", "2001:b28:f23f::/48", "2001:67c:4e8::/48"]} - - {id: whatsapp, name: WhatsApp, name_en: WhatsApp, category: im, - domains: [whatsapp.com, whatsapp.net, wa.me]} - - {id: discord, name: Discord, name_en: Discord, category: im, - domains: [discord.com, discord.gg, discordapp.com, discordapp.net, discord.media]} - - {id: facebook, name: Facebook/Instagram, name_en: Meta, category: social, - domains: [facebook.com, fbcdn.net, instagram.com, cdninstagram.com, messenger.com, fb.com, threads.net]} - - {id: twitter, name: X/Twitter, name_en: X/Twitter, category: social, - domains: [twitter.com, x.com, twimg.com, t.co]} - - {id: reddit, name: Reddit, name_en: Reddit, category: social, - domains: [reddit.com, redd.it, redditmedia.com, redditstatic.com]} - - # ---------- 游戏 ---------- + # ================= 网络游戏 ================= - {id: steam, name: Steam, name_en: Steam, category: game, domains: [steampowered.com, steamcommunity.com, steamstatic.com, steamcontent.com, steamserver.net, steamgames.com, steamusercontent.com, valvesoftware.com, steamchina.com, akamai.steamstatic.com]} - {id: epic, name: Epic Games, name_en: Epic Games, category: game, @@ -121,62 +142,103 @@ apps: domains: [game.qq.com, gamesafe.qq.com, wegame.com, wegame.com.cn, gcloud.qq.com, pvp.qq.com, gp.qq.com, tgpa.qq.com, dlied1.qq.com, dlied5.qq.com, gamecenter.qq.com, tencentgames.com, levelinfinite.com]} - {id: netease_games, name: 网易游戏, name_en: NetEase Games, category: game, domains: [game.163.com, nie.netease.com, netease.gg, x19.update.netease.com, gdl.netease.com, update.netease.com]} - - {id: mihoyo, name: 米哈游, name_en: HoYoverse, category: game, - domains: [mihoyo.com, hoyoverse.com, yuanshen.com, genshinimpact.com, hoyolab.com, bhsr.com, starrails.com, mhyurl.cn]} - - {id: xbox, name: Xbox/Microsoft游戏, name_en: Xbox, category: game, - domains: [xbox.com, xboxlive.com, xboxservices.com, gamepass.com, assets1.xboxlive.com]} - - {id: playstation, name: PlayStation, name_en: PlayStation, category: game, - domains: [playstation.com, playstation.net, sonyentertainmentnetwork.com, np.dl.playstation.net, gs2.ww.prod.dl.playstation.net]} - - {id: nintendo, name: Nintendo, name_en: Nintendo, category: game, - domains: [nintendo.com, nintendo.net, nintendo.co.jp, nintendowifi.net]} - {id: blizzard, name: 暴雪战网, name_en: Battle.net, category: game, domains: [battle.net, blizzard.com, blizzard.cn, battlenet.com.cn, blzstatic.cn]} - {id: riot, name: Riot Games, name_en: Riot Games, category: game, domains: [riotgames.com, leagueoflegends.com, riotcdn.net, playvalorant.com]} - {id: ea, name: EA, name_en: EA, category: game, domains: [ea.com, origin.com, eaassets-a.akamaihd.net]} + - {id: ubisoft, name: Ubisoft, name_en: Ubisoft, category: game, domains: [ubisoft.com, ubi.com, ubisoftconnect.com]} + - {id: gog, name: GOG, name_en: GOG, category: game, domains: [gog.com, gog-statics.com]} + - {id: rockstar, name: Rockstar, name_en: Rockstar, category: game, domains: [rockstargames.com]} + - {id: activision, name: 动视, name_en: Activision, category: game, domains: [activision.com, callofduty.com]} + - {id: pubg, name: PUBG/Krafton, name_en: PUBG/Krafton, category: game, domains: [pubg.com, playbattlegrounds.com, krafton.com]} + - {id: minecraft, name: Minecraft, name_en: Minecraft, category: game, domains: [minecraft.net, mojang.com, minecraftservices.com]} + - {id: roblox, name: Roblox, name_en: Roblox, category: game, domains: [roblox.com, rbxcdn.com, roblox.cn]} + - {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, + 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]} + - {id: papergames, name: 叠纸游戏, name_en: Papergames, category: mobile_game, domains: [papegames.com, papegames.cn]} + - {id: supercell, name: Supercell, name_en: Supercell, category: mobile_game, domains: [supercell.com, supercell.net]} + - {id: taptap, name: TapTap, name_en: TapTap, category: mobile_game, domains: [taptap.com, taptap.cn, tapimg.com, taptap.io]} + - {id: xbox, name: Xbox/Microsoft游戏, name_en: Xbox, category: console_game, + domains: [xbox.com, xboxlive.com, xboxservices.com, gamepass.com, assets1.xboxlive.com]} + - {id: playstation, name: PlayStation, name_en: PlayStation, category: console_game, + domains: [playstation.com, playstation.net, sonyentertainmentnetwork.com, np.dl.playstation.net, gs2.ww.prod.dl.playstation.net]} + - {id: nintendo, name: Nintendo, name_en: Nintendo, category: console_game, + domains: [nintendo.com, nintendo.net, nintendo.co.jp, nintendowifi.net]} + - {id: game_booster, name: 游戏加速器, name_en: Game boosters, category: game_tool, + domains: [uu.163.com, xunyou.com, leigod.com, qiyou.cn, biubiu001.com]} - # ---------- 购物 / 生活 / 金融 ---------- - - {id: taobao, 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]} - - {id: jd, 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]} - - {id: meituan, name: 美团, name_en: Meituan, category: life, - domains: [meituan.com, meituan.net, dianping.com, dpfile.com, maoyan.com, sankuai.com]} - - {id: eleme, name: 饿了么, name_en: Ele.me, category: life, - domains: [ele.me, elemecdn.com, eleme.cn]} - - {id: didi, name: 滴滴出行, name_en: DiDi, category: life, - domains: [didiglobal.com, diditaxi.com.cn, didistatic.com, xiaojukeji.com]} - - {id: amap, name: 高德地图, name_en: Amap, category: life, - domains: [amap.com, autonavi.com, gaode.com]} - - {id: ctrip, name: 携程, name_en: Trip.com, category: life, - domains: [ctrip.com, trip.com, c-ctrip.com, ctripcorp.com, tripcdn.com]} - - {id: alipay, name: 支付宝, name_en: Alipay, category: finance, - domains: [alipay.com, alipay.cn, alipay-eco.com, antgroup.com, alipaydns.com, mybank.cn]} - - {id: amazon, name: Amazon, name_en: Amazon, category: shopping, - domains: [amazon.com, amazon.cn, media-amazon.com, ssl-images-amazon.com, amazon.co.jp]} + # ================= 社交通讯 ================= + - {id: wechat, name: 微信, name_en: WeChat, category: im, + domains: [weixin.qq.com, wechat.com, servicewechat.com, weixinbridge.com, wx.qq.com, qpic.cn, qlogo.cn, wxs.qq.com, mmbiz.qpic.cn, tenpay.com, wxapp.tc.qq.com, szextshort.weixin.qq.com, szshort.weixin.qq.com, szminorshort.weixin.qq.com, extshort.weixin.qq.com, short.weixin.qq.com, dns.weixin.qq.com]} + - {id: qq, name: QQ, name_en: QQ, category: im, + domains: [qq.com, qzone.qq.com, im.qq.com, idqqimg.com, gtimg.cn, tencent.com]} + - {id: telegram, name: Telegram, name_en: Telegram, category: im, + domains: [telegram.org, t.me, telegram.me, tdesktop.com, telesco.pe, telegra.ph], + cidrs: [91.108.4.0/22, 91.108.8.0/22, 91.108.12.0/22, 91.108.16.0/22, 91.108.56.0/22, 149.154.160.0/20, 95.161.64.0/20, "2001:b28:f23d::/48", "2001:b28:f23f::/48", "2001:67c:4e8::/48"]} + - {id: whatsapp, name: WhatsApp, name_en: WhatsApp, category: im, + domains: [whatsapp.com, whatsapp.net, wa.me]} + - {id: discord, name: Discord, name_en: Discord, category: im, + domains: [discord.com, discord.gg, discordapp.com, discordapp.net, discord.media]} + - {id: line, name: LINE, name_en: LINE, category: im, domains: [line.me, line-apps.com, line-scdn.net, naver.jp]} + - {id: signal, name: Signal, name_en: Signal, category: im, domains: [signal.org, whispersystems.org, signal.art]} + - {id: kakao, name: KakaoTalk, name_en: KakaoTalk, category: im, domains: [kakao.com, kakaocdn.net]} + - {id: momo, name: 陌陌/探探, name_en: Momo/Tantan, category: im, domains: [immomo.com, momocdn.com, tantanapp.com]} + - {id: soul, name: Soul, name_en: Soul, category: im, domains: [soulapp.cn, soulapp.me]} + - {id: weibo, name: 微博, name_en: Weibo, category: social, + domains: [weibo.com, weibo.cn, sinaimg.cn, weibocdn.com]} + - {id: xiaohongshu, name: 小红书, name_en: Xiaohongshu, category: social, + domains: [xiaohongshu.com, xhscdn.com, xhslink.com, xhscdn.net]} + - {id: zhihu, name: 知乎, name_en: Zhihu, category: social, + domains: [zhihu.com, zhimg.com, zhihu.cn]} + - {id: douban, name: 豆瓣, name_en: Douban, category: social, + domains: [douban.com, doubanio.com]} + - {id: tieba, name: 百度贴吧, name_en: Tieba, category: social, + domains: [tieba.baidu.com, tiebapic.baidu.com]} + - {id: hupu, name: 虎扑, name_en: Hupu, category: social, domains: [hupu.com, hoopchina.com.cn, hupucdn.com]} + - {id: nga, name: NGA 玩家社区, name_en: NGA, category: social, domains: [nga.cn, ngabbs.com, 178.com, nga.178.com]} + - {id: jike, name: 即刻/脉脉, name_en: Jike/Maimai, category: social, domains: [okjike.com, maimai.cn, taou.com]} + - {id: facebook, name: Facebook/Instagram, name_en: Meta, category: social, + domains: [facebook.com, fbcdn.net, instagram.com, cdninstagram.com, messenger.com, fb.com, threads.net]} + - {id: twitter, name: X/Twitter, name_en: X/Twitter, category: social, + domains: [twitter.com, x.com, twimg.com, t.co]} + - {id: reddit, name: Reddit, name_en: Reddit, category: social, + domains: [reddit.com, redd.it, redditmedia.com, redditstatic.com]} + - {id: linkedin, name: LinkedIn, name_en: LinkedIn, category: social, domains: [linkedin.com, licdn.com]} + - {id: pinterest, name: Pinterest, name_en: Pinterest, category: social, domains: [pinterest.com, pinimg.com]} + - {id: snapchat, name: Snapchat, name_en: Snapchat, category: social, domains: [snapchat.com, sc-cdn.net, snap.com]} + - {id: bluesky, name: Bluesky/Mastodon, name_en: Bluesky/Mastodon, category: social, domains: [bsky.app, bsky.social, mastodon.social, joinmastodon.org]} + - {id: quora, name: Quora/Tumblr, name_en: Quora/Tumblr, category: social, domains: [quora.com, tumblr.com]} + - {id: stun, name: 音视频通话(STUN/RTP), name_en: Real-time media, category: voip, ports: [udp/3478-3481, udp/19302-19309]} + - {id: sip, name: SIP 电话, name_en: SIP, category: voip, ports: [udp/5060, tcp/5060, tcp/5061]} + - {id: mail, name: 邮件协议, name_en: Mail protocols, category: mail, ports: [tcp/25, tcp/465, tcp/587, tcp/993, tcp/995, tcp/143, tcp/110]} + - {id: qq_mail, name: QQ邮箱, name_en: QQ Mail, category: mail, domains: [mail.qq.com, exmail.qq.com, qqmail.com]} + - {id: netease_mail, name: 网易邮箱, name_en: NetEase Mail, category: mail, domains: [mail.163.com, mail.126.com, ym.163.com, qiye.163.com, mail.yeah.net]} + - {id: gmail, name: Gmail, name_en: Gmail, category: mail, domains: [mail.google.com, gmail.com, inbox.google.com]} + - {id: outlook, name: Outlook, name_en: Outlook, category: mail, domains: [outlook.com, outlook.live.com, outlook.office365.com, outlook.office.com, hotmail.com]} + - {id: proton, name: Proton, name_en: Proton, category: mail, domains: [proton.me, protonmail.com, protonmail.ch]} - # ---------- 搜索 / 门户 / 资讯 ---------- - - {id: baidu, name: 百度, name_en: Baidu, category: search, - domains: [baidu.com, bdstatic.com, bdimg.com, bcebos.com, bdydns.com, baidubce.com, baiduyun.com, shifen.com]} - - {id: google, name: Google, name_en: Google, category: search, - domains: [google.com, googleapis.com, gstatic.com, google.com.hk, googleusercontent.com, ggpht.com, gvt1.com, gvt2.com, googlesyndication.com, google-analytics.com, googletagmanager.com, recaptcha.net]} - - {id: bing, name: Bing, name_en: Bing, category: search, - domains: [bing.com, bing.net]} - - {id: netease, name: 网易, name_en: NetEase, category: news, - domains: [163.com, 126.net, 127.net, netease.com, ydstatic.com]} - - {id: toutiao, name: 今日头条, name_en: Toutiao, category: news, - domains: [toutiao.com, toutiaocdn.com, toutiaoimg.cn]} - - {id: wikipedia, name: Wikipedia, name_en: Wikipedia, category: news, - domains: [wikipedia.org, wikimedia.org, wiki.wikipedia.org]} - - # ---------- 网盘 / 下载 / 更新 ---------- + # ================= 下载更新 ================= + - {id: thunder, name: 迅雷, name_en: Xunlei, category: download, + domains: [xunlei.com, sandai.net, n0808.com, xlcdn.com, xluser.com], + ports: [tcp/6881-6889, udp/6881-6889]} + - {id: bittorrent, name: BT/P2P, name_en: BitTorrent, category: download, + domains: [tracker.opentrackr.org, bt.byr.cn, open.stealth.si]} - {id: baidu_pan, name: 百度网盘, name_en: Baidu Netdisk, category: cloud, domains: [pan.baidu.com, pcs.baidu.com, d.pcs.baidu.com, baidupcs.com, nd.baidu.com]} - {id: aliyun_drive, name: 阿里云盘/夸克, name_en: Aliyun Drive/Quark, category: cloud, domains: [aliyundrive.com, alipan.com, aliyundrive.net, quark.cn, quarkpan.com, uc.cn, ucweb.com]} + - {id: pan_115, name: 115网盘, name_en: 115 Cloud, category: cloud, domains: [115.com, 115cdn.com, 115cdn.net]} + - {id: tianyi_cloud, name: 天翼云盘/和彩云, name_en: Tianyi/Caiyun, category: cloud, domains: [cloud.189.cn, yun.139.com, caiyun.feixin.10086.cn]} + - {id: pan_123, name: 123云盘/蓝奏云, name_en: 123Pan/Lanzou, category: cloud, domains: [123pan.com, 123pan.cn, lanzou.com, lanzoui.com, lanzoux.com]} + - {id: jianguoyun, name: 坚果云, name_en: Nutstore, category: cloud, domains: [jianguoyun.com]} + - {id: pikpak, name: PikPak, name_en: PikPak, category: cloud, domains: [mypikpak.com]} - {id: onedrive, name: OneDrive, name_en: OneDrive, category: cloud, domains: [onedrive.com, onedrive.live.com, 1drv.com, 1drv.ms, sharepoint.com, storage.live.com]} - {id: icloud, name: iCloud, name_en: iCloud, category: cloud, @@ -185,70 +247,234 @@ apps: domains: [drive.google.com, docs.google.com, drive.usercontent.google.com]} - {id: dropbox, name: Dropbox, name_en: Dropbox, category: cloud, domains: [dropbox.com, dropboxapi.com, dropboxusercontent.com, db.tt]} - - {id: thunder, name: 迅雷, name_en: Xunlei, category: download, - domains: [xunlei.com, sandai.net, n0808.com, xlcdn.com, xluser.com], - ports: [tcp/6881-6889, udp/6881-6889]} - - {id: bittorrent, name: BT/P2P, name_en: BitTorrent, category: download, - domains: [tracker.opentrackr.org, bt.byr.cn, open.stealth.si]} - - {id: apple_update, name: Apple 更新/商店, name_en: Apple updates/App Store, category: update, - domains: [swcdn.apple.com, swdist.apple.com, updates.cdn-apple.com, mesu.apple.com, appldnld.apple.com, oscdn.apple.com, iosapps.itunes.apple.com, osxapps.itunes.apple.com, apps.apple.com, gdmf.apple.com]} - - {id: apple, name: Apple 服务, name_en: Apple services, category: update, - domains: [apple.com, mzstatic.com, itunes.apple.com, apple.com.cn, push.apple.com, aaplimg.com, apple-dns.net, apple.news]} + - {id: mega, name: MEGA, name_en: MEGA, category: cloud, domains: [mega.nz, mega.co.nz, mega.io]} + - {id: synology, name: 群晖 QuickConnect, name_en: Synology QuickConnect, category: cloud, domains: [quickconnect.to, quickconnect.cn, synology.com, synology.cn]} + - {id: apple_update, name: Apple 更新, name_en: Apple updates, category: update, + domains: [swcdn.apple.com, swdist.apple.com, updates.cdn-apple.com, mesu.apple.com, appldnld.apple.com, oscdn.apple.com, gdmf.apple.com]} - {id: windows_update, name: Windows 更新, name_en: Windows Update, category: update, domains: [windowsupdate.com, update.microsoft.com, delivery.mp.microsoft.com, dl.delivery.mp.microsoft.com, tlu.dl.delivery.mp.microsoft.com, download.windowsupdate.com, wustat.windows.com]} - - {id: microsoft, name: Microsoft 服务, name_en: Microsoft services, category: work, - domains: [microsoft.com, office.com, office.net, office365.com, live.com, msn.com, outlook.com, microsoftonline.com, msftconnecttest.com, windows.net, azureedge.net, msedge.net, skype.com, teams.microsoft.com, bing.com.cn]} - - {id: android_update, name: 安卓/手机厂商, name_en: Android vendor services, category: update, - domains: [xiaomi.com, xiaomi.net, mi.com, miui.com, mi-img.com, hicloud.com, huawei.com, dbankcdn.com, vivo.com.cn, vivoglobal.com, oppo.com, heytapmobi.com, coloros.com, heytapdownload.com, oneplus.com, honor.com, hihonor.com, meizu.com, flyme.cn]} - - {id: app_market, name: 应用商店下载, name_en: App store downloads, category: update, - domains: [play.googleapis.com, appstore.huawei.com, app.mi.com, apkpure.com, wandoujia.com, pp.cn, myapp.com, sj.qq.com]} + - {id: chrome_update, name: Chrome/Google 更新, name_en: Chrome updates, category: update, + domains: [dl.google.com, update.googleapis.com, gvt1.com, edgedl.me.gvt1.com]} + - {id: firefox, name: Firefox/Mozilla, name_en: Firefox/Mozilla, category: update, domains: [mozilla.org, mozilla.net, firefox.com, mozilla.com]} + - {id: driver_update, name: 显卡/硬件驱动, name_en: Drivers, category: update, + domains: [nvidia.com, nvidia.cn, geforce.com, geforce.cn, amd.com, intel.com, intel.cn, lenovo.com, lenovo.com.cn, dell.com, hp.com]} + - {id: app_store_apple, name: App Store, name_en: App Store, category: app_store, + domains: [iosapps.itunes.apple.com, osxapps.itunes.apple.com, apps.apple.com, apps.mzstatic.com]} + - {id: app_market, name: 安卓应用商店, name_en: Android app stores, category: app_store, + domains: [play.googleapis.com, play.google.com, appstore.huawei.com, appgallery.huawei.com, app.mi.com, apkpure.com, wandoujia.com, pp.cn, myapp.com, sj.qq.com, appstore.vivo.com.cn, store.heytap.com]} - # ---------- AI / 开发 / 办公 ---------- + # ================= 办公学习 ================= + - {id: dingtalk, name: 钉钉, name_en: DingTalk, category: work, + domains: [dingtalk.com, dingtalkapps.com, alicdn-dingtalk.com, dingtalk.cn]} + - {id: feishu, name: 飞书, name_en: Lark/Feishu, category: work, + domains: [feishu.cn, larksuite.com, feishucdn.com, larkoffice.com, feishu.net]} + - {id: wework, name: 企业微信, name_en: WeCom, category: work, + domains: [work.weixin.qq.com, wework.qpic.cn, qyapi.weixin.qq.com]} + - {id: microsoft, name: Microsoft 365, name_en: Microsoft 365, category: work, + domains: [microsoft.com, office.com, office.net, office365.com, live.com, msn.com, microsoftonline.com, msftconnecttest.com, windows.net, azureedge.net, msedge.net, skype.com, bing.com.cn]} + - {id: wps, name: WPS, name_en: WPS, category: work, + domains: [wps.cn, wps.com, kdocs.cn, kingsoft.com, ksord.com, wpscdn.cn]} + - {id: tencent_docs, name: 腾讯文档, name_en: Tencent Docs, category: work, domains: [docs.qq.com]} + - {id: notion, name: Notion, name_en: Notion, category: work, domains: [notion.so, notion.site, notion.com]} + - {id: yuque, name: 语雀/石墨, name_en: Yuque/Shimo, category: work, domains: [yuque.com, shimo.im]} + - {id: slack, name: Slack, name_en: Slack, category: work, domains: [slack.com, slack-edge.com, slack-msgs.com, slack-imgs.com]} + - {id: atlassian, name: Atlassian, name_en: Atlassian, category: work, domains: [atlassian.net, atlassian.com, bitbucket.org, trello.com]} + - {id: figma, name: Figma/Canva, name_en: Figma/Canva, category: work, domains: [figma.com, canva.com, canva.cn, mastergo.com, js.design]} + - {id: adobe, name: Adobe, name_en: Adobe, category: work, domains: [adobe.com, adobe.io, adobelogin.com, adobecc.com, typekit.net]} + - {id: evernote, name: 印象笔记/Evernote, name_en: Evernote, category: work, domains: [evernote.com, yinxiang.com]} + - {id: tencent_meeting, name: 腾讯会议, name_en: Tencent Meeting, category: meeting, + domains: [meeting.tencent.com, wemeet.qq.com, voovmeeting.com]} + - {id: zoom, name: Zoom, name_en: Zoom, category: meeting, + domains: [zoom.us, zoom.com, zoom.com.cn, zoomgov.com]} + - {id: teams, name: Microsoft Teams, name_en: Microsoft Teams, category: meeting, domains: [teams.microsoft.com, teams.live.com, teams.cdn.office.net]} + - {id: webex, name: Webex, name_en: Webex, category: meeting, domains: [webex.com, wbx2.com, webex.com.cn]} + - {id: google_meet, name: Google Meet, name_en: Google Meet, category: meeting, domains: [meet.google.com]} + - {id: github, name: GitHub, name_en: GitHub, category: dev, + domains: [github.com, githubusercontent.com, githubassets.com, github.io, ghcr.io, github.dev, copilot-proxy.githubusercontent.com]} + - {id: docker, name: Docker Hub, name_en: Docker Hub, category: dev, + domains: [docker.com, docker.io, registry-1.docker.io, production.cloudflare.docker.com, dockerhub.com]} + - {id: dev_mirrors, name: 开发镜像源, name_en: Package mirrors, category: dev, + domains: [pypi.org, pythonhosted.org, npmjs.org, npmjs.com, npmmirror.com, yarnpkg.com, crates.io, golang.org, go.dev, maven.org, mirrors.aliyun.com, mirrors.tuna.tsinghua.edu.cn, mirrors.ustc.edu.cn, debian.org, ubuntu.com, archlinux.org, alpinelinux.org, fedoraproject.org, centos.org]} + - {id: gitlab, name: GitLab/Gitee, name_en: GitLab/Gitee, category: dev, domains: [gitlab.com, gitee.com, gitee.io]} + - {id: stackoverflow, name: Stack Overflow, name_en: Stack Overflow, category: dev, domains: [stackoverflow.com, sstatic.net, stackexchange.com]} + - {id: csdn, name: CSDN/掘金/博客园, name_en: CSDN/Juejin/Cnblogs, category: dev, domains: [csdn.net, juejin.cn, cnblogs.com, oschina.net]} + - {id: jetbrains, name: JetBrains, name_en: JetBrains, category: dev, domains: [jetbrains.com, jetbrains.com.cn]} + - {id: vscode, name: VS Code, name_en: VS Code, category: dev, domains: [vscode.dev, visualstudio.com, vsassets.io, vscode-cdn.net, vscode-unpkg.net]} + - {id: cursor, name: Cursor, name_en: Cursor, category: dev, domains: [cursor.sh, cursor.com, cursorapi.com]} + - {id: ssh, name: SSH/远程桌面协议, name_en: SSH/RDP/VNC, category: remote, ports: [tcp/22, tcp/3389, udp/3389, tcp/5900]} + - {id: todesk, name: ToDesk, name_en: ToDesk, category: remote, domains: [todesk.com, todesk.cn]} + - {id: sunlogin, name: 向日葵, name_en: Sunlogin, category: remote, domains: [oray.com, oray.net, sunlogin.oray.com, orayimg.com]} + - {id: teamviewer, name: TeamViewer, name_en: TeamViewer, category: remote, domains: [teamviewer.com, teamviewer.cn], ports: [tcp/5938, udp/5938]} + - {id: anydesk, name: AnyDesk/RustDesk, name_en: AnyDesk/RustDesk, category: remote, domains: [anydesk.com, rustdesk.com], ports: [tcp/7070]} + - {id: parsec, name: Parsec, name_en: Parsec, category: remote, domains: [parsec.app, parsecgaming.com]} - {id: chatgpt, name: ChatGPT, name_en: ChatGPT, category: ai, domains: [openai.com, chatgpt.com, oaistatic.com, oaiusercontent.com]} - {id: claude, name: Claude, name_en: Claude, category: ai, domains: [claude.ai, anthropic.com, claude.com, claudeusercontent.com]} - {id: gemini, name: Gemini, name_en: Gemini, category: ai, domains: [gemini.google.com, generativelanguage.googleapis.com, aistudio.google.com]} - - {id: cn_ai, name: 国产大模型, name_en: Chinese AI, category: ai, - domains: [deepseek.com, kimi.moonshot.cn, moonshot.cn, kimi.com, doubao.com, yiyan.baidu.com, tongyi.aliyun.com, qianwen.com, chatglm.cn, zhipuai.cn, yuanbao.tencent.com, hunyuan.tencent.com, minimaxi.com, hailuoai.com]} - - {id: github, name: GitHub, name_en: GitHub, category: work, - domains: [github.com, githubusercontent.com, githubassets.com, github.io, ghcr.io, github.dev, copilot-proxy.githubusercontent.com]} - - {id: docker, name: Docker Hub, name_en: Docker Hub, category: work, - domains: [docker.com, docker.io, registry-1.docker.io, production.cloudflare.docker.com, dockerhub.com]} - - {id: dev_mirrors, name: 开发镜像源, name_en: Package mirrors, category: work, - domains: [pypi.org, pythonhosted.org, npmjs.org, npmjs.com, npmmirror.com, yarnpkg.com, crates.io, golang.org, go.dev, maven.org, mirrors.aliyun.com, mirrors.tuna.tsinghua.edu.cn, mirrors.ustc.edu.cn, debian.org, ubuntu.com, archlinux.org, alpinelinux.org, fedoraproject.org, centos.org]} - - {id: wps, name: WPS, name_en: WPS, category: work, - domains: [wps.cn, wps.com, kdocs.cn, kingsoft.com, ksord.com, wpscdn.cn]} - - {id: tencent_meeting, name: 腾讯会议, name_en: Tencent Meeting, category: work, - domains: [meeting.tencent.com, wemeet.qq.com, voovmeeting.com]} - - {id: zoom, name: Zoom, name_en: Zoom, category: work, - domains: [zoom.us, zoom.com, zoom.com.cn, zoomgov.com]} - - {id: cloudflare, name: Cloudflare, name_en: Cloudflare, category: web, - domains: [cloudflare.com, cloudflare-dns.com, cloudflareinsights.com, cloudflare.net, workers.dev, pages.dev]} + - {id: copilot, name: Microsoft Copilot, name_en: Microsoft Copilot, category: ai, domains: [copilot.microsoft.com]} + - {id: grok, name: Grok, name_en: Grok, category: ai, domains: [grok.com, x.ai]} + - {id: perplexity, name: Perplexity/Poe, name_en: Perplexity/Poe, category: ai, domains: [perplexity.ai, poe.com]} + - {id: midjourney, name: Midjourney, name_en: Midjourney, category: ai, domains: [midjourney.com]} + - {id: huggingface, name: Hugging Face, name_en: Hugging Face, category: ai, domains: [huggingface.co, hf.co, hf-mirror.com]} + - {id: deepseek, name: DeepSeek, name_en: DeepSeek, category: ai, domains: [deepseek.com]} + - {id: kimi, name: Kimi, name_en: Kimi, category: ai, domains: [kimi.moonshot.cn, moonshot.cn, kimi.com]} + - {id: doubao, name: 豆包, name_en: Doubao, category: ai, domains: [doubao.com]} + - {id: yuanbao, name: 腾讯元宝, name_en: Yuanbao, category: ai, domains: [yuanbao.tencent.com, hunyuan.tencent.com]} + - {id: tongyi, name: 通义千问, name_en: Qwen, category: ai, domains: [tongyi.aliyun.com, qianwen.com, tongyi.com]} + - {id: wenxin, name: 文心一言, name_en: ERNIE Bot, category: ai, domains: [yiyan.baidu.com]} + - {id: zhipu, name: 智谱清言, name_en: Zhipu GLM, category: ai, domains: [chatglm.cn, zhipuai.cn, bigmodel.cn]} + - {id: cn_ai, name: 其他国产大模型, name_en: Other Chinese AI, category: ai, + domains: [minimaxi.com, hailuoai.com, xinghuo.xfyun.cn, tiangong.cn, baichuan-ai.com, stepfun.com]} + - {id: chaoxing, name: 学习通/雨课堂, name_en: Chaoxing/Yuketang, category: education, domains: [chaoxing.com, yuketang.cn]} + - {id: icourse, name: 中国大学MOOC, name_en: iCourse MOOC, category: education, domains: [icourse163.org]} + - {id: k12, name: 作业帮/猿辅导/学而思, name_en: K12 tutoring, category: education, domains: [zuoyebang.com, zybang.com, yuanfudao.com, fenbi.com, xueersi.com, xueersi.cn]} + - {id: youdao, name: 网易有道, name_en: Youdao, category: education, domains: [youdao.com, ydstatic.com]} + - {id: cnki, name: 知网/学术, name_en: CNKI & papers, category: education, domains: [cnki.net, wanfangdata.com.cn, cqvip.com, arxiv.org, sciencedirect.com, springer.com, ieee.org]} + - {id: coursera, name: Coursera/Duolingo, name_en: Coursera/Duolingo, category: education, domains: [coursera.org, duolingo.com, khanacademy.org, udemy.com]} - # ---------- 广告统计 ---------- - - {id: ads, name: 广告与统计, name_en: Ads & analytics, category: ads, - domains: [doubleclick.net, googleadservices.com, adnxs.com, umeng.com, umengcloud.com, cnzz.com, growingio.com, talkingdata.com, gdt.qq.com, e.qq.com, pangolin-sdk-toutiao.com, pglstatp-toutiao.com, ads.tiktok.com, adkwai.com, app-measurement.com, crashlytics.com, sentry.io, bugly.qq.com, appsflyer.com, adjust.com, branch.io, mixpanel.com, segment.io, hotjar.com, miaozhen.com, admaster.com.cn, scorecardresearch.com]} + # ================= 购物生活 ================= + - {id: taobao, 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, + 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]} + - {id: vipshop, name: 唯品会, name_en: Vipshop, category: shopping, domains: [vip.com, vipstatic.com, vipglobal.hk]} + - {id: dewu, name: 得物, name_en: Dewu, category: shopping, domains: [dewu.com, poizon.com, dewucdn.com]} + - {id: suning, name: 苏宁/当当, name_en: Suning/Dangdang, category: shopping, domains: [suning.com, suning.cn, dangdang.com, ddimg.cn]} + - {id: smzdm, name: 什么值得买, name_en: SMZDM, category: shopping, domains: [smzdm.com, zdmimg.com]} + - {id: amazon, name: Amazon, name_en: Amazon, category: shopping, + domains: [amazon.com, amazon.cn, media-amazon.com, ssl-images-amazon.com, amazon.co.jp]} + - {id: ebay, name: eBay/SHEIN, name_en: eBay/SHEIN, category: shopping, domains: [ebay.com, ebaystatic.com, ebayimg.com, shein.com, ltwebstatic.com]} + - {id: meituan, name: 美团/大众点评, name_en: Meituan/Dianping, category: life, + domains: [meituan.com, meituan.net, dianping.com, dpfile.com, maoyan.com, sankuai.com]} + - {id: eleme, name: 饿了么, name_en: Ele.me, category: life, + domains: [ele.me, elemecdn.com, eleme.cn]} + - {id: city58, name: 58同城/赶集, name_en: 58.com, category: life, domains: [58.com, 58cdn.com.cn, ganji.com]} + - {id: house, name: 贝壳/链家/安居客, name_en: Real estate, category: life, domains: [ke.com, lianjia.com, anjuke.com, ljcdn.com]} + - {id: jobs, name: 招聘求职, name_en: Jobs, category: life, domains: [zhipin.com, zhaopin.com, 51job.com, liepin.com]} + - {id: amap, name: 高德地图, name_en: Amap, category: travel, + domains: [amap.com, autonavi.com, gaode.com]} + - {id: baidu_map, name: 百度地图, name_en: Baidu Maps, category: travel, domains: [map.baidu.com, api.map.baidu.com, map.bdimg.com]} + - {id: tencent_map, name: 腾讯地图, name_en: Tencent Maps, category: travel, domains: [map.qq.com, apis.map.qq.com]} + - {id: google_maps, name: Google Maps, name_en: Google Maps, category: travel, domains: [maps.google.com, maps.googleapis.com, maps.gstatic.com]} + - {id: didi, name: 滴滴出行, name_en: DiDi, category: travel, + domains: [didiglobal.com, diditaxi.com.cn, didistatic.com, xiaojukeji.com]} + - {id: ctrip, name: 携程, name_en: Trip.com, category: travel, + domains: [ctrip.com, trip.com, c-ctrip.com, ctripcorp.com, tripcdn.com]} + - {id: rail12306, name: 铁路12306, name_en: China Railway 12306, category: travel, domains: [12306.cn]} + - {id: fliggy, name: 飞猪/去哪儿/同程, name_en: Fliggy/Qunar/LY, category: travel, domains: [fliggy.com, alitrip.com, qunar.com, qunarzz.com, ly.com, 17u.cn, umetrip.com]} + - {id: hellobike, name: 哈啰/Uber, name_en: Hellobike/Uber, category: travel, domains: [hellobike.com, uber.com]} + - {id: alipay, name: 支付宝, name_en: Alipay, category: finance, + domains: [alipay.com, alipay.cn, alipay-eco.com, antgroup.com, alipaydns.com, mybank.cn]} + - {id: unionpay, name: 云闪付/银联, name_en: UnionPay, category: finance, domains: [95516.com, unionpay.com, unionpayintl.com]} + - {id: paypal, name: PayPal, name_en: PayPal, category: finance, domains: [paypal.com, paypalobjects.com, paypal.cn]} + - {id: cmb, name: 招商银行, name_en: China Merchants Bank, category: bank, domains: [cmbchina.com, cmbimg.com, cmbchina.cn]} + - {id: icbc, name: 工商银行, name_en: ICBC, category: bank, domains: [icbc.com.cn, icbc.com]} + - {id: ccb, name: 建设银行, name_en: CCB, category: bank, domains: [ccb.com, ccb.cn]} + - {id: abc_bank, name: 农业银行, name_en: ABC, category: bank, domains: [abchina.com, abchina.com.cn]} + - {id: boc, name: 中国银行, name_en: Bank of China, category: bank, domains: [boc.cn, bankofchina.com]} + - {id: bocom, name: 交通/邮储银行, name_en: BoCom/PSBC, category: bank, domains: [bankcomm.com, psbc.com]} + - {id: other_bank, name: 其他银行, name_en: Other banks, category: bank, + domains: [pingan.com, pingan.com.cn, spdb.com.cn, citicbank.com, cib.com.cn, cmbc.com.cn, webank.com, cebbank.com, hxb.com.cn, cgbchina.com.cn]} + - {id: ths, name: 同花顺, name_en: 10jqka, category: stock, domains: [10jqka.com.cn, hexin.cn, 10jqka.com]} + - {id: eastmoney, name: 东方财富/天天基金, name_en: East Money, category: stock, domains: [eastmoney.com, dfcfw.com, 1234567.com.cn]} + - {id: xueqiu, name: 雪球, name_en: Xueqiu, category: stock, domains: [xueqiu.com, xqdoc.imedao.com, imedao.com]} + - {id: futu, name: 富途/老虎, name_en: Futu/Tiger, category: stock, domains: [futunn.com, futuhk.com, futu5.com, futuholdings.com, itiger.com, tigerbrokers.com]} + - {id: securities, name: 券商行情, name_en: Brokers, category: stock, domains: [htsc.com.cn, tdx.com.cn, gw.com.cn, gtja.com, citics.com, cmschina.com]} - # ---------- 智能家居 ---------- + # ================= 资讯阅读 ================= + - {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, + 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]} + - {id: ifeng, name: 凤凰网, name_en: iFeng, category: news, domains: [ifeng.com, ifengimg.com]} + - {id: cn_media, name: 官方媒体, name_en: State media, category: news, domains: [thepaper.cn, people.com.cn, xinhuanet.com, news.cn, chinanews.com.cn, chinadaily.com.cn]} + - {id: tech_news, name: 科技资讯, name_en: Tech news, category: news, domains: [ithome.com, 36kr.com, huxiu.com, sspai.com, geekpark.net]} + - {id: intl_news, name: 国际媒体, name_en: International news, category: news, domains: [bbc.com, bbc.co.uk, bbci.co.uk, nytimes.com, nyt.com, cnn.com, reuters.com, theguardian.com, bloomberg.com]} + - {id: wikipedia, name: Wikipedia, name_en: Wikipedia, category: news, + domains: [wikipedia.org, wikimedia.org, wiki.wikipedia.org]} + - {id: baidu, name: 百度, name_en: Baidu, category: search, + domains: [baidu.com, bdstatic.com, bdimg.com, bcebos.com, bdydns.com, baidubce.com, baiduyun.com, shifen.com]} + - {id: google, name: Google, name_en: Google, category: search, + domains: [google.com, googleapis.com, gstatic.com, google.com.hk, googleusercontent.com, ggpht.com, gvt2.com, recaptcha.net]} + - {id: bing, name: Bing, name_en: Bing, category: search, + domains: [bing.com, bing.net]} + - {id: sogou, name: 搜狗/360搜索, name_en: Sogou/360 Search, category: search, domains: [sogou.com, sogoucdn.com, so.com]} + - {id: duckduckgo, name: DuckDuckGo/Yandex, name_en: DuckDuckGo/Yandex, category: search, domains: [duckduckgo.com, yandex.ru, yandex.net, yandex.com]} + - {id: weread, name: 微信读书, name_en: WeRead, category: reading, domains: [weread.qq.com]} + - {id: yuewen, name: 起点/阅文, name_en: Qidian/Yuewen, category: reading, domains: [qidian.com, yuewen.com, qdmm.com, hongxiu.com]} + - {id: fanqie, name: 番茄/七猫小说, name_en: Fanqie/Qimao, category: reading, domains: [fanqienovel.com, qimao.com, wtzw.com]} + - {id: zhangyue, name: 掌阅/晋江, name_en: iReader/JJWXC, category: reading, domains: [zhangyue.com, ireader.com, jjwxc.net]} + - {id: dedao, name: 得到/樊登, name_en: Dedao, category: reading, domains: [dedao.cn, igetget.com, dushu365.com]} + - {id: comics, name: 漫画, name_en: Comics, category: reading, domains: [kuaikanmanhua.com, manga.bilibili.com, ac.qq.com, pixiv.net, pximg.net]} + + # ================= 智能设备 ================= - {id: mijia, name: 米家, name_en: Mi Home, category: iot, domains: [io.mi.com, iot.mi.com, miot-spec.org, mijia.tech, xiaomiyoupin.com, mi-iot.com]} - {id: tuya, name: 涂鸦智能, name_en: Tuya, category: iot, domains: [tuyacn.com, tuyaus.com, tuyaeu.com, tuya.com, tuyacloud.com]} - {id: homeassistant, name: Home Assistant, name_en: Home Assistant, category: iot, domains: [home-assistant.io, nabucasa.com, ui.nabu.casa]} - - {id: ezviz, name: 萤石/摄像头, name_en: Ezviz/Cameras, category: iot, - domains: [ezvizlife.com, ys7.com, hik-connect.com, hikvision.com, imoulife.com, dahuatech.com]} + - {id: smart_speaker, name: 智能音箱, name_en: Smart speakers, category: iot, + domains: [aligenie.com, xiaodu.baidu.com, dueros.baidu.com, amazonalexa.com, alexa.amazon.com]} + - {id: appliances, name: 家电云(美的/海尔/格力), name_en: Appliance clouds, category: iot, + domains: [midea.com, msmartlife.cn, smartmidea.net, haier.com, haier.net, gree.com, coolkit.cc, ewelink.cc]} + - {id: intl_iot, name: Hue/SmartThings/Nest, name_en: Hue/SmartThings/Nest, category: iot, + domains: [meethue.com, smartthings.com, nest.com, home.google.com]} + - {id: mqtt, name: MQTT, name_en: MQTT, category: iot, ports: [tcp/1883, tcp/8883]} + - {id: ezviz, name: 萤石/海康, name_en: Ezviz/Hikvision, category: camera, + domains: [ezvizlife.com, ys7.com, hik-connect.com, hikvision.com]} + - {id: imou, name: 乐橙/大华, name_en: Imou/Dahua, category: camera, domains: [imoulife.com, lechange.cn, dahuatech.com, easy4ip.com]} + - {id: tplink_cloud, name: TP-Link 云, name_en: TP-Link cloud, category: camera, domains: [tplinkcloud.com, tplinkcloud.com.cn, tp-link.com, tp-link.com.cn]} + - {id: rtsp, name: RTSP 视频流, name_en: RTSP, category: camera, ports: [tcp/554]} - # ---------- 网络协议(按端口兜底) ---------- + # ================= 网络基础 ================= + - {id: apple, name: Apple 服务, name_en: Apple services, category: system, + domains: [apple.com, mzstatic.com, itunes.apple.com, apple.com.cn, push.apple.com, aaplimg.com, apple-dns.net, apple.news]} + - {id: android_update, name: 安卓/手机厂商服务, name_en: Android vendor services, category: system, + domains: [xiaomi.com, xiaomi.net, mi.com, miui.com, mi-img.com, hicloud.com, huawei.com, dbankcdn.com, vivo.com.cn, vivoglobal.com, oppo.com, heytapmobi.com, coloros.com, heytapdownload.com, oneplus.com, honor.com, hihonor.com, meizu.com, flyme.cn]} + - {id: connectivity, name: 联网检测, name_en: Connectivity checks, category: system, + domains: [connectivitycheck.gstatic.com, captive.apple.com, connect.rom.miui.com, connectivitycheck.platform.hicloud.com, www.msftncsi.com]} - {id: dns, name: DNS, name_en: DNS, category: network, ports: [udp/53, tcp/53, tcp/853, udp/853]} + - {id: doh, name: 加密 DNS (DoH), name_en: Encrypted DNS (DoH), category: network, + domains: [dns.google, cloudflare-dns.com, dns.alidns.com, doh.pub, dot.pub, dns.quad9.net, dns.adguard-dns.com, doh.360.cn, dns.nextdns.io]} - {id: ntp, name: NTP, name_en: NTP, category: network, ports: [udp/123]} - - {id: vpn, name: VPN 隧道, name_en: VPN, category: network, - ports: [udp/51820, udp/1194, tcp/1194, udp/500, udp/4500, tcp/1723, udp/1701]} - - {id: ssh, name: SSH/远程, name_en: SSH/remote, category: network, ports: [tcp/22, tcp/3389, udp/3389, tcp/5900]} - - {id: mail, name: 邮件, name_en: Mail, category: work, ports: [tcp/25, tcp/465, tcp/587, tcp/993, tcp/995, tcp/143, tcp/110]} - - {id: stun, name: 音视频通话(STUN/RTP), name_en: Real-time media, category: im, ports: [udp/3478-3481, udp/19302-19309]} + - {id: ftp, name: FTP, name_en: FTP, category: network, ports: [tcp/21, tcp/990]} + - {id: icmp, name: ICMP (Ping), name_en: ICMP, category: network, protos: [1, 58]} + - {id: vpn, name: VPN 隧道, name_en: VPN, category: proxy, + ports: [udp/51820, udp/1194, tcp/1194, udp/500, udp/4500, tcp/1723, udp/1701], protos: [47, 50]} + - {id: tailscale, name: Tailscale/ZeroTier, name_en: Tailscale/ZeroTier, category: proxy, + domains: [tailscale.com, tailscale.io, ts.net, zerotier.com], ports: [udp/41641, udp/9993]} + - {id: warp, name: Cloudflare WARP, name_en: Cloudflare WARP, category: proxy, domains: [cloudflareclient.com, warp.plus]} + - {id: cloudflare, name: Cloudflare, name_en: Cloudflare, category: cdn, + domains: [cloudflare.com, cloudflareinsights.com, cloudflare.net, workers.dev, pages.dev, cdnjs.cloudflare.com]} + - {id: akamai, 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, 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, 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]} + - {id: huawei_cloud, name: 华为云/CDN, name_en: Huawei Cloud, category: cdn, domains: [huaweicloud.com, myhuaweicloud.com, hwcdn.net, cdnhwc1.com]} + - {id: volcengine, name: 火山引擎/CDN, name_en: Volcengine, category: cdn, domains: [volcengine.com, volccdn.com, bytegslb.com]} + - {id: baishan, name: 白山云/其他国内CDN, name_en: Baishan & other CN CDN, category: cdn, domains: [bsgslb.cn, trpcdn.net, bsclink.cn, ccgslb.com, ccgslb.net, chinacache.net, qiniucdn.com, qiniu.com, clouddn.com, upaiyun.com]} + - {id: aws, name: AWS/CloudFront, name_en: AWS/CloudFront, category: cdn, domains: [amazonaws.com, cloudfront.net, awsstatic.com, aws.amazon.com]} + - {id: fastly, name: Fastly/jsDelivr, name_en: Fastly/jsDelivr, category: cdn, domains: [fastly.net, fastlylb.net, fastly.com, jsdelivr.net]} + - {id: azure, name: Azure, name_en: Azure, category: cdn, domains: [azurefd.net, azure.com, trafficmanager.net, azurewebsites.net]} + - {id: security_360, name: 360 安全, name_en: 360 Security, category: security, domains: [360.cn, 360.com, qhimg.com, qihucdn.com, 360safe.com, qhres.com]} + - {id: tencent_pcmgr, name: 腾讯电脑管家, name_en: Tencent PC Manager, category: security, domains: [guanjia.qq.com, pcmgr.qq.com]} + - {id: huorong, name: 火绒, name_en: Huorong, category: security, domains: [huorong.cn]} + - {id: antivirus, name: 卡巴斯基/ESET/Avast, name_en: Antivirus, category: security, + domains: [kaspersky.com, kaspersky-labs.com, eset.com, avast.com, avcdn.net, mcafee.com, norton.com, bitdefender.com]} + - {id: ads, name: 广告与统计, name_en: Ads & analytics, category: ads, + domains: [doubleclick.net, googleadservices.com, googlesyndication.com, google-analytics.com, googletagmanager.com, adnxs.com, umeng.com, umengcloud.com, cnzz.com, growingio.com, talkingdata.com, gdt.qq.com, e.qq.com, pangolin-sdk-toutiao.com, pglstatp-toutiao.com, ads.tiktok.com, adkwai.com, app-measurement.com, crashlytics.com, sentry.io, bugly.qq.com, appsflyer.com, adjust.com, branch.io, mixpanel.com, segment.io, hotjar.com, miaozhen.com, admaster.com.cn, scorecardresearch.com]} - {id: https, name: HTTPS 网页, name_en: HTTPS, category: web, ports: [tcp/443, tcp/8443]} - {id: quic, name: QUIC 网页, name_en: QUIC, category: web, ports: [udp/443]} - {id: http, name: HTTP 网页, name_en: HTTP, category: web, ports: [tcp/80, tcp/8080]} diff --git a/backend/app/collector/tzsp.py b/backend/app/collector/tzsp.py index 7503912..fda12da 100644 --- a/backend/app/collector/tzsp.py +++ b/backend/app/collector/tzsp.py @@ -10,7 +10,8 @@ QTYPE_AAAA = 28 @dataclass class DnsAnswer: - client: str # 收到响应的终端(UDP 目的地址) + client: str # 收到响应的一方(UDP 目的地址) + server: str # 发出响应的 DNS 服务器(UDP 源地址) qname: str # 终端查询的名字 ips: list[tuple[str, int]] = field(default_factory=list) # (ip, ttl) cnames: list[str] = field(default_factory=list) @@ -142,7 +143,7 @@ def parse_tzsp_dns(data: bytes) -> DnsAnswer | None: udp = ethernet_udp(frame) if udp is None: return None - _src, dst, sport, _dport, payload = udp + src, dst, sport, _dport, payload = udp if sport != 53: return None try: @@ -152,4 +153,4 @@ def parse_tzsp_dns(data: bytes) -> DnsAnswer | None: if parsed is None: return None qname, ips, cnames = parsed - return DnsAnswer(client=dst, qname=qname, ips=ips, cnames=cnames) + return DnsAnswer(client=dst, server=src, qname=qname, ips=ips, cnames=cnames) diff --git a/backend/app/engine.py b/backend/app/engine.py index 03865f6..2f496d9 100644 --- a/backend/app/engine.py +++ b/backend/app/engine.py @@ -11,7 +11,7 @@ from app.classify import GENERIC_APPS, Classifier from app.collector.ipfix import FlowDecoder from app.collector.tzsp import parse_tzsp_dns from app.config import get_settings -from app import database +from app import database, hostnames from app.database import upsert_add from app.dnsmap import DnsMap from app.models import (CustomRule, DnsCache, Host, HostDomainDay, Setting, TrafficDay, TrafficHour, @@ -57,6 +57,7 @@ class Engine: self.total_rate = (0.0, 0.0) self.stats = {"flows": 0, "flows_dropped": 0, "dns_packets": 0, "dns_answers": 0, "bytes_seen": 0, "last_flow": 0, "last_dns": 0, "exporters": {}} + self.dns_servers: dict[str, list[int]] = {} # 应答来源 DNS 服务器 -> [应答数, 最近时间] # ---------- 配置 ---------- def set_lan(self, text: str): @@ -108,9 +109,18 @@ class Engine: ans = parse_tzsp_dns(data) if ans is None: return + now = int(time.time()) self.stats["dns_answers"] += 1 - self.stats["last_dns"] = int(time.time()) + self.stats["last_dns"] = now self.dns.add(ans.qname, ans.ips, ans.cnames) + # 记录应答来自哪台 DNS 服务器,方便在系统设置里确认嗅探到的是哪一段(终端↔DNS 还是 DNS↔上游) + srv = self.dns_servers.get(ans.server) + if srv is None: + if len(self.dns_servers) >= 64: + del self.dns_servers[min(self.dns_servers, key=lambda k: self.dns_servers[k][1])] + srv = self.dns_servers[ans.server] = [0, 0] + srv[0] += 1 + srv[1] = now def host_key(self, ip: str, mac: str | None) -> tuple[str, str | None]: m = self.ip2mac.get(ip) @@ -234,6 +244,7 @@ class Engine: rows = db.execute(select(DnsCache.ip, DnsCache.name, DnsCache.cname, DnsCache.expires) .where(DnsCache.expires > now)).all() lan = db.get(Setting, "lan_cidrs") + hostnames.migrate_aliases(db) self.dns.load(rows) if lan and lan.value: self.set_lan(lan.value) diff --git a/backend/app/hostnames.py b/backend/app/hostnames.py new file mode 100644 index 0000000..55a75ed --- /dev/null +++ b/backend/app/hostnames.py @@ -0,0 +1,75 @@ +"""终端命名:用户按 MAC 或 IP 给终端起名。 + +- 按 MAC:设备换了 IP 名字也跟着走,适合大多数设备。 +- 按 IP:适合固定 IP、但 MAC 会变的设备(手机的随机 MAC),或拿不到 MAC 的终端。 +""" +import ipaddress +import re +import time + +from sqlalchemy import select + +from app.models import Host, HostName + +_MAC_RE = re.compile(r"^[0-9A-F]{12}$") + + +def norm_mac(s: str) -> str | None: + """AA-BB-CC-DD-EE-FF / aabb.ccdd.eeff / aabbccddeeff → AA:BB:CC:DD:EE:FF""" + h = re.sub(r"[:\-.\s]", "", s or "").upper() + if not _MAC_RE.match(h): + return None + return ":".join(h[i:i + 2] for i in range(0, 12, 2)) + + +def norm_ip(s: str) -> str | None: + try: + return str(ipaddress.ip_address((s or "").strip())) + except ValueError: + return None + + +def normalize(kind: str, value: str) -> str | None: + return norm_mac(value) if kind == "mac" else norm_ip(value) if kind == "ip" else None + + +def load(db) -> dict[tuple[str, str], str]: + return {(n.kind, n.value): n.name for n in db.scalars(select(HostName))} + + +def resolve(names: dict, mac: str | None, ip: str | None) -> tuple[str | None, str | None]: + """返回 (名字, 命中方式 mac|ip)。""" + if mac and (n := names.get(("mac", mac))): + return n, "mac" + if ip and (n := names.get(("ip", ip))): + return n, "ip" + return None, None + + +def put(db, kind: str, value: str, name: str | None): + """name 为空表示删除这条命名。value 需已规范化。""" + row = db.get(HostName, (kind, value)) + name = (name or "").strip() + if not name: + if row is not None: + db.delete(row) + return + if row is None: + db.add(HostName(kind=kind, value=value, name=name, updated=int(time.time()))) + else: + row.name, row.updated = name, int(time.time()) + + +def migrate_aliases(db): + """把旧版 hosts.alias 搬到 host_names(有 MAC 按 MAC,否则按 IP),只做一次。""" + hosts = db.scalars(select(Host).where(Host.alias.is_not(None))).all() + if not hosts: + return + names = load(db) + for h in hosts: + kind, value = ("mac", h.mac) if h.mac else ("ip", h.ip) + if value and (kind, value) not in names and h.alias.strip(): + put(db, kind, value, h.alias) + names[(kind, value)] = h.alias + h.alias = None + db.commit() diff --git a/backend/app/models.py b/backend/app/models.py index d3cf79d..dde5300 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -58,6 +58,18 @@ class Host(Base): last_seen = Column(BigInteger) +class HostName(Base): + """终端命名:按 MAC(跟着设备走)或按 IP(跟着地址走)。同时命中时 MAC 优先。 + + 旧版本把备注名存在 hosts.alias(按终端 key),启动时会迁移到这里。 + """ + __tablename__ = "host_names" + kind = Column(String(8), primary_key=True) # mac | ip + value = Column(String(45), primary_key=True) # 大写冒号格式的 MAC,或规范化后的 IP + name = Column(String(128), nullable=False) + updated = Column(BigInteger) + + class DnsCache(Base): __tablename__ = "dns_cache" ip = Column(String(45), primary_key=True) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 5641901..0319091 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -27,6 +27,7 @@ _APPID_RE = re.compile(r"^[a-z0-9_]{1,48}$") def meta(): c = engine.classifier return { + "groups": [{"id": k, **v} for k, v in c.groups.items()], "categories": [{"id": k, **v} for k, v in c.categories.items()], "apps": sorted(({"id": a.id, "name": a.name, "name_en": a.name_en, "category": a.category, "custom": a.custom} for a in c.apps.values()), key=lambda a: a["id"]), @@ -184,7 +185,10 @@ def system(): "flow_ok": bool(st["last_flow"] and now - st["last_flow"] < 180), }, "dns": {"packets": st["dns_packets"], "answers": st["dns_answers"], "last": st["last_dns"], - "entries": len(engine.dns), "ok": bool(st["last_dns"] and now - st["last_dns"] < 600)}, + "entries": len(engine.dns), "ok": bool(st["last_dns"] and now - st["last_dns"] < 600), + "servers": sorted(({"ip": ip, "answers": n, "last": ts, "lan": engine.is_lan(ip)} + for ip, (n, ts) in list(engine.dns_servers.items())), + key=lambda x: x["answers"], reverse=True)[:8]}, "router": poller.status(), "generic_apps": sorted(GENERIC_APPS), } diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py index 2ef3de5..300ae99 100644 --- a/backend/app/routers/stats.py +++ b/backend/app/routers/stats.py @@ -4,10 +4,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from sqlalchemy.orm import Session -from app import stats, timeutil +from app import hostnames, stats, timeutil from app.database import get_db from app.engine import engine -from app.models import Host +from app.models import Host, HostName from app.ros_poller import poller from app.security import current_user @@ -28,9 +28,8 @@ def _trend_gran(rng: str) -> str: def overview(range: str = Query("today", pattern=RANGES), db: Session = Depends(get_db)): start, end = _bounds(range) apps = stats.top_apps(db, start, end) + cats = stats.top_categories(apps) now = int(time.time()) - known = sum(a["total"] for a in apps if a["category"] not in ("unknown", "web", "network")) - all_bytes = sum(a["total"] for a in apps) or 1 return { "totals": stats.totals3(db), "range_total": stats.total(db, start, end), @@ -38,11 +37,14 @@ def overview(range: str = Query("today", pattern=RANGES), db: Session = Depends( "wan_up": poller.wan_rate[0], "wan_down": poller.wan_rate[1]}, "online": stats.online_count(db), "hosts_total": db.query(Host).count(), - "identified_ratio": known / all_bytes, + "identified_ratio": stats.identified_ratio(apps), + "apps_count": len(apps), "top_apps": apps[:10], - "categories": stats.top_categories(apps), + "categories": cats, + "groups": stats.top_groups(cats), "top_hosts": stats.top_hosts(db, start, end, limit=10), "trend": stats.trend(db, _trend_gran(range), start, end), + "trend_groups": stats.trend_groups(db, _trend_gran(range), start, end), "realtime": stats.trend(db, "minute", now - 3600, now + 1), "router": poller.status(), } @@ -71,14 +73,17 @@ def host_detail(key: str, range: str = Query("today", pattern=RANGES), db: Sessi if meta["first_seen"] is None and stats.total(db, *_bounds("month"), host=key)["total"] == 0: raise HTTPException(404, "终端不存在") apps = stats.top_apps(db, start, end, host=key) + cats = stats.top_categories(apps) lease = poller.leases.get(meta["mac"] or "") or {} return { **meta, "totals": stats.totals3(db, host=key), "range_total": stats.total(db, start, end, host=key), "apps": apps, - "categories": stats.top_categories(apps), + "categories": cats, + "groups": stats.top_groups(cats), "trend": stats.trend(db, _trend_gran(range), start, end, host=key), + "trend_groups": stats.trend_groups(db, _trend_gran(range), start, end, host=key), "domains": stats.host_domains(db, key, start, end), "dhcp": {"server": lease.get("server"), "status": lease.get("status"), "expires_after": lease.get("expires-after")} if lease else None, @@ -87,23 +92,85 @@ def host_detail(key: str, range: str = Query("today", pattern=RANGES), db: Sessi class HostPatch(BaseModel): alias: str | None = Field(None, max_length=128) + by: str | None = Field(None, pattern="^(mac|ip)$") # 留空:有 MAC 按 MAC,否则按 IP @router.patch("/hosts/{key:path}") def host_patch(key: str, req: HostPatch, db: Session = Depends(get_db)): + """给终端改名。按 MAC 命名跟着设备走;按 IP 命名跟着地址走。""" h = db.get(Host, key) if h is None: raise HTTPException(404, "终端不存在") - h.alias = (req.alias or "").strip() or None + by = req.by or ("mac" if h.mac else "ip") + value = h.mac if by == "mac" else h.ip + if not value: + raise HTTPException(400, "这台终端没有 MAC 地址,只能按 IP 命名" if by == "mac" else "这台终端没有 IP 地址") + # 换了命名方式时,删掉另一种方式下这台终端的旧名字,避免两处都有 + other = ("ip", h.ip) if by == "mac" else ("mac", h.mac) + if other[1]: + hostnames.put(db, other[0], other[1], None) + hostnames.put(db, by, value, req.alias) + h.alias = None db.commit() - return {"ok": True} + return {"ok": True, **stats.host_meta(db, [key])[key]} + + +# ---------- 终端命名表(按 MAC / IP) ---------- +class NameIn(BaseModel): + kind: str = Field(pattern="^(mac|ip)$") + value: str = Field(min_length=1, max_length=45) + name: str = Field("", max_length=128) + + +def _name_rows(db): + hosts = db.query(Host).all() + by_mac = {h.mac: h for h in hosts if h.mac} + by_ip: dict[str, Host] = {} + for h in sorted(hosts, key=lambda x: x.last_seen or 0): + if h.ip: + by_ip[h.ip] = h # 同一 IP 用过多台设备时取最近的 + out = [] + for n in db.query(HostName).order_by(HostName.kind, HostName.value): + h = by_mac.get(n.value) if n.kind == "mac" else by_ip.get(n.value) + out.append({"kind": n.kind, "value": n.value, "name": n.name, "updated": n.updated, + "host": {"key": h.key, "ip": h.ip, "mac": h.mac, "hostname": h.hostname} if h else None}) + return out + + +@router.get("/names") +def list_names(db: Session = Depends(get_db)): + return _name_rows(db) + + +@router.put("/names") +def put_name(req: NameIn, db: Session = Depends(get_db)): + """新增或修改一条命名;name 为空表示删除。可以给还没出现过的设备提前命名。""" + value = hostnames.normalize(req.kind, req.value) + if value is None: + raise HTTPException(400, "MAC 地址格式不正确,例如 AA:BB:CC:DD:EE:FF" if req.kind == "mac" + else "IP 地址格式不正确,例如 192.168.88.20") + hostnames.put(db, req.kind, value, req.name) + db.commit() + return _name_rows(db) + + +@router.delete("/names/{kind}/{value:path}") +def delete_name(kind: str, value: str, db: Session = Depends(get_db)): + v = hostnames.normalize(kind, value) + if v is None: + raise HTTPException(400, "格式不正确") + hostnames.put(db, kind, v, None) + db.commit() + return _name_rows(db) @router.get("/apps") def apps(range: str = Query("today", pattern=RANGES), db: Session = Depends(get_db)): start, end = _bounds(range) rows = stats.top_apps(db, start, end) - return {"apps": rows, "categories": stats.top_categories(rows), "range_total": stats.total(db, start, end)} + cats = stats.top_categories(rows) + return {"apps": rows, "categories": cats, "groups": stats.top_groups(cats), + "range_total": stats.total(db, start, end)} @router.get("/apps/{app_id}/detail") diff --git a/backend/app/stats.py b/backend/app/stats.py index 2a039ff..0b33f86 100644 --- a/backend/app/stats.py +++ b/backend/app/stats.py @@ -4,8 +4,8 @@ from collections import defaultdict from sqlalchemy import distinct, func, select -from app import timeutil -from app.classify import GENERIC_APPS +from app import hostnames, timeutil +from app.classify import GENERIC_APPS, UNIDENTIFIED_CATEGORIES from app.engine import engine from app.models import Host, HostDomainDay, TrafficDay, TrafficHour, TrafficMinute @@ -35,10 +35,13 @@ def totals3(db, host=None, app=None) -> dict: def app_meta(app_id: str) -> dict: - a = engine.classifier.app_info(app_id) - c = engine.classifier.category_info(a.category) + cl = engine.classifier + a = cl.app_info(app_id) + c = cl.category_info(a.category) + g = cl.group_info(c["group"]) return {"id": a.id, "name": a.name, "name_en": a.name_en, "category": a.category, - "category_name": c["name"], "category_name_en": c["name_en"]} + "category_name": c["name"], "category_name_en": c["name_en"], + "group": c["group"], "group_name": g["name"], "group_name_en": g["name_en"]} def top_apps(db, start, end, host=None, limit=None) -> list[dict]: @@ -59,8 +62,8 @@ def top_categories(apps: list[dict]) -> list[dict]: agg: dict[str, dict] = {} for a in apps: c = agg.setdefault(a["category"], {"id": a["category"], "name": a["category_name"], - "name_en": a["category_name_en"], "up": 0, "down": 0, "total": 0, - "apps": 0}) + "name_en": a["category_name_en"], "group": a["group"], + "up": 0, "down": 0, "total": 0, "apps": 0}) c["up"] += a["up"] c["down"] += a["down"] c["total"] += a["total"] @@ -68,20 +71,45 @@ def top_categories(apps: list[dict]) -> list[dict]: return sorted(agg.values(), key=lambda c: c["total"], reverse=True) +def top_groups(cats: list[dict]) -> list[dict]: + """应用大类汇总,children 是该大类下的小类(已按流量排序)。""" + agg: dict[str, dict] = {} + for c in cats: + gi = engine.classifier.group_info(c["group"]) + g = agg.setdefault(c["group"], {"id": c["group"], "name": gi["name"], "name_en": gi["name_en"], + "up": 0, "down": 0, "total": 0, "apps": 0, "children": []}) + for k in ("up", "down", "total", "apps"): + g[k] += c[k] + g["children"].append(c) + return sorted(agg.values(), key=lambda g: g["total"], reverse=True) + + +def identified_ratio(apps: list[dict]) -> float: + known = sum(a["total"] for a in apps if a["category"] not in UNIDENTIFIED_CATEGORIES) + return known / (sum(a["total"] for a in apps) or 1) + + def host_meta(db, keys) -> dict[str, dict]: keys = list(keys) if not keys: return {} now = time.time() rows = db.scalars(select(Host).where(Host.key.in_(keys))).all() + names = hostnames.load(db) out = {} for h in rows: - out[h.key] = {"key": h.key, "mac": h.mac, "ip": h.ip, "hostname": h.hostname, "alias": h.alias, - "name": h.alias or h.hostname or h.ip or h.key, "first_seen": h.first_seen, + alias, by = hostnames.resolve(names, h.mac, h.ip) + alias = alias or h.alias # 旧版备注名(迁移前) + out[h.key] = {"key": h.key, "mac": h.mac, "ip": h.ip, "hostname": h.hostname, "alias": alias, + "alias_by": by, "name": alias or h.hostname or h.ip or h.key, "first_seen": h.first_seen, "last_seen": h.last_seen, "online": bool(h.last_seen and now - h.last_seen < ONLINE_SECONDS)} for k in keys: - out.setdefault(k, {"key": k, "mac": None, "ip": k.removeprefix("IP:"), "hostname": None, "alias": None, - "name": k.removeprefix("IP:"), "first_seen": None, "last_seen": None, "online": False}) + if k in out: + continue + ip = k.removeprefix("IP:") + alias, by = hostnames.resolve(names, None, ip) + out[k] = {"key": k, "mac": None, "ip": ip, "hostname": None, "alias": alias, "alias_by": by, + "name": alias or ip, "first_seen": None, "last_seen": None, "online": False} rate = engine.host_rate.get(k, (0, 0)) out[k]["rate_up"], out[k]["rate_down"] = rate return out @@ -99,27 +127,58 @@ def top_hosts(db, start, end, app=None, limit=None) -> list[dict]: for h, u, d, f in rows] -def trend(db, gran: str, start, end, host=None, app=None) -> list[dict]: - """gran: minute | hour | day。返回补齐空桶后的序列。""" - t, step = {"minute": (TrafficMinute, 60), "hour": (TrafficHour, 3600), "day": (TrafficDay, 86400)}[gran] - q = _where(select(t.ts, *_sum_cols(t)), t, start, end, host, app).group_by(t.ts).order_by(t.ts) - data = {int(ts): (int(u), int(d)) for ts, u, d, _f in db.execute(q)} +_GRAN = {"minute": (TrafficMinute, 60), "hour": (TrafficHour, 3600), "day": (TrafficDay, 86400)} + + +def _buckets(gran: str, start, end) -> list[int]: out = [] if gran == "day": cur = timeutil.day_bucket(start) while cur < end: - u, d = data.get(cur, (0, 0)) - out.append({"ts": cur, "up": u, "down": d}) + out.append(cur) cur = timeutil.day_bucket(cur + 86400 + 3600) # +1h 兼容夏令时 else: + step = _GRAN[gran][1] cur = start // step * step while cur < end: - u, d = data.get(cur, (0, 0)) - out.append({"ts": cur, "up": u, "down": d}) + out.append(cur) cur += step return out +def trend(db, gran: str, start, end, host=None, app=None) -> list[dict]: + """gran: minute | hour | day。返回补齐空桶后的序列。""" + t = _GRAN[gran][0] + q = _where(select(t.ts, *_sum_cols(t)), t, start, end, host, app).group_by(t.ts).order_by(t.ts) + data = {int(ts): (int(u), int(d)) for ts, u, d, _f in db.execute(q)} + return [{"ts": ts, "up": data.get(ts, (0, 0))[0], "down": data.get(ts, (0, 0))[1]} + for ts in _buckets(gran, start, end)] + + +def trend_groups(db, gran: str, start, end, host=None, app=None, max_series=7) -> dict: + """按应用大类拆分的趋势(堆叠面积图)。流量最大的 max_series 个大类单独成线,其余合并为 other。""" + t, step = _GRAN[gran] + q = _where(select(t.ts, t.app, func.sum(t.up) + func.sum(t.down)), t, start, end, host, app) .group_by(t.ts, t.app) + per: dict[str, dict[int, int]] = defaultdict(lambda: defaultdict(int)) + tot: dict[str, int] = defaultdict(int) + for ts, a, b in db.execute(q): + g = app_meta(a)["group"] + per[g][int(ts)] += int(b) + tot[g] += int(b) + buckets = _buckets(gran, start, end) + order = sorted(tot, key=tot.get, reverse=True) + keep, rest = order[:max_series], order[max_series:] + series = [] + for g in keep: + gi = engine.classifier.group_info(g) + series.append({"id": g, "name": gi["name"], "name_en": gi["name_en"], "total": tot[g], + "values": [per[g].get(ts, 0) for ts in buckets]}) + if rest: + series.append({"id": "__other", "name": None, "name_en": None, "total": sum(tot[g] for g in rest), + "values": [sum(per[g].get(ts, 0) for g in rest) for ts in buckets]}) + return {"gran": gran, "step": step, "ts": buckets, "series": series} + + def all_hosts(db) -> list[dict]: """主机监控列表:每台终端的 今日/本周/本月 流量。""" per: dict[str, dict] = defaultdict(dict) @@ -170,7 +229,7 @@ def online_count(db) -> int: def flow_graph(db, start, end, max_hosts=12, max_apps=16, host=None) -> dict: - """流向图(桑基图)数据:终端 → 应用分类 → 应用。超出上限的终端/应用合并为"其他"。""" + """流向图(桑基图)数据:终端 → 应用大类 → 应用。超出上限的终端/应用合并为"其他"。""" t = TrafficDay q = _where(select(t.host, t.app, *_sum_cols(t)), t, start, end, host).group_by(t.host, t.app) rows = [(h, a, int(u), int(d)) for h, a, u, d, _f in db.execute(q)] @@ -183,13 +242,13 @@ def flow_graph(db, start, end, max_hosts=12, max_apps=16, host=None) -> dict: top_a = set(sorted(app_tot, key=app_tot.get, reverse=True)[:max_apps]) meta = host_meta(db, top_h) - hc: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0]) - ca: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0]) + hg: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0]) + ga: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0]) for h, a, u, d in rows: hk = h if h in top_h else "__other_hosts" - cat = app_meta(a)["category"] - ak = a if a in top_a else f"__other_apps:{cat}" - for acc, key in ((hc, (hk, cat)), (ca, (cat, ak))): + grp = app_meta(a)["group"] + ak = a if a in top_a else f"__other_apps:{grp}" + for acc, key in ((hg, (hk, grp)), (ga, (grp, ak))): acc[key][0] += u acc[key][1] += d @@ -200,19 +259,20 @@ def flow_graph(db, start, end, max_hosts=12, max_apps=16, host=None) -> dict: return nid links = [] - for (hk, cat), (u, d) in hc.items(): + for (hk, grp), (u, d) in hg.items(): hm = meta.get(hk) src = node(f"h:{hk}", "host", key=None if hk == "__other_hosts" else hk, name=hm["name"] if hm else None, online=hm["online"] if hm else False) - cm = engine.classifier.category_info(cat) - dst = node(f"c:{cat}", "category", category=cat, name=cm["name"], name_en=cm["name_en"]) + gi = engine.classifier.group_info(grp) + dst = node(f"g:{grp}", "group", group=grp, name=gi["name"], name_en=gi["name_en"]) links.append({"source": src, "target": dst, "up": u, "down": d, "value": u + d}) - for (cat, ak), (u, d) in ca.items(): + for (grp, ak), (u, d) in ga.items(): if ak.startswith("__other_apps:"): - dst = node(f"a:{ak}", "app", app=None, category=cat) + dst = node(f"a:{ak}", "app", app=None, group=grp, category=None) else: am = app_meta(ak) - dst = node(f"a:{ak}", "app", app=ak, category=cat, name=am["name"], name_en=am["name_en"]) - links.append({"source": f"c:{cat}", "target": dst, "up": u, "down": d, "value": u + d}) + dst = node(f"a:{ak}", "app", app=ak, group=grp, category=am["category"], name=am["name"], + name_en=am["name_en"]) + links.append({"source": f"g:{grp}", "target": dst, "up": u, "down": d, "value": u + d}) links = [x for x in links if x["value"] > 0] return {"nodes": list(nodes.values()), "links": links, "total": sum(host_tot.values())} diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index c01c79b..6951145 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -24,12 +24,30 @@ def test_api_flow(): assert ov["totals"]["today"]["total"] >= 777 assert any(x["key"] == "AA:00:00:00:00:50" for x in ov["top_hosts"]) assert len(ov["realtime"]) >= 60 + assert ov["groups"][0]["children"][0]["id"] == "web" + tg = ov["trend_groups"] + assert len(tg["ts"]) == len(ov["trend"]) and all(len(x["values"]) == len(tg["ts"]) for x in tg["series"]) hosts = c.get("/api/hosts", headers=h).json() assert any(x["key"] == "AA:00:00:00:00:50" for x in hosts) assert c.patch("/api/hosts/AA:00:00:00:00:50", json={"alias": "书房电脑"}, headers=h).json()["ok"] d = c.get("/api/hosts/AA:00:00:00:00:50/detail?range=month", headers=h).json() - assert d["name"] == "书房电脑" and d["apps"][0]["id"] == "https" + assert d["name"] == "书房电脑" and d["alias_by"] == "mac" and d["apps"][0]["id"] == "https" + assert d["groups"][0]["id"] == "base" and d["trend_groups"]["series"][0]["id"] == "base" + # 改成按 IP 命名:MAC 下的旧名字被删掉 + r = c.patch("/api/hosts/AA:00:00:00:00:50", json={"alias": "固定IP", "by": "ip"}, headers=h).json() + assert r["name"] == "固定IP" and r["alias_by"] == "ip" + names = c.get("/api/names", headers=h).json() + assert [(n["kind"], n["value"]) for n in names] == [("ip", "192.168.88.50")] + assert names[0]["host"]["key"] == "AA:00:00:00:00:50" + # 命名表:MAC 优先于 IP;格式自动规范化;可以给没出现过的设备提前命名 + c.put("/api/names", json={"kind": "mac", "value": "aa-00-00-00-00-50", "name": "按MAC"}, headers=h) + c.put("/api/names", json={"kind": "mac", "value": "aabb.ccdd.eeff", "name": "未来设备"}, headers=h) + assert c.put("/api/names", json={"kind": "ip", "value": "300.1.1.1", "name": "x"}, headers=h).status_code == 400 + assert c.get("/api/hosts/AA:00:00:00:00:50/detail", headers=h).json()["name"] == "按MAC" + names = c.delete("/api/names/mac/AA:00:00:00:00:50", headers=h).json() + assert {n["value"] for n in names} == {"192.168.88.50", "AA:BB:CC:DD:EE:FF"} + assert next(x for x in c.get("/api/hosts", headers=h).json() if x["key"] == "AA:00:00:00:00:50")["name"] == "固定IP" assert c.get("/api/hosts/IP:1.1.1.1/detail", headers=h).status_code == 404 assert c.get("/api/apps/https/detail", headers=h).json()["hosts"][0]["key"] == "AA:00:00:00:00:50" @@ -45,7 +63,8 @@ def test_api_flow(): assert s["lan_cidrs"] == "192.168.88.0/24,10.0.0.0/8" assert c.put("/api/settings", json={"lan_cidrs": "nope"}, headers=h).status_code == 400 assert c.get("/api/system", headers=h).json()["collector"]["flows"] >= 1 - assert len(c.get("/api/meta", headers=h).json()["apps"]) > 50 + meta = c.get("/api/meta", headers=h).json() + assert len(meta["apps"]) > 250 and {g["id"] for g in meta["groups"]} >= {"media", "game", "unknown"} def test_flow_graph(): @@ -62,5 +81,5 @@ def test_flow_graph(): assert "h:__other_hosts" in ids or len([n for n in g["nodes"] if n["kind"] == "host"]) == 1 # 守恒:终端→分类 的总量 == 分类→应用 的总量 == total hc = sum(l["value"] for l in g["links"] if l["source"].startswith("h:")) - ca = sum(l["value"] for l in g["links"] if l["source"].startswith("c:")) + ca = sum(l["value"] for l in g["links"] if l["source"].startswith("g:")) assert hc == ca == g["total"] diff --git a/backend/tests/test_engine.py b/backend/tests/test_engine.py index 002ed0d..beea5bc 100644 --- a/backend/tests/test_engine.py +++ b/backend/tests/test_engine.py @@ -22,6 +22,37 @@ def test_classifier_longest_suffix_and_cname(): 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(): @@ -41,6 +72,7 @@ def _engine(): 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) diff --git a/docs/RouterOS配置.md b/docs/RouterOS配置.md index dbcb775..e979b4e 100644 --- a/docs/RouterOS配置.md +++ b/docs/RouterOS配置.md @@ -23,9 +23,19 @@ ``` - `output`:路由器自己作为 DNS 服务器时,发给终端的应答。 -- `forward`:终端直连外部 DNS(如 223.5.5.5)时的应答。 +- `forward`:终端直连外部 DNS(如 223.5.5.5)时的应答;内网自建 DNS 服务器向上游查询时的应答也走这里。 - 只镜像 DNS 应答,数据量很小。 +**作用**:流量记录里只有 IP 和端口。DNS 嗅探记下“这个 IP 是哪个域名解析出来的”,ROS-DPI 才能把发往该 IP 的流量认成抖音、微信等具体应用。它不影响流量字节数统计;没有它时,流量只能按 IP 段、端口识别为 HTTPS / QUIC 等通用协议。 + +**内网有自己的 DNS 服务器**(例如 AdGuard Home / Pi-hole 在 192.168.88.248,DHCP 下发的 DNS 是它): + +- 终端 → 内网 DNS 在同一网段,走交换机,路由器看不到,这一段嗅探不到。这没关系:ROS-DPI 的“IP → 域名”表是全网共用的,不需要知道是哪台终端查的。 +- 内网 DNS → 上游 DNS(如 223.5.5.5)的应答要经过路由器,会被上面的 `forward` 规则嗅探到,效果和终端直接查询一样。 +- 前提是内网 DNS 的**上游用普通 DNS(udp/53)**。上游设成 DoH / DoT(AdGuard Home 默认的 `https://dns10.quad9.net/dns-query` 之类)时,路由器只看到加密流量,DNS 应答数会一直是 0。解决方法二选一:把上游改成普通 DNS(如 `223.5.5.5`、`119.29.29.29`);或者把上游改成路由器地址,并在路由器上开启 `/ip dns set allow-remote-requests=yes`,这样应答由 `output` 规则嗅探。 +- 内网 DNS 有缓存,缓存命中时不会再向上游查询。ROS-DPI 会把每条对应关系保留 6 到 48 小时,一般够用。 +- 可以在 **系统设置 → DNS 嗅探 → 应答来源** 确认:看到 223.5.5.5 等外网地址(标为“外网”)说明上游应答已经嗅探到了。 + 提高识别率: - 在 DHCP 里把 DNS 下发为路由器地址,并开启 `/ip dns set allow-remote-requests=yes`。 diff --git a/docs/images/apps-light.png b/docs/images/apps-light.png new file mode 100644 index 0000000..4ac33d6 Binary files /dev/null and b/docs/images/apps-light.png differ diff --git a/docs/images/flow-light.png b/docs/images/flow-light.png index 91cd2b0..155a405 100644 Binary files a/docs/images/flow-light.png and b/docs/images/flow-light.png differ diff --git a/docs/images/host-light.png b/docs/images/host-light.png index a78b00a..f6d937d 100644 Binary files a/docs/images/host-light.png and b/docs/images/host-light.png differ diff --git a/docs/images/overview-dark.png b/docs/images/overview-dark.png index b4f8641..0847992 100644 Binary files a/docs/images/overview-dark.png and b/docs/images/overview-dark.png differ diff --git a/docs/images/overview-light.png b/docs/images/overview-light.png index b227b57..00d63a6 100644 Binary files a/docs/images/overview-light.png and b/docs/images/overview-light.png differ diff --git a/frontend/package.json b/frontend/package.json index de7018e..a8a855e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ros-dpi-frontend", - "version": "0.1.3", + "version": "0.1.4", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/charts.js b/frontend/src/charts.js index 2a8dde4..9040712 100644 --- a/frontend/src/charts.js +++ b/frontend/src/charts.js @@ -1,15 +1,18 @@ // ECharts 配置生成。颜色从 CSS 变量读取,切换主题/语言后重新生成即可。 -import { appName, bytes, catColor, rate } from './format.js' +// 规范(dataviz):细线 2px、发丝网格、2px 底色间隙分隔色块、颜色跟随实体(应用大类)而不是排名、 +// 只给关键点(峰值)直接标注,其余交给图例、提示框和旁边的数值表。 +import { appName, bytes, catHex, catName, groupHex, groupName, pct, rate } from './format.js' import { i18n } from './i18n/index.js' const css = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim() const t = (k, p) => i18n.global.t(k, p) const FONT = '"Barlow Semi Condensed", "Barlow", "PingFang SC", "Microsoft YaHei", sans-serif' +const STEP = { minute: 60, hour: 3600, day: 86400 } function tooltip(extra = {}) { return { backgroundColor: css('--panel'), borderColor: css('--line-strong'), borderWidth: 1, padding: [8, 12], - textStyle: { color: css('--ink'), fontSize: 13, fontFamily: FONT }, extraCssText: 'box-shadow:none;border-radius:6px', ...extra, + textStyle: { color: css('--ink'), fontSize: 13, fontFamily: FONT }, extraCssText: 'box-shadow:0 4px 16px rgba(0,0,0,.08);border-radius:6px', ...extra, } } @@ -20,27 +23,11 @@ function fmtTime(ts, gran) { return `${p(d.getHours())}:${p(d.getMinutes())}` } -/** 上下行趋势:下行(蓝)在上、上行(橙)以负值画在零线下方,像一对双绞线。 */ -export function trendOption(points, gran = 'hour', asRate = false) { - const step = { minute: 60, hour: 3600, day: 86400 }[gran] - const val = v => (asRate ? v / step : v) - const fmt = v => (asRate ? rate(Math.abs(v)) : bytes(Math.abs(v))) - const down = css('--down'), up = css('--up') - const series = (name, key, color, sign) => ({ - name, type: gran === 'day' ? 'bar' : 'line', barMaxWidth: 14, barGap: '-100%', - smooth: 0.25, symbol: 'none', lineStyle: { width: 1.5, color }, - areaStyle: gran === 'day' ? undefined : { color, opacity: 0.22 }, - itemStyle: { color, borderRadius: sign > 0 ? [2, 2, 0, 0] : [0, 0, 2, 2] }, - data: points.map(p => sign * val(p[key])), - }) +function axes(labels, gran, fmt) { return { - textStyle: { fontFamily: FONT }, - grid: { left: 4, right: 8, top: 30, bottom: 2, containLabel: true }, - legend: { top: 0, left: 0, icon: 'rect', itemWidth: 12, itemHeight: 3, textStyle: { color: css('--muted'), fontFamily: FONT, fontSize: 13 } }, - tooltip: tooltip({ trigger: 'axis', axisPointer: { type: 'line', lineStyle: { color: css('--line-strong') } }, valueFormatter: fmt }), xAxis: { - type: 'category', boundaryGap: gran === 'day', data: points.map(p => fmtTime(p.ts, gran)), - axisLine: { show: false }, axisTick: { show: false }, + type: 'category', boundaryGap: gran === 'day', data: labels, + axisLine: { lineStyle: { color: css('--line-strong') } }, axisTick: { show: false }, axisLabel: { color: css('--faint'), hideOverlap: true, fontFamily: FONT, fontSize: 12 }, }, yAxis: { @@ -48,44 +35,134 @@ export function trendOption(points, gran = 'hour', asRate = false) { splitLine: { lineStyle: { color: css('--line') } }, axisLabel: { color: css('--faint'), fontFamily: FONT, fontSize: 12, formatter: v => (v === 0 ? '0' : fmt(v).replace('.0 ', ' ')) }, }, + } +} + +const legend = () => ({ top: 0, left: 0, icon: 'roundRect', itemWidth: 10, itemHeight: 10, itemGap: 14, + textStyle: { color: css('--muted'), fontFamily: FONT, fontSize: 13 } }) + +/** 上下行趋势:下行(蓝)在上、上行(橙)以负值画在零线下方,像一对双绞线。峰值直接标注。 */ +export function trendOption(points, gran = 'hour', asRate = false) { + const val = v => (asRate ? v / STEP[gran] : v) + const fmt = v => (asRate ? rate(Math.abs(v)) : bytes(Math.abs(v))) + const down = css('--down'), up = css('--up') + const series = (name, key, color, sign) => ({ + name, type: gran === 'day' ? 'bar' : 'line', barMaxWidth: 14, barGap: '-100%', + smooth: 0.25, symbol: 'none', lineStyle: { width: 2, color }, + areaStyle: gran === 'day' ? undefined : { color, opacity: 0.16 }, + itemStyle: { color, borderRadius: sign > 0 ? [4, 4, 0, 0] : [0, 0, 4, 4] }, + data: points.map(p => sign * val(p[key])), + // 只标下行峰值:一个数字讲清"最忙的时候有多忙" + markPoint: sign > 0 && points.some(p => p[key] > 0) ? { + symbol: 'circle', symbolSize: 7, itemStyle: { color, borderColor: css('--panel'), borderWidth: 2 }, + label: { position: 'top', distance: 6, color: css('--muted'), fontFamily: FONT, fontSize: 12, formatter: p => t('chart.peak', { v: fmt(p.value) }) }, + data: [{ type: 'max' }], + } : undefined, + }) + return { + textStyle: { fontFamily: FONT }, + grid: { left: 4, right: 12, top: 34, bottom: 2, containLabel: true }, + legend: legend(), + tooltip: tooltip({ trigger: 'axis', axisPointer: { type: 'line', lineStyle: { color: css('--line-strong') } }, valueFormatter: fmt }), + ...axes(points.map(p => fmtTime(p.ts, gran)), gran, fmt), series: [series(t('common.down'), 'down', down, 1), series(t('common.up'), 'up', up, -1)], } } -/** 流向桑基图:终端 → 分类 → 应用 */ +/** 按应用大类堆叠的趋势(Panabit 的"应用流量趋势")。最多 7 个大类 + 其他,颜色固定跟随大类。 */ +export function stackedOption(tg, asRate = false) { + const step = tg.step || STEP[tg.gran] + const fmt = v => (asRate ? rate(v) : bytes(v)) + const panel = css('--panel') + const series = tg.series.map(s => { + const color = s.id === '__other' ? css('--line-strong') : groupHex(s.id) + return { + name: groupName(s), id: s.id, type: tg.gran === 'day' ? 'bar' : 'line', stack: 'all', + smooth: 0.2, symbol: 'none', barMaxWidth: 18, + lineStyle: { width: 1, color: panel }, areaStyle: { color, opacity: 0.88 }, + // 堆叠柱之间留 2px 底色间隙,代替描边 + itemStyle: { color, borderColor: panel, borderWidth: tg.gran === 'day' ? 1 : 0 }, + emphasis: { focus: 'series' }, + data: s.values.map(v => (asRate ? v / step : v)), + } + }) + return { + textStyle: { fontFamily: FONT }, + grid: { left: 4, right: 12, top: 58, bottom: 2, containLabel: true }, + legend: { ...legend(), type: 'scroll' }, + tooltip: tooltip({ + trigger: 'axis', axisPointer: { type: 'line', lineStyle: { color: css('--line-strong') } }, + formatter: ps => { + const sum = ps.reduce((a, p) => a + (p.value || 0), 0) + const rows = ps.filter(p => p.value > 0).sort((a, b) => b.value - a.value) + .map(p => `
${p.marker}${p.seriesName}${fmt(p.value)}
`) + return `
${ps[0]?.axisValue ?? ''} ${fmt(sum)}${rows.join('')}
` + }, + }), + ...axes(tg.ts.map(ts => fmtTime(ts, tg.gran)), tg.gran, fmt), + series, + } +} + +/** 构成环图:内环 = 应用大类,外环 = 小类(同色深浅)。数值和名称在旁边的图例表里,图上不放文字。 */ +export function donutOption(groups) { + const panel = css('--panel') + const sum = groups.reduce((a, g) => a + g.total, 0) || 1 + const ring = { type: 'pie', startAngle: 90, avoidLabelOverlap: false, label: { show: false }, labelLine: { show: false }, + itemStyle: { borderColor: panel, borderWidth: 2, borderRadius: 3 }, emphasis: { scale: true, scaleSize: 4 } } + const gs = groups.filter(g => g.total > 0) + return { + tooltip: tooltip({ + trigger: 'item', + formatter: p => `${p.marker}${p.name}
${bytes(p.value)} ${pct(p.value / sum)}`, + }), + series: [ + { ...ring, name: 'group', radius: ['42%', '63%'], + data: gs.map(g => ({ name: groupName(g), value: g.total, id: g.id, kind: 'group', itemStyle: { color: groupHex(g.id) } })) }, + { ...ring, name: 'category', radius: ['67%', '84%'], + data: gs.flatMap(g => g.children.filter(c => c.total > 0).map(c => ({ + name: `${groupName(g)} · ${catName(c)}`, value: c.total, id: c.id, kind: 'category', itemStyle: { color: catHex(c.id) }, + }))) }, + ], + } +} + +/** 流向桑基图:终端 → 应用大类 → 应用 */ export function sankeyOption(graph) { - const zh = i18n.global.locale.value === 'zh' const ink = css('--ink'), muted = css('--muted') const label = n => { if (n.kind === 'host') return n.key ? n.name : t('flow.otherHosts') - if (n.kind === 'category') return zh ? n.name : n.name_en + if (n.kind === 'group') return groupName(n) if (!n.app) { - const cat = graph.nodes.find(x => x.id === `c:${n.category}`) - return t('flow.otherApps', { cat: cat ? (zh ? cat.name : cat.name_en) : '' }) + const g = graph.nodes.find(x => x.id === `g:${n.group}`) + return t('flow.otherApps', { cat: g ? groupName(g) : '' }) } return appName(n) } const minLabel = (graph.total || 0) * 0.012 const idx = new Map(graph.nodes.map((n, i) => [n.id, i])) - const color = n => (n.kind === 'host' ? (n.online ? css('--pair-green') : css('--line-strong')) : catColor(n.category)) + const color = n => (n.kind === 'host' ? (n.online ? css('--pair-green') : css('--line-strong')) + : n.kind === 'app' && n.category ? catHex(n.category) : groupHex(n.group)) + const share = v => pct(v / (graph.total || 1), 0) return { textStyle: { fontFamily: FONT }, tooltip: tooltip({ trigger: 'item', formatter: p => (p.dataType === 'edge' ? `${label(p.data.s)} → ${label(p.data.tg)}
${bytes(p.data.value)} ↓${bytes(p.data.down)} ↑${bytes(p.data.up)}` - : `${p.name}
${bytes(p.value)}`), + : `${p.name}
${bytes(p.value)} ${share(p.value)}`), }), series: [{ - type: 'sankey', left: 4, right: 150, top: 8, bottom: 8, nodeWidth: 10, nodeGap: 9, draggable: false, + type: 'sankey', left: 4, right: 170, top: 8, bottom: 8, nodeWidth: 12, nodeGap: 10, draggable: false, layoutIterations: 48, emphasis: { focus: 'adjacency' }, // 占比太小的节点挤在一起时标签会重叠,只在悬停时通过提示框查看 label: { color: ink, fontFamily: FONT, fontSize: 13, rich: { v: { color: muted, fontSize: 12 } }, - formatter: p => (p.value < minLabel ? '' : `${p.name} {v|${bytes(p.value)}}`), + formatter: p => (p.value < minLabel ? '' : `${p.name} {v|${bytes(p.value)} · ${share(p.value)}}`), }, - lineStyle: { color: 'gradient', opacity: 0.32, curveness: 0.5 }, - itemStyle: { borderWidth: 0 }, + // 连线颜色取下游节点(大类/应用),一眼看出流量去了哪类应用 + lineStyle: { color: 'target', opacity: 0.28, curveness: 0.5 }, + itemStyle: { borderWidth: 0, borderRadius: 2 }, data: graph.nodes.map(n => ({ name: label(n), id: n.id, raw: n, itemStyle: { color: color(n) } })), // 用下标连线:终端备注名可能和应用/分类重名 links: graph.links.map(l => { diff --git a/frontend/src/components/BarList.vue b/frontend/src/components/BarList.vue index 216f9ec..1e96fbb 100644 --- a/frontend/src/components/BarList.vue +++ b/frontend/src/components/BarList.vue @@ -1,19 +1,22 @@