feat: ROS-DPI v0.1.0:RouterOS 终端流量与应用统计面板

- IPFIX / NetFlow v9 采集 + TZSP DNS 嗅探识别应用,可选 ROS 只读 REST 轮询
- 内置 100+ 应用规则库,支持自定义域名 / IP 段 / 端口规则
- 按分钟 / 小时 / 天汇总写入 MySQL
- 全网概览、主机监控、应用统计、流量流向(桑基图)、识别规则、系统设置
- 中英双语,深浅色主题,T568B 线色设计语言
- Docker 多架构镜像,GitHub Actions 测试与发布

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Su YijunandClaude Opus 5.5 committed 2026-10-21 23:51:02 +08:00
commit f3e9fdb2af
77 files changed
+7086

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
.git
.github
**/node_modules
**/.venv
**/__pycache__
**/.pytest_cache
frontend/dist
data
mysql
.env
*.md
docs
scripts
backend/tests
+30
View File
@@ -0,0 +1,30 @@
# ---------- 数据库(docker-compose 自带的 MySQL) ----------
MYSQL_PASSWORD=change-me
MYSQL_ROOT_PASSWORD=change-me-too
# 使用外部 MySQL 时,在 docker-compose.yml 里改 DATABASE_URL:
# DATABASE_URL=mysql+pymysql://user:pass@192.168.1.10:3306/rosdpi?charset=utf8mb4
# ---------- 基本 ----------
TZ=Asia/Shanghai
# 首次启动的管理员密码(用户名 admin);留空则在登录页设置
ADMIN_PASSWORD=
# LAN 网段(逗号分隔),也可以在界面里改。双 NAT(WAN 也是私网地址)时务必只填内网实际网段
LAN_CIDRS=192.168.88.0/24
# ---------- RouterOS 只读 API(可选:主机名、WAN 速率、自动发现 IPv6 前缀) ----------
# ROS_URL=https://192.168.88.1
# ROS_USER=rosdpi
# ROS_PASSWORD=
# ROS_VERIFY_TLS=false
# WAN_INTERFACE=pppoe-out1
# ---------- 端口(与 docker-compose.yml 的端口映射保持一致) ----------
# HTTP_PORT=8080
# IPFIX_PORT=2055
# TZSP_PORT=37008
# ---------- 数据保留天数 ----------
# RETENTION_MINUTE_DAYS=2
# RETENTION_HOUR_DAYS=45
# RETENTION_DAY_DAYS=730
# RETENTION_DOMAIN_DAYS=31
+2
View File
@@ -0,0 +1,2 @@
* text=auto eol=lf
*.png binary
+117
View File
@@ -0,0 +1,117 @@
name: Test, build and publish
on:
push:
branches: [ main ]
tags: [ 'v*' ]
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Backend tests
working-directory: backend
run: |
pip install -r requirements.txt pytest
python -m pytest -q
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Frontend build (含 i18n 检查)
working-directory: frontend
run: |
npm ci || npm install
npm run build
build-and-push:
needs: test
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
# 推送到 GHCR 需要 packages:write
permissions:
contents: read
packages: write
env:
HAS_DOCKERHUB: ${{ secrets.DOCKERHUB_TOKEN != '' }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 没配置 DOCKERHUB_* secrets 时只推 GHCR
- name: Login to Docker Hub
if: env.HAS_DOCKERHUB == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Image names
id: images
run: |
{
echo 'list<<EOF'
echo "ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/ros-dpi"
[ "$HAS_DOCKERHUB" = "true" ] && echo "suyijun8182/ros-dpi"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.images.outputs.list }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# 把 DOCKERHUB.md 同步到 Docker Hub 的 Overview。独立 job:失败不影响镜像发布。
# DOCKERHUB_TOKEN 需要 Read, Write, Delete 权限,Read & Write 只能推镜像、改不了说明。
dockerhub-overview:
needs: build-and-push
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_REPO: suyijun8182/ros-dpi
DOCKERHUB_SHORT: "RouterOS per-device traffic & app (DPI) statistics via IPFIX + DNS. RouterOS 终端流量与应用统计。"
steps:
- uses: actions/checkout@v4
- name: Sync Docker Hub overview (DOCKERHUB.md)
if: env.DOCKERHUB_TOKEN != ''
run: |
set -euo pipefail
JWT=$(curl -s -H "Content-Type: application/json" \
-d "$(jq -n --arg u "$DOCKERHUB_USERNAME" --arg p "$DOCKERHUB_TOKEN" '{username:$u,password:$p}')" \
https://hub.docker.com/v2/users/login/ | jq -r .token)
[ -n "$JWT" ] && [ "$JWT" != "null" ] || { echo "Docker Hub 登录失败:检查 DOCKERHUB_USERNAME / DOCKERHUB_TOKEN"; exit 1; }
BODY=$(jq -n --arg full "$(cat DOCKERHUB.md)" --arg short "$DOCKERHUB_SHORT" '{full_description:$full, description:$short}')
CODE=$(curl -s -o /tmp/dh.json -w '%{http_code}' -X PATCH -H "Authorization: JWT $JWT" \
-H "Content-Type: application/json" -d "$BODY" "https://hub.docker.com/v2/repositories/$DOCKERHUB_REPO/")
[ "$CODE" = "200" ] && echo "Overview 已同步" || { echo "同步失败(HTTP $CODE)"; cat /tmp/dh.json; exit 1; }
+34
View File
@@ -0,0 +1,34 @@
name: GitHub Release
# 推送 v* tag 时创建 GitHub Release,正文取自 CHANGELOG.md 对应版本段落。
on:
push:
tags: [ 'v*' ]
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Create release
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"; VER="${TAG#v}"
NOTES=$(awk -v v="## [$VER]" 'index($0,v)==1{f=1;next} /^## \[/{if(f)exit} f' CHANGELOG.md)
[ -n "$NOTES" ] || NOTES="详见提交记录。"
{
echo '### 升级'
echo
echo '```bash'
echo 'docker compose pull && docker compose up -d'
echo '```'
echo
echo "$NOTES"
} > notes.md
gh release view "$TAG" >/dev/null 2>&1 && { echo "已存在 $TAG"; exit 0; }
gh release create "$TAG" --title "$TAG" --notes-file notes.md --verify-tag
+13
View File
@@ -0,0 +1,13 @@
.env
data/
mysql/
node_modules/
frontend/dist/
__pycache__/
*.pyc
.venv/
.pytest_cache/
*.db
*.db-wal
*.db-shm
项目说明.txt
+12
View File
@@ -0,0 +1,12 @@
# Changelog
## [0.1.0] - 2026-10-21
首个版本。
- IPFIX / NetFlow v9 流量采集,TZSP DNS 嗅探识别应用,可选 RouterOS 只读 REST 轮询。
- 内置 100+ 应用、20+ 分类的识别规则库;支持按域名 / IP 段 / 端口自定义规则,“未识别域名 Top”一键归类。
- 按 分钟 / 小时 / 天 汇总写入 MySQL(开发可用 SQLite),保留天数可配置。
- 页面:全网概览、主机监控(含详情)、应用统计(含详情)、流量流向(桑基图)、识别规则、系统设置(含 RouterOS 接入向导)。
- 中英双语,深浅色主题,适配手机。
- Docker 镜像(amd64 / arm64)发布到 GHCR(配置了 Docker Hub 密钥时同时发布到 Docker Hub)。
+46
View File
@@ -0,0 +1,46 @@
# ROS-DPI
RouterOS 终端流量与应用统计面板。按 今日 / 本周 / 本月 统计每台终端的流量和应用使用情况,类似 Panabit / 碧海威。
Per-device traffic and per-application (DPI-style) statistics for MikroTik RouterOS, built on IPFIX + DNS sniffing.
Source & docs: https://github.com/suyijun8182/ros-dpi
## What's new
- **0.1.0**: first release. Overview, hosts, applications, flow Sankey, custom rules, setup guide. zh/en, light/dark.
## 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
- Per-minute / hour / day aggregation in MySQL
## Quick start
```bash
curl -O https://raw.githubusercontent.com/suyijun8182/ros-dpi/main/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/suyijun8182/ros-dpi/main/.env.example
# edit .env (MySQL passwords, LAN_CIDRS)
docker compose up -d
```
Open `http://<host>:8080`. RouterOS commands are shown in **Settings → Setup guide**.
## Ports
| Port | Use |
|---|---|
| 8080/tcp | Web UI |
| 2055/udp | IPFIX / NetFlow v9 |
| 37008/udp | TZSP (DNS mirror) |
## Environment
| Variable | Default | Description |
|---|---|---|
| `DATABASE_URL` | set in compose | `mysql+pymysql://user:pass@host:3306/rosdpi?charset=utf8mb4` |
| `LAN_CIDRS` | private ranges | LAN ranges, comma separated |
| `ADMIN_PASSWORD` | empty | initial password for `admin` |
| `ROS_URL` / `ROS_USER` / `ROS_PASSWORD` | empty | RouterOS REST + read-only user |
| `TZ` | `Asia/Shanghai` | statistics time zone (weeks start Monday) |
+22
View File
@@ -0,0 +1,22 @@
# ---------- 阶段 1:构建前端 ----------
FROM node:20-alpine AS frontend
WORKDIR /fe
COPY frontend/package*.json ./
RUN npm ci || npm install
COPY frontend/ ./
RUN npm run build
# ---------- 阶段 2:后端运行时 ----------
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 TZ=Asia/Shanghai
WORKDIR /app
COPY backend/requirements.txt ./
RUN pip install -r requirements.txt
COPY backend/app ./app
COPY --from=frontend /fe/dist ./frontend_dist
RUN mkdir -p /app/data
VOLUME ["/app/data"]
# Web / IPFIX / TZSP-DNS
EXPOSE 8080/tcp 2055/udp 37008/udp
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s CMD python -c "import urllib.request,os;urllib.request.urlopen(f'http://127.0.0.1:{os.environ.get(\"HTTP_PORT\",\"8080\")}/api/health',timeout=4)"
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port ${HTTP_PORT:-8080} --no-access-log"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Su Yijun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+129
View File
@@ -0,0 +1,129 @@
# ROS-DPI
**RouterOS 终端流量与应用统计面板**:像 Panabit / 碧海威那样,按 今日 / 本周 / 本月 统计 RouterOS 下每台终端用了多少流量、用在了哪些应用上。
[中文](#中文) · [English](#english)
![全网概览](docs/images/overview-light.png)
---
## 中文
### 能做什么
- **全网概览**:今日 / 本周 / 本月流量,此刻的上下行速率,流量构成(按应用分类),Top 应用与 Top 终端,24 小时与 30 天趋势。
- **主机监控**:所有终端的 IP、MAC、主机名、在线状态、当前速率、今日 / 本周 / 本月流量,可以给设备起备注名;详情页可看该终端的应用排行、分类占比和访问过的域名。
- **应用统计**:100 多个内置应用(抖音、B 站、微信、Steam、Apple 更新、ChatGPT……)分 20 多个类别,可以查看每个应用被哪些终端使用。
- **流量流向**:桑基图展示 终端 → 应用分类 → 应用 的流向。
- **识别规则**:按域名后缀、IP 段、端口自定义识别规则;列出“未识别域名 Top”,一键归类。
- 中英双语,深浅色主题,手机可用。
![流量流向](docs/images/flow-light.png)
### 工作原理
RouterOS 本身没有 DPI,ROS-DPI 用两路数据拼出应用级统计:
```
RouterOS ──IPFIX udp/2055 ──▶ 流量采集:每条连接的终端、远端 IP、端口、字节数
RouterOS ──TZSP udp/37008──▶ DNS 嗅探:远端 IP 是哪个域名解析出来的
RouterOS ◀─REST 只读(可选)── 主机名、IP↔MAC、WAN 口速率、LAN 前缀
↓
域名 → 应用规则库 → 按 分钟 / 小时 / 天 汇总写入 MySQL
```
- 流量来自 `/ip traffic-flow`(IPFIX),统计的是真实字节数。
- 应用识别靠 DNS:终端访问 `v26.douyinvod.com` 时,路由器把 DNS 应答镜像一份过来,之后发往该 IP 的流量就归到“抖音”。查不到域名时,依次按 IP 段、端口兜底(HTTPS / QUIC 等)。
- 终端用 MAC 区分(来自 ROS 的 ARP / DHCP,或流记录中的源 MAC),IP 变化不影响统计。
**已知局限**:终端使用 DoH / DoT 加密 DNS,或者在路由器之外解析 DNS 时,这部分流量只能识别为 HTTPS / QUIC。可以在终端或路由器上禁用加密 DNS 来提高识别率。
### 部署
```bash
mkdir ros-dpi && cd ros-dpi
curl -O https://raw.githubusercontent.com/suyijun8182/ros-dpi/main/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/suyijun8182/ros-dpi/main/.env.example
# 编辑 .env:修改 MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD,填写 LAN_CIDRS
docker compose up -d
```
打开 `http://<主机IP>:8080`,第一次访问时设置管理员密码。
镜像:`ghcr.io/suyijun8182/ros-dpi`(amd64 / arm64)。已有 MySQL 时,删掉 compose 里的 `mysql` 服务,把 `DATABASE_URL` 指向你的数据库即可。
### RouterOS 配置
把 `192.168.88.10` 换成运行容器的主机 IP(**系统设置 → 接入向导**里可以直接复制填好 IP 的命令):
```routeros
# 1. 流量采集(IPFIX)
/ip traffic-flow set enabled=yes interfaces=all active-flow-timeout=1m inactive-flow-timeout=15s
/ip traffic-flow target add dst-address=192.168.88.10 port=2055 version=ipfix
# 2. DNS 嗅探(应用识别)
/ip firewall mangle add chain=output protocol=udp src-port=53 action=sniff-tzsp sniff-target=192.168.88.10 sniff-target-port=37008 comment=ROSDPI-DNS
/ip firewall mangle add chain=forward protocol=udp src-port=53 action=sniff-tzsp sniff-target=192.168.88.10 sniff-target-port=37008 comment=ROSDPI-DNS
# 3. 只读 API 账号(可选:主机名、WAN 速率)
/user group add name=rosdpi policy=read,rest-api,api,!write,!policy,!sensitive
/user add name=rosdpi group=rosdpi password=<强密码> address=192.168.88.10
```
更多说明(FastTrack、双 NAT、IPv6、排错)见 [docs/RouterOS配置.md](docs/RouterOS配置.md)。
### 环境变量
| 变量 | 默认值 | 说明 |
|---|---|---|
| `DATABASE_URL` | compose 中已设置 | `mysql+pymysql://user:pass@host:3306/rosdpi?charset=utf8mb4` |
| `LAN_CIDRS` | 私网三段 + `fd00::/8` | 内网网段,逗号分隔,界面里也能改 |
| `ADMIN_PASSWORD` | 空 | 首次启动创建 `admin` 用的密码;留空则在登录页设置 |
| `ROS_URL` / `ROS_USER` / `ROS_PASSWORD` | 空 | RouterOS REST 地址与只读账号,如 `https://192.168.88.1` |
| `ROS_VERIFY_TLS` | `false` | 是否校验 ROS 证书 |
| `WAN_INTERFACE` | 自动 | WAN 接口名,默认按默认路由识别 |
| `TZ` | `Asia/Shanghai` | 统计用时区,每周从周一开始 |
| `HTTP_PORT` / `IPFIX_PORT` / `TZSP_PORT` | 8080 / 2055 / 37008 | 端口 |
| `RETENTION_*_DAYS` | 2 / 45 / 730 / 31 | 分钟 / 小时 / 每日 / 域名明细的保留天数 |
| `SECRET_KEY` | 自动生成 | 登录令牌签名密钥 |
### 开发
```bash
# 后端
cd backend && python -m venv .venv && .venv/bin/pip install -r requirements.txt pytest
DATABASE_URL=sqlite:///./dev.db .venv/bin/uvicorn app.main:app --port 8080
.venv/bin/python -m pytest
# 前端
cd frontend && npm install && npm run dev # http://localhost:5173,/api 代理到 8080
# 灌入模拟数据
backend/.venv/bin/python scripts/simulate.py --minutes 5
```
应用规则库在 [backend/app/classify/apps.yaml](backend/app/classify/apps.yaml),欢迎 PR 补充。
---
## English
**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.
- **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.
**Deploy.** Copy `docker-compose.yml` and `.env.example` (as `.env`), set the MySQL passwords and `LAN_CIDRS`, run `docker compose up -d`, then open `http://<host>:8080`. RouterOS commands are listed above and in **Settings → Setup guide** inside the app.
## License
[MIT](LICENSE)
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
+123
View File
@@ -0,0 +1,123 @@
"""把一条流(远端 IP + 端口 + 域名)识别为应用。
优先级:自定义规则 > 域名后缀(先查询名再 CNAME,最长后缀优先)> CIDR > 端口 > other。
"""
import ipaddress
import os
from dataclasses import dataclass
import yaml
RULES_FILE = os.path.join(os.path.dirname(__file__), "apps.yaml")
UNKNOWN_APP = "other"
GENERIC_APPS = {"https", "quic", "http", UNKNOWN_APP} # 这些应用下的域名算"未识别"
@dataclass
class App:
id: str
name: str
name_en: str
category: str
custom: bool = False
def _parse_port(spec: str) -> tuple[int, int, int]:
proto, rng = spec.lower().split("/")
pnum = {"tcp": 6, "udp": 17}[proto]
lo, _, hi = rng.partition("-")
return pnum, int(lo), int(hi or lo)
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.categories: dict[str, dict] = data["categories"]
self._base_apps = data["apps"]
self.load_custom([])
def load_custom(self, custom_rules: list):
"""custom_rules: CustomRule 行(或同字段的对象)。每次规则变更后整体重建。"""
self.apps: dict[str, App] = {}
self.domains: dict[str, str] = {}
self.nets: list[tuple[ipaddress._BaseNetwork, str]] = []
self.ports: list[tuple[int, int, 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)
self.apps[UNKNOWN_APP] = App(UNKNOWN_APP, "其他", "Other", "unknown")
custom_nets, custom_ports = [], []
for r in custom_rules:
if r.app_id not in self.apps:
self.apps[r.app_id] = App(r.app_id, r.app_name or r.app_id, r.app_name or r.app_id,
r.category or "web", custom=True)
try:
if r.kind == "domain":
self.domains[r.pattern.lower().strip(".")] = r.app_id
elif r.kind == "cidr":
custom_nets.append((ipaddress.ip_network(r.pattern, strict=False), r.app_id))
elif r.kind == "port":
p, lo, hi = _parse_port(r.pattern)
custom_ports.append((p, lo, hi, r.app_id))
except (ValueError, KeyError):
continue
# 自定义的放前面,优先匹配
self.nets = custom_nets + self.nets
self.ports = custom_ports + self.ports
self._cache: dict[tuple, str] = {}
def _add(self, app_id, domains, cidrs, ports, first_wins):
for d in domains:
d = d.lower().strip(".")
if first_wins and d in self.domains:
continue
self.domains[d] = app_id
for c in cidrs:
self.nets.append((ipaddress.ip_network(c, strict=False), app_id))
for p in ports:
proto, lo, hi = _parse_port(p)
self.ports.append((proto, lo, hi, app_id))
def match_domain(self, name: str | None) -> str | None:
if not name:
return None
parts = name.lower().strip(".").split(".")
for i in range(len(parts) - 1):
hit = self.domains.get(".".join(parts[i:]))
if hit:
return hit
return None
def classify(self, remote_ip: str, proto: int, port: int, qname: str | None, cname: str | None) -> str:
key = (remote_ip, proto, port, qname, cname)
hit = self._cache.get(key)
if hit:
return hit
app = self.match_domain(qname) or self.match_domain(cname)
if app is None:
try:
addr = ipaddress.ip_address(remote_ip)
for net, aid in self.nets:
if addr.version == net.version and addr in net:
app = aid
break
except ValueError:
pass
if app is None:
for p, lo, hi, aid in self.ports:
if p == proto and lo <= port <= hi:
app = aid
break
app = app or UNKNOWN_APP
if len(self._cache) > 200_000:
self._cache.clear()
self._cache[key] = app
return app
def app_info(self, app_id: str) -> App:
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}
+254
View File
@@ -0,0 +1,254 @@
# 应用识别规则库
# domains: 后缀匹配(douyin.com 同时匹配 www.douyin.com)。先匹配终端查询的域名,再匹配 CNAME。
# cidrs: 目的 IP 段(没有 DNS 记录时使用)
# ports: "tcp/443"、"udp/3478-3481" 形式,最后兜底
# 欢迎通过 PR 补充;用户也可以在 设置 → 识别规则 里添加自定义规则(优先级最高)。
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}
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: 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,
domains: [iqiyi.com, iqiyipic.com, qiyi.com, qy.net, 71.am, iq.com, ppstream.com]}
- {id: tencent_video, name: 腾讯视频, name_en: Tencent Video, category: video,
domains: [v.qq.com, video.qq.com, vqq.com, gtimg.com, ugcvod.qq.com, apd-vlive.apdcdn.tc.qq.com, vod-tencent.com, tencentvideo.com]}
- {id: youku, name: 优酷, name_en: Youku, category: video,
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: 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: 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: netease_music, name: 网易云音乐, name_en: NetEase Music, category: music,
domains: [music.163.com, music.126.net, music.netease.com, iplay.163.com]}
- {id: qq_music, name: QQ音乐, name_en: QQ Music, category: music,
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: ximalaya, name: 喜马拉雅, name_en: Ximalaya, category: music,
domains: [ximalaya.com, xmcdn.com]}
- {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]}
# ---------- 即时通讯 / 社交 ----------
- {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,
domains: [epicgames.com, epicgames.dev, unrealengine.com, fortnite.com, epicgamescdn.com]}
- {id: tencent_games, name: 腾讯游戏, name_en: Tencent Games, category: game,
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: 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: 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: 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: 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,
domains: [icloud.com, icloud-content.com, icloud.com.cn, apple-cloudkit.com, cdn-apple.com]}
- {id: google_drive, name: Google Drive, name_en: Google Drive, category: cloud,
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: 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]}
# ---------- AI / 开发 / 办公 ----------
- {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: 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: 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: dns, name: DNS, name_en: DNS, category: network, ports: [udp/53, tcp/53, tcp/853, udp/853]}
- {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: 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]}
+45
View File
@@ -0,0 +1,45 @@
"""UDP 监听:IPFIX/NetFlow(默认 2055)与 TZSP DNS 镜像(默认 37008)。"""
import asyncio
import socket
class _Proto(asyncio.DatagramProtocol):
def __init__(self, handler):
self.handler = handler
def datagram_received(self, data, addr):
try:
self.handler(data, addr[0].removeprefix("::ffff:"))
except Exception as e: # noqa: BLE001 单个坏包不能影响监听
print(f"[collector] 处理数据包出错 {addr[0]}: {e!r}")
def _udp_socket(port: int) -> socket.socket:
"""优先建 IPv6 双栈 socket(显式关闭 IPV6_V6ONLY,Windows 默认是开的),不支持 IPv6 时退回 IPv4。"""
try:
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
s.bind(("::", port))
except OSError:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", port))
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024)
s.setblocking(False)
return s
async def start_listeners(engine, ipfix_port: int, tzsp_port: int):
loop = asyncio.get_running_loop()
transports = []
for port, handler, name in (
(ipfix_port, lambda d, a: engine.on_ipfix(d, a), "IPFIX/NetFlow"),
(tzsp_port, lambda d, a: engine.on_tzsp(d), "TZSP-DNS"),
):
try:
sock = _udp_socket(port)
t, _ = await loop.create_datagram_endpoint(lambda h=handler: _Proto(h), sock=sock)
transports.append(t)
print(f"[collector] {name} 监听 udp/{sock.getsockname()[1]}", flush=True)
except OSError as e:
print(f"[collector] {name} 无法监听 udp/{port}: {e}", flush=True)
return transports
+223
View File
@@ -0,0 +1,223 @@
"""IPFIX (v10) 与 NetFlow v9 解码。
RouterOS 的 /ip traffic-flow 两种都支持,模板会周期性重发(默认 20 个包或 30 分钟),
收到模板之前的数据集无法解码,会被计数后丢弃。
"""
import ipaddress
import struct
from dataclasses import dataclass
# IANA IPFIX 信息元素编号(NetFlow v9 的字段类型编号在这个范围内一致)
F_OCTETS = 1
F_PACKETS = 2
F_PROTO = 4
F_SRC_PORT = 7
F_SRC_V4 = 8
F_DST_PORT = 11
F_DST_V4 = 12
F_IN_IF = 10
F_OUT_IF = 14
F_SRC_V6 = 27
F_DST_V6 = 28
F_SRC_MAC = 56
F_DST_MAC = 80
F_POST_SRC_V4 = 225
F_POST_DST_V4 = 226
F_POST_SRC_PORT = 227
F_POST_DST_PORT = 228
_NAMES = {
F_OCTETS: "bytes",
F_PACKETS: "packets",
F_PROTO: "proto",
F_SRC_PORT: "sport",
F_DST_PORT: "dport",
F_SRC_V4: "src",
F_DST_V4: "dst",
F_SRC_V6: "src",
F_DST_V6: "dst",
F_IN_IF: "in_if",
F_OUT_IF: "out_if",
F_SRC_MAC: "src_mac",
F_DST_MAC: "dst_mac",
F_POST_SRC_V4: "post_src",
F_POST_DST_V4: "post_dst",
F_POST_SRC_PORT: "post_sport",
F_POST_DST_PORT: "post_dport",
}
_IP_FIELDS = {F_SRC_V4, F_DST_V4, F_SRC_V6, F_DST_V6, F_POST_SRC_V4, F_POST_DST_V4}
_MAC_FIELDS = {F_SRC_MAC, F_DST_MAC}
@dataclass
class Field:
fid: int
length: int # 65535 = 变长
enterprise: int = 0
class DecodeError(Exception):
pass
def _fmt_mac(b: bytes) -> str:
return ":".join(f"{x:02X}" for x in b)
class FlowDecoder:
def __init__(self):
# (exporter, version, domain/source_id, template_id) -> [Field]
self.templates: dict[tuple, list[Field]] = {}
self.stats = {"packets": 0, "records": 0, "no_template": 0, "errors": 0}
def decode(self, data: bytes, exporter: str) -> list[dict]:
self.stats["packets"] += 1
try:
if len(data) < 4:
raise DecodeError("too short")
version = struct.unpack_from("!H", data, 0)[0]
if version == 10:
return self._ipfix(data, exporter)
if version == 9:
return self._v9(data, exporter)
raise DecodeError(f"unsupported version {version}")
except (DecodeError, struct.error, ValueError, IndexError):
self.stats["errors"] += 1
return []
# ---------- IPFIX ----------
def _ipfix(self, data: bytes, exporter: str) -> list[dict]:
if len(data) < 16:
raise DecodeError("short ipfix header")
_, length, _export_time, _seq, domain = struct.unpack_from("!HHIII", data, 0)
end = min(length, len(data))
off = 16
out: list[dict] = []
while off + 4 <= end:
set_id, set_len = struct.unpack_from("!HH", data, off)
if set_len < 4:
raise DecodeError("bad set length")
body = data[off + 4: off + set_len]
if set_id == 2:
self._ipfix_templates(body, (exporter, 10, domain), options=False)
elif set_id == 3:
self._ipfix_templates(body, (exporter, 10, domain), options=True)
elif set_id >= 256:
out.extend(self._records(body, (exporter, 10, domain, set_id), ipfix=True))
off += set_len
return out
def _ipfix_templates(self, body: bytes, key: tuple, options: bool):
off = 0
while off + 4 <= len(body):
tid, count = struct.unpack_from("!HH", body, off)
off += 4
if tid < 256: # 填充
break
if options:
off += 2 # scope field count,作用域字段也在下面的列表里
fields = []
for _ in range(count):
fid, flen = struct.unpack_from("!HH", body, off)
off += 4
ent = 0
if fid & 0x8000:
fid &= 0x7FFF
ent = struct.unpack_from("!I", body, off)[0]
off += 4
fields.append(Field(fid, flen, ent))
# 选项模板(采样率、接口名等)只记下长度用于跳过,不解码
self.templates[key + (tid,)] = fields if not options else [Field(-1, f.length, f.enterprise) for f in fields]
# ---------- NetFlow v9 ----------
def _v9(self, data: bytes, exporter: str) -> list[dict]:
if len(data) < 20:
raise DecodeError("short v9 header")
_, _count, _uptime, _secs, _seq, source_id = struct.unpack_from("!HHIIII", data, 0)
off = 20
out: list[dict] = []
while off + 4 <= len(data):
set_id, set_len = struct.unpack_from("!HH", data, off)
if set_len < 4:
raise DecodeError("bad flowset length")
body = data[off + 4: off + set_len]
key = (exporter, 9, source_id)
if set_id == 0:
self._v9_templates(body, key)
elif set_id == 1:
self._v9_options(body, key)
elif set_id >= 256:
out.extend(self._records(body, key + (set_id,), ipfix=False))
off += set_len
return out
def _v9_templates(self, body: bytes, key: tuple):
off = 0
while off + 4 <= len(body):
tid, count = struct.unpack_from("!HH", body, off)
off += 4
if tid < 256:
break
fields = []
for _ in range(count):
fid, flen = struct.unpack_from("!HH", body, off)
off += 4
fields.append(Field(fid, flen))
self.templates[key + (tid,)] = fields
def _v9_options(self, body: bytes, key: tuple):
off = 0
while off + 6 <= len(body):
tid, scope_len, opt_len = struct.unpack_from("!HHH", body, off)
off += 6
if tid < 256:
break
fields = []
for _ in range((scope_len + opt_len) // 4):
_fid, flen = struct.unpack_from("!HH", body, off)
off += 4
fields.append(Field(-1, flen))
self.templates[key + (tid,)] = fields
# ---------- 数据记录 ----------
def _records(self, body: bytes, tkey: tuple, ipfix: bool) -> list[dict]:
fields = self.templates.get(tkey)
if fields is None:
self.stats["no_template"] += 1
return []
if not fields:
return []
min_len = sum(f.length if f.length != 65535 else 1 for f in fields)
out = []
off = 0
while len(body) - off >= min_len:
rec: dict = {}
for f in fields:
flen = f.length
if flen == 65535:
flen = body[off]
off += 1
if flen == 255:
flen = struct.unpack_from("!H", body, off)[0]
off += 2
raw = body[off: off + flen]
off += flen
if len(raw) < flen:
raise DecodeError("truncated record")
if f.enterprise or f.fid < 0:
continue
name = _NAMES.get(f.fid)
if name is None:
continue
if f.fid in _IP_FIELDS:
if flen in (4, 16):
rec[name] = str(ipaddress.ip_address(raw))
elif f.fid in _MAC_FIELDS:
if flen == 6:
rec[name] = _fmt_mac(raw)
else:
rec[name] = int.from_bytes(raw, "big")
if "bytes" in rec:
out.append(rec)
self.stats["records"] += len(out)
return out
+155
View File
@@ -0,0 +1,155 @@
"""解 RouterOS sniff-tzsp 镜像过来的包:TZSP → 以太网 → IPv4/IPv6 → UDP → DNS 响应。"""
import ipaddress
import struct
from dataclasses import dataclass, field
QTYPE_A = 1
QTYPE_CNAME = 5
QTYPE_AAAA = 28
@dataclass
class DnsAnswer:
client: str # 收到响应的终端(UDP 目的地址)
qname: str # 终端查询的名字
ips: list[tuple[str, int]] = field(default_factory=list) # (ip, ttl)
cnames: list[str] = field(default_factory=list)
def tzsp_payload(data: bytes) -> bytes | None:
"""返回 TZSP 里封装的以太网帧。"""
if len(data) < 4 or data[0] != 1:
return None
encap = struct.unpack_from("!H", data, 2)[0]
if encap != 1: # 只处理 Ethernet
return None
off = 4
while off < len(data):
tag = data[off]
if tag == 1: # TAG_END
return data[off + 1:]
if tag == 0: # TAG_PADDING
off += 1
continue
if off + 1 >= len(data):
return None
off += 2 + data[off + 1]
return None
def ethernet_udp(frame: bytes) -> tuple[str, str, int, int, bytes] | None:
"""以太网帧 → (src_ip, dst_ip, sport, dport, udp_payload),非 UDP 返回 None。"""
if len(frame) < 14:
return None
off = 12
etype = struct.unpack_from("!H", frame, off)[0]
off += 2
while etype in (0x8100, 0x88A8): # VLAN / QinQ
if len(frame) < off + 4:
return None
etype = struct.unpack_from("!H", frame, off + 2)[0]
off += 4
if etype == 0x0800:
if len(frame) < off + 20:
return None
ihl = (frame[off] & 0x0F) * 4
if frame[off + 9] != 17:
return None
src = str(ipaddress.IPv4Address(frame[off + 12: off + 16]))
dst = str(ipaddress.IPv4Address(frame[off + 16: off + 20]))
off += ihl
elif etype == 0x86DD:
if len(frame) < off + 40:
return None
if frame[off + 6] != 17: # 不处理扩展头
return None
src = str(ipaddress.IPv6Address(frame[off + 8: off + 24]))
dst = str(ipaddress.IPv6Address(frame[off + 24: off + 40]))
off += 40
else:
return None
if len(frame) < off + 8:
return None
sport, dport, ulen = struct.unpack_from("!HHH", frame, off)
payload = frame[off + 8: off + max(ulen, 8)]
return src, dst, sport, dport, payload
def _read_name(msg: bytes, off: int) -> tuple[str, int]:
labels = []
jumped = False
end = off
hops = 0
while True:
if off >= len(msg):
raise ValueError("name out of range")
ln = msg[off]
if ln & 0xC0 == 0xC0:
if off + 1 >= len(msg):
raise ValueError("bad pointer")
ptr = ((ln & 0x3F) << 8) | msg[off + 1]
if not jumped:
end = off + 2
jumped = True
off = ptr
hops += 1
if hops > 20:
raise ValueError("pointer loop")
continue
if ln == 0:
if not jumped:
end = off + 1
break
labels.append(msg[off + 1: off + 1 + ln].decode("ascii", "replace"))
off += 1 + ln
return ".".join(labels).lower(), end
def parse_dns_response(msg: bytes) -> tuple[str, list[tuple[str, int]], list[str]] | None:
"""返回 (qname, [(ip, ttl)], [cname...]);不是响应或没有 A/AAAA 时返回 None。"""
if len(msg) < 12:
return None
_id, flags, qd, an, _ns, _ar = struct.unpack_from("!HHHHHH", msg, 0)
if not flags & 0x8000 or flags & 0x000F or qd != 1 or an == 0:
return None
qname, off = _read_name(msg, 12)
off += 4
ips: list[tuple[str, int]] = []
cnames: list[str] = []
for _ in range(an):
_name, off = _read_name(msg, off)
if off + 10 > len(msg):
break
rtype, _cls, ttl, rdlen = struct.unpack_from("!HHIH", msg, off)
off += 10
rdata = msg[off: off + rdlen]
if rtype == QTYPE_A and rdlen == 4:
ips.append((str(ipaddress.IPv4Address(rdata)), ttl))
elif rtype == QTYPE_AAAA and rdlen == 16:
ips.append((str(ipaddress.IPv6Address(rdata)), ttl))
elif rtype == QTYPE_CNAME:
cnames.append(_read_name(msg, off)[0])
off += rdlen
if not ips or not qname:
return None
return qname, ips, cnames
def parse_tzsp_dns(data: bytes) -> DnsAnswer | None:
frame = tzsp_payload(data)
if frame is None:
return None
udp = ethernet_udp(frame)
if udp is None:
return None
_src, dst, sport, _dport, payload = udp
if sport != 53:
return None
try:
parsed = parse_dns_response(payload)
except (ValueError, struct.error, ipaddress.AddressValueError):
return None
if parsed is None:
return None
qname, ips, cnames = parsed
return DnsAnswer(client=dst, qname=qname, ips=ips, cnames=cnames)
+52
View File
@@ -0,0 +1,52 @@
"""运行配置:全部来自环境变量(见 .env.example)。
界面里可修改的项(LAN 网段、保留天数、WAN 接口)存到数据库 settings 表,
优先级高于这里的默认值,见 app.runtime。
"""
from functools import lru_cache
from zoneinfo import ZoneInfo
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# mysql+pymysql://user:pass@host:3306/rosdpi?charset=utf8mb4;开发/测试可用 sqlite:///./dev.db
database_url: str = "sqlite:///./data/rosdpi.db"
http_port: int = 8080
ipfix_port: int = 2055
tzsp_port: int = 37008
# 逗号分隔,界面可覆盖
lan_cidrs: str = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fd00::/8"
wan_interface: str = ""
# RouterOS REST(只读用户)。留空则不轮询,终端只显示 IP。
ros_url: str = ""
ros_user: str = ""
ros_password: str = ""
ros_verify_tls: bool = False
ros_poll_seconds: int = 30
tz: str = "Asia/Shanghai"
retention_minute_days: int = 2
retention_hour_days: int = 45
retention_day_days: int = 730
retention_domain_days: int = 31
# JWT 密钥;留空则首次启动随机生成并保存到数据库
secret_key: str = ""
# 首次启动的管理员密码;留空则由登录页引导设置
admin_password: str = ""
@property
def zone(self) -> ZoneInfo:
return ZoneInfo(self.tz)
@lru_cache
def get_settings() -> Settings:
return Settings()
+79
View File
@@ -0,0 +1,79 @@
import os
import time
from sqlalchemy import create_engine, event
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.config import get_settings
class Base(DeclarativeBase):
pass
def _make_engine(url: str):
if url.startswith("sqlite"):
path = url.split("///", 1)[-1]
if path and path != ":memory:":
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
eng = create_engine(url, connect_args={"check_same_thread": False})
@event.listens_for(eng, "connect")
def _pragma(conn, _):
cur = conn.cursor()
cur.execute("PRAGMA journal_mode=WAL")
cur.execute("PRAGMA synchronous=NORMAL")
cur.close()
return eng
return create_engine(url, pool_pre_ping=True, pool_recycle=1800, pool_size=10, max_overflow=10)
engine = _make_engine(get_settings().database_url)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def is_mysql() -> bool:
return engine.dialect.name == "mysql"
def upsert_add(session, table, rows: list[dict], keys: list[str], add_cols: list[str], set_cols: list[str] = ()):
"""批量 upsert:主键冲突时 add_cols 累加、set_cols 覆盖。MySQL / SQLite 通用。"""
if not rows:
return
if is_mysql():
stmt = mysql_insert(table).values(rows)
upd = {c: table.c[c] + stmt.inserted[c] for c in add_cols}
upd.update({c: stmt.inserted[c] for c in set_cols})
stmt = stmt.on_duplicate_key_update(**upd)
else:
stmt = sqlite_insert(table).values(rows)
upd = {c: table.c[c] + stmt.excluded[c] for c in add_cols}
upd.update({c: stmt.excluded[c] for c in set_cols})
stmt = stmt.on_conflict_do_update(index_elements=keys, set_=upd)
session.execute(stmt)
def init_db(retries: int = 20, delay: float = 3.0):
"""建表。容器启动时 MySQL 往往还没就绪,这里重试。"""
from app import models # noqa: F401 注册模型
for i in range(1, retries + 1):
try:
Base.metadata.create_all(engine)
return
except Exception as e: # noqa: BLE001
print(f"[db] 数据库未就绪({i}/{retries}):{type(e).__name__}: {str(e)[:120]}")
if i == retries:
raise
time.sleep(delay)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+55
View File
@@ -0,0 +1,55 @@
"""IP → 域名 映射表(从嗅探到的 DNS 响应建立)。
DNS TTL 往往只有几十秒,但连接会持续很久,所以保留时间取 max(TTL, MIN_KEEP)。
同一个 CDN IP 可能对应多个域名,这里保留最近一次的结果。
"""
import time
from collections import OrderedDict
MIN_KEEP = 6 * 3600
MAX_KEEP = 48 * 3600
MAX_ENTRIES = 300_000
class DnsMap:
def __init__(self, max_entries: int = MAX_ENTRIES):
self._d: OrderedDict[str, tuple[str, str | None, int]] = OrderedDict()
self.max_entries = max_entries
self.answers = 0
self.dirty: dict[str, tuple[str, str | None, int]] = {}
def add(self, qname: str, ips: list[tuple[str, int]], cnames: list[str], now: float | None = None):
now = int(now or time.time())
cname = cnames[-1] if cnames else None
self.answers += 1
for ip, ttl in ips:
exp = now + min(max(ttl, MIN_KEEP), MAX_KEEP)
val = (qname, cname, exp)
self._d[ip] = val
self._d.move_to_end(ip)
self.dirty[ip] = val
while len(self._d) > self.max_entries:
self._d.popitem(last=False)
def lookup(self, ip: str) -> tuple[str, str | None] | None:
# 过期条目也照常返回(比没有强),由 purge 定期清理
v = self._d.get(ip)
return (v[0], v[1]) if v else None
def load(self, rows):
for ip, name, cname, exp in rows:
self._d[ip] = (name, cname, exp)
def purge(self, now: float | None = None) -> int:
now = now or time.time()
dead = [ip for ip, v in self._d.items() if v[2] < now]
for ip in dead:
del self._d[ip]
return len(dead)
def take_dirty(self) -> dict:
d, self.dirty = self.dirty, {}
return d
def __len__(self):
return len(self._d)
+260
View File
@@ -0,0 +1,260 @@
"""流量处理核心:流记录 → 判断终端与方向 → 查域名 → 识别应用 → 内存累加 → 定时落库。"""
import ipaddress
import threading
import time
from collections import defaultdict
from sqlalchemy import delete, select
from app import timeutil
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.database import SessionLocal, upsert_add
from app.dnsmap import DnsMap
from app.models import (CustomRule, DnsCache, Host, HostDomainDay, Setting, TrafficDay, TrafficHour,
TrafficMinute)
_ZERO = {"0.0.0.0", "::"}
def parse_cidrs(text: str) -> list:
nets = []
for part in (text or "").replace("\n", ",").split(","):
part = part.strip()
if part:
try:
nets.append(ipaddress.ip_network(part, strict=False))
except ValueError:
pass
return nets
class Engine:
def __init__(self):
s = get_settings()
self.decoder = FlowDecoder()
self.dns = DnsMap()
self.classifier = Classifier()
self.lock = threading.Lock()
self.lan_cidrs_text = s.lan_cidrs
self.lan_nets = parse_cidrs(s.lan_cidrs)
self.auto_nets: list = [] # ros_poller 自动发现的 LAN 前缀(主要是 IPv6 公网前缀)
self.ip2mac: dict[str, str] = {} # ARP / IPv6 邻居 / DHCP
self.mac2name: dict[str, str] = {}
self._lan_cache: dict[str, bool] = {}
self._buf: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0, 0])
self._dom: dict[tuple[str, str, str], int] = defaultdict(int)
self._seen: dict[str, tuple[str, str | None, int]] = {} # host_key -> (ip, mac, ts)
# 最近一个分钟窗口的统计,用于实时速率
self.last_flush = time.time()
self.host_rate: dict[str, tuple[float, float]] = {}
self.app_rate: dict[str, tuple[float, float]] = {}
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": {}}
# ---------- 配置 ----------
def set_lan(self, text: str):
self.lan_cidrs_text = text
self.lan_nets = parse_cidrs(text)
self._lan_cache.clear()
def set_auto_nets(self, nets: list):
if set(map(str, nets)) != set(map(str, self.auto_nets)):
self.auto_nets = nets
self._lan_cache.clear()
def reload_rules(self):
with SessionLocal() as db:
rules = db.scalars(select(CustomRule)).all()
self.classifier.load_custom(rules)
def is_lan(self, ip: str | None) -> bool:
if not ip or ip in _ZERO:
return False
hit = self._lan_cache.get(ip)
if hit is not None:
return hit
try:
a = ipaddress.ip_address(ip)
except ValueError:
return False
if a.version == 6 and a.is_link_local:
hit = True
else:
hit = any(a.version == n.version and a in n for n in self.lan_nets + self.auto_nets)
if len(self._lan_cache) > 100_000:
self._lan_cache.clear()
self._lan_cache[ip] = hit
return hit
# ---------- 输入 ----------
def on_ipfix(self, data: bytes, exporter: str):
recs = self.decoder.decode(data, exporter)
now = time.time()
ex = self.stats["exporters"]
ex[exporter] = int(now)
with self.lock:
for r in recs:
self.add_flow(r, now)
def on_tzsp(self, data: bytes):
self.stats["dns_packets"] += 1
ans = parse_tzsp_dns(data)
if ans is None:
return
self.stats["dns_answers"] += 1
self.stats["last_dns"] = int(time.time())
self.dns.add(ans.qname, ans.ips, ans.cnames)
def host_key(self, ip: str, mac: str | None) -> tuple[str, str | None]:
m = self.ip2mac.get(ip)
if not m and mac:
# 没配 ROS 轮询时,靠上行流里的源 MAC 学习 IP→MAC
self.ip2mac[ip] = m = mac
if m:
return m, m
return f"IP:{ip}", None
def add_flow(self, r: dict, now: float):
"""调用方持有 self.lock。返回 (host_key, app, direction) 便于测试。"""
src, dst = r.get("src"), r.get("dst")
post_src, post_dst = r.get("post_src"), r.get("post_dst")
proto = r.get("proto", 0)
nbytes = r.get("bytes", 0)
if not src or not dst or nbytes <= 0:
self.stats["flows_dropped"] += 1
return None
src_lan, dst_lan = self.is_lan(src), self.is_lan(dst)
mac = None
if src_lan and not dst_lan:
direction, host_ip, remote, rport = "up", src, dst, r.get("dport", 0)
mac = r.get("src_mac")
elif dst_lan and not src_lan:
# 下行包在 WAN 口入方向采集,记录里的 MAC 是路由器自己的,不能用
direction, host_ip, remote, rport = "down", dst, src, r.get("sport", 0)
elif not src_lan and not dst_lan and self.is_lan(post_dst):
# WAN 入方向、记录的是 DNAT 前地址:用 post-NAT 目的地址找终端
direction, host_ip, remote, rport = "down", post_dst, src, r.get("sport", 0)
else:
# 内网互访,或路由器自身的流量
self.stats["flows_dropped"] += 1
return None
names = self.dns.lookup(remote)
qname, cname = names if names else (None, None)
app = self.classifier.classify(remote, proto, rport, qname, cname)
key, mac = self.host_key(host_ip, mac)
b = self._buf[(key, app)]
if direction == "up":
b[0] += nbytes
else:
b[1] += nbytes
b[2] += 1
if qname:
self._dom[(key, qname, app)] += nbytes
self._seen[key] = (host_ip, mac, int(now))
self.stats["flows"] += 1
self.stats["bytes_seen"] += nbytes
self.stats["last_flow"] = int(now)
return key, app, direction
# ---------- 落库 ----------
def flush(self, now: float | None = None):
now = now or time.time()
with self.lock:
buf, self._buf = self._buf, defaultdict(lambda: [0, 0, 0])
dom, self._dom = self._dom, defaultdict(int)
seen, self._seen = self._seen, {}
elapsed = max(now - self.last_flush, 1.0)
self.last_flush = now
hr, ar = defaultdict(lambda: [0, 0]), defaultdict(lambda: [0, 0])
for (host, app), (up, down, _f) in buf.items():
hr[host][0] += up
hr[host][1] += down
ar[app][0] += up
ar[app][1] += down
self.host_rate = {h: (u / elapsed, d / elapsed) for h, (u, d) in hr.items()}
self.app_rate = {a: (u / elapsed, d / elapsed) for a, (u, d) in ar.items()}
self.total_rate = (sum(v[0] for v in hr.values()) / elapsed, sum(v[1] for v in hr.values()) / elapsed)
m, h, d = timeutil.minute_bucket(now), timeutil.hour_bucket(now), timeutil.day_bucket(now)
rows = [{"host": host, "app": app, "up": up, "down": down, "flows": f}
for (host, app), (up, down, f) in buf.items()]
dirty_dns = self.dns.take_dirty()
with SessionLocal() as db:
for table, ts in ((TrafficMinute.__table__, m), (TrafficHour.__table__, h), (TrafficDay.__table__, d)):
for chunk in _chunks([{**r, "ts": ts} for r in rows], 500):
upsert_add(db, table, chunk, ["ts", "host", "app"], ["up", "down", "flows"])
drows = [{"ts": d, "host": host, "domain": name[:255], "app": app, "bytes": n}
for (host, name, app), n in dom.items()]
for chunk in _chunks(drows, 500):
upsert_add(db, HostDomainDay.__table__, chunk, ["ts", "host", "domain"], ["bytes"], ["app"])
self._save_hosts(db, seen)
if dirty_dns:
drs = [{"ip": ip, "name": v[0][:255], "cname": (v[1] or "")[:255] or None, "expires": v[2]}
for ip, v in dirty_dns.items()]
for chunk in _chunks(drs, 500):
upsert_add(db, DnsCache.__table__, chunk, ["ip"], [], ["name", "cname", "expires"])
db.commit()
def _save_hosts(self, db, seen: dict):
if not seen:
return
rows = []
for key, (ip, mac, ts) in seen.items():
rows.append({"key": key, "mac": mac, "ip": ip, "hostname": self.mac2name.get(mac) if mac else None,
"first_seen": ts, "last_seen": ts})
# 已存在的终端只更新 ip / last_seen,first_seen 与备注名保持不变
existing = {h.key: h for h in db.scalars(select(Host).where(Host.key.in_(list(seen))))}
for r in rows:
h = existing.get(r["key"])
if h is None:
db.add(Host(**r))
continue
h.ip, h.last_seen = r["ip"], r["last_seen"]
h.first_seen = h.first_seen or r["first_seen"]
h.mac = h.mac or r["mac"]
if r["hostname"]:
h.hostname = r["hostname"]
def load_state(self):
"""启动时恢复 DNS 映射、自定义规则和界面设置。"""
now = int(time.time())
with SessionLocal() as db:
rows = db.execute(select(DnsCache.ip, DnsCache.name, DnsCache.cname, DnsCache.expires)
.where(DnsCache.expires > now)).all()
lan = db.get(Setting, "lan_cidrs")
self.dns.load(rows)
if lan and lan.value:
self.set_lan(lan.value)
self.reload_rules()
def cleanup(self):
s = get_settings()
now = int(time.time())
with SessionLocal() as db:
db.execute(delete(TrafficMinute).where(TrafficMinute.ts < now - s.retention_minute_days * 86400))
db.execute(delete(TrafficHour).where(TrafficHour.ts < now - s.retention_hour_days * 86400))
db.execute(delete(TrafficDay).where(TrafficDay.ts < now - s.retention_day_days * 86400))
db.execute(delete(HostDomainDay).where(HostDomainDay.ts < now - s.retention_domain_days * 86400))
db.execute(delete(DnsCache).where(DnsCache.expires < now))
db.commit()
self.dns.purge()
def is_generic(self, app: str) -> bool:
return app in GENERIC_APPS
def _chunks(seq, n):
for i in range(0, len(seq), n):
yield seq[i: i + n]
engine = Engine()
+89
View File
@@ -0,0 +1,89 @@
import asyncio
import os
from contextlib import asynccontextmanager
from apscheduler.schedulers.background import BackgroundScheduler
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app import __version__
from app.collector import start_listeners
from app.config import get_settings
from app.database import SessionLocal, init_db
from app.engine import engine
from app.models import Setting
from app.ros_poller import poller
from app.routers import admin, auth, stats
from app.security import ensure_admin
FRONTEND_DIR = os.environ.get("FRONTEND_DIR", os.path.join(os.path.dirname(__file__), "..", "frontend_dist"))
def _safe(fn, name):
def run():
try:
fn()
except Exception as e: # noqa: BLE001
print(f"[job] {name} 失败:{type(e).__name__}: {e}")
return run
@asynccontextmanager
async def lifespan(app: FastAPI):
s = get_settings()
await asyncio.to_thread(init_db)
ensure_admin()
engine.load_state()
with SessionLocal() as db:
wan = db.get(Setting, "wan_interface")
poller.wan_override = wan.value if wan and wan.value else ""
transports = await start_listeners(engine, s.ipfix_port, s.tzsp_port)
sched = BackgroundScheduler(timezone=s.tz)
sched.add_job(_safe(engine.flush, "flush"), "cron", second=0, max_instances=1, coalesce=True)
sched.add_job(_safe(engine.cleanup, "cleanup"), "cron", minute=17, max_instances=1)
if poller.enabled:
sched.add_job(_safe(poller.poll, "ros-poll"), "interval", seconds=s.ros_poll_seconds,
max_instances=1, coalesce=True)
await asyncio.to_thread(poller.poll)
sched.start()
print(f"[startup] ROS-DPI {__version__} 已启动,Web 端口 {s.http_port}")
yield
sched.shutdown(wait=False)
for t in transports:
t.close()
_safe(engine.flush, "flush")()
app = FastAPI(title="ROS-DPI", version=__version__, lifespan=lifespan)
@app.exception_handler(Exception)
async def _unhandled(request: Request, exc: Exception):
print(f"[error] {request.method} {request.url.path}: {exc!r}")
msg = (str(exc).splitlines() or [""])[0][:300]
return JSONResponse(status_code=500, content={"detail": f"服务器内部错误:{type(exc).__name__}: {msg}"})
for r in (auth.router, stats.router, admin.router):
app.include_router(r)
@app.get("/api/health")
def health():
return {"ok": True, "version": __version__}
if os.path.isdir(FRONTEND_DIR):
app.mount("/assets", StaticFiles(directory=os.path.join(FRONTEND_DIR, "assets")), name="assets")
@app.get("/{path:path}", include_in_schema=False)
def spa(path: str):
root = os.path.realpath(FRONTEND_DIR)
f = os.path.realpath(os.path.join(root, path))
if path and f.startswith(root + os.sep) and os.path.isfile(f):
return FileResponse(f)
if path.startswith("api/"):
return JSONResponse(status_code=404, content={"detail": "Not Found"})
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
+89
View File
@@ -0,0 +1,89 @@
"""数据表。时间一律存 epoch 秒(BigInteger),分桶起点:分钟/小时按 UTC 对齐,天按配置时区的 0 点。"""
from sqlalchemy import BigInteger, Column, Index, Integer, String, Text
from app.database import Base
def _traffic_cols():
return [
Column("ts", BigInteger, primary_key=True),
Column("host", String(64), primary_key=True),
Column("app", String(48), primary_key=True),
Column("up", BigInteger, nullable=False, default=0),
Column("down", BigInteger, nullable=False, default=0),
Column("flows", Integer, nullable=False, default=0),
]
class TrafficMinute(Base):
__tablename__ = "traffic_minute"
ts, host, app, up, down, flows = _traffic_cols()
__table_args__ = (Index("ix_tmin_host_ts", "host", "ts"),)
class TrafficHour(Base):
__tablename__ = "traffic_hour"
ts, host, app, up, down, flows = _traffic_cols()
__table_args__ = (Index("ix_thour_host_ts", "host", "ts"), Index("ix_thour_app_ts", "app", "ts"))
class TrafficDay(Base):
__tablename__ = "traffic_day"
ts, host, app, up, down, flows = _traffic_cols()
__table_args__ = (Index("ix_tday_host_ts", "host", "ts"), Index("ix_tday_app_ts", "app", "ts"))
class HostDomainDay(Base):
"""每台终端每天访问的域名及字节数(主机详情 "最近访问域名"、未识别域名 Top)。"""
__tablename__ = "host_domain_day"
ts = Column(BigInteger, primary_key=True)
host = Column(String(64), primary_key=True)
domain = Column(String(255), primary_key=True)
app = Column(String(48), nullable=False)
bytes = Column(BigInteger, nullable=False, default=0)
__table_args__ = (Index("ix_hdd_host_ts", "host", "ts"), Index("ix_hdd_app_ts", "app", "ts"))
class Host(Base):
"""终端。key 优先用 MAC(大写冒号格式),拿不到 MAC 时用 "IP:x.x.x.x"。"""
__tablename__ = "hosts"
key = Column(String(64), primary_key=True)
mac = Column(String(17))
ip = Column(String(45))
ipv6 = Column(Text)
hostname = Column(String(128))
alias = Column(String(128))
first_seen = Column(BigInteger)
last_seen = Column(BigInteger)
class DnsCache(Base):
__tablename__ = "dns_cache"
ip = Column(String(45), primary_key=True)
name = Column(String(255), nullable=False)
cname = Column(String(255))
expires = Column(BigInteger, nullable=False)
class CustomRule(Base):
__tablename__ = "custom_rules"
id = Column(Integer, primary_key=True, autoincrement=True)
kind = Column(String(16), nullable=False) # domain | cidr | port
pattern = Column(String(255), nullable=False)
app_id = Column(String(48), nullable=False)
app_name = Column(String(64))
category = Column(String(32))
created = Column(BigInteger)
class Setting(Base):
__tablename__ = "settings"
key = Column(String(64), primary_key=True)
value = Column(Text)
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(64), unique=True, nullable=False)
password_hash = Column(String(128), nullable=False)
+150
View File
@@ -0,0 +1,150 @@
"""通过 RouterOS REST API(只读,仅 GET)获取终端名称、IP↔MAC、LAN 前缀、WAN 口速率和系统状态。"""
import ipaddress
import time
import httpx
from app.config import get_settings
class RosPoller:
def __init__(self, engine):
self.engine = engine
self.ok = False
self.error = ""
self.last_poll = 0
self.identity = ""
self.resource: dict = {}
self.interfaces: list[dict] = []
self.wan_name = ""
self.wan_rate = (0.0, 0.0) # (上行 tx, 下行 rx) 字节/秒
self._wan_prev: tuple[float, int, int] | None = None
self.wan_override = ""
self.leases: dict[str, dict] = {} # mac -> lease
@property
def enabled(self) -> bool:
return bool(get_settings().ros_url)
def _client(self) -> httpx.Client:
s = get_settings()
return httpx.Client(base_url=s.ros_url.rstrip("/") + "/rest", auth=(s.ros_user, s.ros_password),
verify=s.ros_verify_tls, timeout=10.0)
def poll(self):
if not self.enabled:
return
try:
with self._client() as c:
get = lambda p: (r := c.get(p)).raise_for_status() or r.json() # noqa: E731
ident = get("/system/identity")
self.identity = ident.get("name", "") if isinstance(ident, dict) else ""
self.resource = get("/system/resource")
ifaces = get("/interface")
arp = get("/ip/arp")
leases = get("/ip/dhcp-server/lease")
addrs = get("/ip/address")
routes = get("/ip/route?dst-address=0.0.0.0/0")
try:
nb6 = get("/ipv6/neighbor")
addrs6 = get("/ipv6/address")
except httpx.HTTPError:
nb6, addrs6 = [], [] # IPv6 包未启用
self._apply(ifaces, arp, leases, addrs, routes, nb6, addrs6)
self.ok, self.error = True, ""
except Exception as e: # noqa: BLE001
self.ok, self.error = False, f"{type(e).__name__}: {str(e)[:200]}"
print(f"[ros] 轮询失败:{self.error}")
self.last_poll = int(time.time())
def _apply(self, ifaces, arp, leases, addrs, routes, nb6, addrs6):
now = time.time()
self.interfaces = [
{"name": i.get("name"), "type": i.get("type"), "running": i.get("running") == "true",
"rx": int(i.get("rx-byte", 0) or 0), "tx": int(i.get("tx-byte", 0) or 0)}
for i in ifaces
]
self.wan_name = self.wan_override or get_settings().wan_interface or _default_route_iface(routes)
wan = next((i for i in self.interfaces if i["name"] == self.wan_name), None)
if wan:
if self._wan_prev and self._wan_prev[0] < now:
dt = now - self._wan_prev[0]
self.wan_rate = (max(wan["tx"] - self._wan_prev[2], 0) / dt,
max(wan["rx"] - self._wan_prev[1], 0) / dt)
self._wan_prev = (now, wan["rx"], wan["tx"])
ip2mac: dict[str, str] = {}
for a in arp:
if a.get("mac-address") and a.get("address"):
ip2mac[a["address"]] = a["mac-address"].upper()
for n in nb6:
if n.get("mac-address") and n.get("address"):
ip2mac[n["address"]] = n["mac-address"].upper()
names: dict[str, str] = {}
self.leases = {}
for le in leases:
mac = (le.get("mac-address") or le.get("active-mac-address") or "").upper()
if not mac:
continue
ip = le.get("active-address") or le.get("address")
if ip and "/" not in ip:
ip2mac.setdefault(ip, mac)
name = le.get("comment") or le.get("host-name") or le.get("active-host-name")
if name:
names[mac] = name
self.leases[mac] = le
self.engine.ip2mac.update(ip2mac)
self.engine.mac2name.update(names)
# 非 WAN 接口上的地址段视为 LAN(自动补充 IPv6 公网前缀等)
# 认不出 WAN 口时不自动发现,免得把 WAN 地址段当成 LAN
nets = []
for a in (list(addrs) + list(addrs6)) if self.wan_name else []:
if a.get("interface") == self.wan_name or a.get("disabled") == "true":
continue
try:
net = ipaddress.ip_interface(a.get("address", "")).network
except ValueError:
continue
if net.is_link_local or net.is_loopback or net.prefixlen == net.max_prefixlen:
continue
nets.append(net)
self.engine.set_auto_nets(nets)
def status(self) -> dict:
r = self.resource or {}
return {
"enabled": self.enabled, "ok": self.ok, "error": self.error, "last_poll": self.last_poll,
"identity": self.identity, "version": r.get("version"), "board": r.get("board-name"),
"uptime": r.get("uptime"), "cpu_load": _int(r.get("cpu-load")),
"mem_total": _int(r.get("total-memory")), "mem_free": _int(r.get("free-memory")),
"wan_interface": self.wan_name,
}
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def _default_route_iface(routes) -> str:
"""默认路由的出接口:immediate-gw 形如 "10.0.0.1%ether1",PPPoE 时 gateway 直接是接口名。"""
for r in routes or []:
if r.get("active") not in ("true", True) and len(routes) > 1:
continue
gw = r.get("immediate-gw") or r.get("gateway") or ""
if "%" in gw:
return gw.split("%", 1)[1]
if gw and not gw[0].isdigit():
return gw
return ""
def _make():
from app.engine import engine
return RosPoller(engine)
poller = _make()
View File
Whitespace-only changes.
+187
View File
@@ -0,0 +1,187 @@
import ipaddress
import re
import time
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app import __version__, stats, timeutil
from app.classify import GENERIC_APPS
from app.config import get_settings
from app.database import get_db
from app.engine import engine, parse_cidrs
from app.models import CustomRule, Setting
from app.ros_poller import poller
from app.security import current_user
router = APIRouter(prefix="/api", tags=["admin"], dependencies=[Depends(current_user)])
_PORT_RE = re.compile(r"^(tcp|udp)/\d{1,5}(-\d{1,5})?$", re.I)
_DOMAIN_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$", re.I)
_APPID_RE = re.compile(r"^[a-z0-9_]{1,48}$")
@router.get("/meta")
def meta():
c = engine.classifier
return {
"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"]),
}
# ---------- 自定义识别规则 ----------
class RuleIn(BaseModel):
kind: str = Field(pattern="^(domain|cidr|port)$")
pattern: str = Field(min_length=1, max_length=255)
app_id: str = Field(min_length=1, max_length=48)
app_name: str | None = Field(None, max_length=64)
category: str | None = Field(None, max_length=32)
def _rule_dict(r: CustomRule):
return {"id": r.id, "kind": r.kind, "pattern": r.pattern, "app_id": r.app_id, "app_name": r.app_name,
"category": r.category, "created": r.created}
@router.get("/rules")
def list_rules(db: Session = Depends(get_db)):
return [_rule_dict(r) for r in db.query(CustomRule).order_by(CustomRule.id.desc())]
@router.post("/rules")
def add_rule(req: RuleIn, db: Session = Depends(get_db)):
p = req.pattern.strip().lower()
if req.kind == "domain":
p = p.strip(".")
if not _DOMAIN_RE.match(p):
raise HTTPException(400, "域名格式不正确")
elif req.kind == "cidr":
try:
p = str(ipaddress.ip_network(p, strict=False))
except ValueError:
raise HTTPException(400, "IP 段格式不正确,例如 1.2.3.0/24")
elif not _PORT_RE.match(p):
raise HTTPException(400, "端口格式不正确,例如 tcp/8080 或 udp/3000-3010")
known = req.app_id in engine.classifier.apps
if not known:
if not _APPID_RE.match(req.app_id):
raise HTTPException(400, "新应用 ID 只能包含小写字母、数字和下划线")
if not req.app_name:
raise HTTPException(400, "新应用需要填写名称")
if req.category not in engine.classifier.categories:
raise HTTPException(400, "请选择分类")
r = CustomRule(kind=req.kind, pattern=p, app_id=req.app_id, app_name=req.app_name,
category=req.category, created=int(time.time()))
db.add(r)
db.commit()
engine.reload_rules()
return _rule_dict(r)
@router.delete("/rules/{rule_id}")
def delete_rule(rule_id: int, db: Session = Depends(get_db)):
r = db.get(CustomRule, rule_id)
if r is None:
raise HTTPException(404, "规则不存在")
db.delete(r)
db.commit()
engine.reload_rules()
return {"ok": True}
@router.get("/rules/unknown-domains")
def unknown_domains(range: str = Query("week", pattern="^(today|week|month|last7|last30)$"),
db: Session = Depends(get_db)):
"""按流量排序的未识别域名,方便一键归类。"""
return stats.unknown_domains(db, *timeutil.range_bounds(range))
@router.get("/rules/test")
def test_rule(domain: str):
"""查看一个域名当前会被识别成什么。"""
app = engine.classifier.match_domain(domain.strip().lower())
return {"domain": domain, "app": stats.app_meta(app) if app else None}
# ---------- 设置 ----------
class SettingsIn(BaseModel):
lan_cidrs: str | None = Field(None, max_length=2000)
wan_interface: str | None = Field(None, max_length=64)
def _get(db, key, default=""):
row = db.get(Setting, key)
return row.value if row and row.value is not None else default
def _put(db, key, value):
row = db.get(Setting, key)
if row is None:
db.add(Setting(key=key, value=value))
else:
row.value = value
@router.get("/settings")
def get_settings_api(db: Session = Depends(get_db)):
s = get_settings()
return {
"lan_cidrs": engine.lan_cidrs_text,
"lan_auto": [str(n) for n in engine.auto_nets],
"wan_interface": _get(db, "wan_interface", s.wan_interface),
"wan_detected": poller.wan_name,
"interfaces": [i["name"] for i in poller.interfaces],
"retention": {"minute": s.retention_minute_days, "hour": s.retention_hour_days,
"day": s.retention_day_days, "domain": s.retention_domain_days},
"ros_url": s.ros_url, "ros_user": s.ros_user,
"ports": {"http": s.http_port, "ipfix": s.ipfix_port, "tzsp": s.tzsp_port},
"tz": s.tz,
}
@router.put("/settings")
def put_settings(req: SettingsIn, db: Session = Depends(get_db)):
if req.lan_cidrs is not None:
text = ",".join(x.strip() for x in req.lan_cidrs.replace("\n", ",").split(",") if x.strip())
bad = [x for x in text.split(",") if x and not parse_cidrs(x)]
if bad:
raise HTTPException(400, f"无效网段:{', '.join(bad)}")
_put(db, "lan_cidrs", text)
engine.set_lan(text)
if req.wan_interface is not None:
_put(db, "wan_interface", req.wan_interface.strip())
poller.wan_override = req.wan_interface.strip()
poller._wan_prev = None
db.commit()
return get_settings_api(db)
# ---------- 系统状态 ----------
@router.get("/system")
def system():
st = engine.stats
now = int(time.time())
return {
"version": __version__,
"collector": {
"flows": st["flows"], "flows_dropped": st["flows_dropped"], "bytes": st["bytes_seen"],
"last_flow": st["last_flow"], "exporters": st["exporters"],
"decoder": engine.decoder.stats, "templates": len(engine.decoder.templates),
"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)},
"router": poller.status(),
"generic_apps": sorted(GENERIC_APPS),
}
@router.post("/ros/test")
def ros_test():
if not poller.enabled:
raise HTTPException(400, "未配置 ROS_URL,请在环境变量中设置 RouterOS REST 地址和只读账号")
poller.poll()
return poller.status()
+61
View File
@@ -0,0 +1,61 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
from app.security import current_user, make_token, pwd
router = APIRouter(prefix="/api/auth", tags=["auth"])
class Creds(BaseModel):
username: str = "admin"
password: str = Field(min_length=1)
class SetupReq(BaseModel):
username: str = Field("admin", min_length=1, max_length=64)
password: str = Field(min_length=6)
class PwChange(BaseModel):
old_password: str
new_password: str = Field(min_length=6)
@router.get("/status")
def status(db: Session = Depends(get_db)):
return {"initialized": db.query(User).count() > 0}
@router.post("/setup")
def setup(req: SetupReq, db: Session = Depends(get_db)):
if db.query(User).count() > 0:
raise HTTPException(400, "管理员已存在")
db.add(User(username=req.username, password_hash=pwd.hash(req.password)))
db.commit()
return {"token": make_token(req.username), "username": req.username}
@router.post("/login")
def login(req: Creds, db: Session = Depends(get_db)):
u = db.query(User).filter(User.username == req.username).first()
if not u or not pwd.verify(req.password, u.password_hash):
raise HTTPException(401, "用户名或密码错误")
return {"token": make_token(u.username), "username": u.username}
@router.get("/me")
def me(user: str = Depends(current_user)):
return {"username": user}
@router.post("/password")
def change_password(req: PwChange, user: str = Depends(current_user), db: Session = Depends(get_db)):
u = db.query(User).filter(User.username == user).first()
if not u or not pwd.verify(req.old_password, u.password_hash):
raise HTTPException(400, "原密码错误")
u.password_hash = pwd.hash(req.new_password)
db.commit()
return {"ok": True}
+124
View File
@@ -0,0 +1,124 @@
import time
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app import stats, timeutil
from app.database import get_db
from app.engine import engine
from app.models import Host
from app.ros_poller import poller
from app.security import current_user
router = APIRouter(prefix="/api", tags=["stats"], dependencies=[Depends(current_user)])
RANGES = "^(today|yesterday|week|month|last7|last30)$"
def _bounds(rng: str):
return timeutil.range_bounds(rng)
def _trend_gran(rng: str) -> str:
return "hour" if rng in ("today", "yesterday") else "day"
@router.get("/overview")
def overview(range: str = Query("today", pattern=RANGES), db: Session = Depends(get_db)):
start, end = _bounds(range)
apps = stats.top_apps(db, start, end)
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),
"rate": {"flow_up": engine.total_rate[0], "flow_down": engine.total_rate[1],
"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,
"top_apps": apps[:10],
"categories": stats.top_categories(apps),
"top_hosts": stats.top_hosts(db, start, end, limit=10),
"trend": stats.trend(db, _trend_gran(range), start, end),
"realtime": stats.trend(db, "minute", now - 3600, now + 1),
"router": poller.status(),
}
@router.get("/trend")
def trend(range: str = Query("today", pattern=RANGES), gran: str = Query(None, pattern="^(minute|hour|day)$"),
host: str | None = None, app: str | None = None, db: Session = Depends(get_db)):
start, end = _bounds(range)
return stats.trend(db, gran or _trend_gran(range), start, end, host, app)
@router.get("/hosts")
def hosts(db: Session = Depends(get_db)):
rows = stats.all_hosts(db)
top = stats.top_app_per_host(db, *_bounds("today"))
for r in rows:
r["top_app"] = top.get(r["key"])
return rows
@router.get("/hosts/{key:path}/detail")
def host_detail(key: str, range: str = Query("today", pattern=RANGES), db: Session = Depends(get_db)):
start, end = _bounds(range)
meta = stats.host_meta(db, [key])[key]
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)
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),
"trend": stats.trend(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,
}
class HostPatch(BaseModel):
alias: str | None = Field(None, max_length=128)
@router.patch("/hosts/{key:path}")
def host_patch(key: str, req: HostPatch, db: Session = Depends(get_db)):
h = db.get(Host, key)
if h is None:
raise HTTPException(404, "终端不存在")
h.alias = (req.alias or "").strip() or None
db.commit()
return {"ok": True}
@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)}
@router.get("/apps/{app_id}/detail")
def app_detail(app_id: str, range: str = Query("today", pattern=RANGES), db: Session = Depends(get_db)):
start, end = _bounds(range)
return {
**stats.app_meta(app_id),
"totals": stats.totals3(db, app=app_id),
"range_total": stats.total(db, start, end, app=app_id),
"hosts": stats.top_hosts(db, start, end, app=app_id),
"trend": stats.trend(db, _trend_gran(range), start, end, app=app_id),
}
@router.get("/flow")
def flow(range: str = Query("today", pattern=RANGES), host: str | None = None,
hosts: int = Query(12, ge=3, le=40), apps: int = Query(16, ge=3, le=60), db: Session = Depends(get_db)):
return stats.flow_graph(db, *_bounds(range), max_hosts=hosts, max_apps=apps, host=host)
+57
View File
@@ -0,0 +1,57 @@
import secrets
import time
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import get_settings
from app.database import SessionLocal
from app.models import Setting, User
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
_bearer = HTTPBearer(auto_error=False)
_secret: str | None = None
TOKEN_DAYS = 30
def secret_key() -> str:
global _secret
if _secret:
return _secret
if get_settings().secret_key:
_secret = get_settings().secret_key
return _secret
with SessionLocal() as db:
row = db.get(Setting, "secret_key")
if row is None:
row = Setting(key="secret_key", value=secrets.token_urlsafe(48))
db.add(row)
db.commit()
_secret = row.value
return _secret
def ensure_admin():
"""设置了 ADMIN_PASSWORD 且还没有账号时,创建 admin。"""
pw = get_settings().admin_password
with SessionLocal() as db:
if pw and db.query(User).count() == 0:
db.add(User(username="admin", password_hash=pwd.hash(pw)))
db.commit()
print("[auth] 已按 ADMIN_PASSWORD 创建管理员 admin")
def make_token(username: str) -> str:
return jwt.encode({"sub": username, "exp": int(time.time()) + TOKEN_DAYS * 86400}, secret_key(), "HS256")
def current_user(cred: HTTPAuthorizationCredentials | None = Depends(_bearer)) -> str:
if cred is None:
raise HTTPException(401, "未登录")
try:
data = jwt.decode(cred.credentials, secret_key(), algorithms=["HS256"])
except JWTError:
raise HTTPException(401, "登录已过期,请重新登录")
return data["sub"]
+218
View File
@@ -0,0 +1,218 @@
"""统计查询。今日/本周/本月 都从天表汇总(流量落库时同时写分钟/小时/天三张表,不需要 rollup)。"""
import time
from collections import defaultdict
from sqlalchemy import distinct, func, select
from app import timeutil
from app.classify import GENERIC_APPS
from app.engine import engine
from app.models import Host, HostDomainDay, TrafficDay, TrafficHour, TrafficMinute
ONLINE_SECONDS = 300
def _sum_cols(t):
return func.coalesce(func.sum(t.up), 0), func.coalesce(func.sum(t.down), 0), func.coalesce(func.sum(t.flows), 0)
def _where(q, t, start, end, host=None, app=None):
q = q.where(t.ts >= start, t.ts < end)
if host:
q = q.where(t.host == host)
if app:
q = q.where(t.app == app)
return q
def total(db, start, end, host=None, app=None) -> dict:
up, down, flows = db.execute(_where(select(*_sum_cols(TrafficDay)), TrafficDay, start, end, host, app)).one()
return {"up": int(up), "down": int(down), "total": int(up) + int(down), "flows": int(flows)}
def totals3(db, host=None, app=None) -> dict:
return {r: total(db, *timeutil.range_bounds(r), host=host, app=app) for r in ("today", "week", "month")}
def app_meta(app_id: str) -> dict:
a = engine.classifier.app_info(app_id)
c = engine.classifier.category_info(a.category)
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"]}
def top_apps(db, start, end, host=None, limit=None) -> list[dict]:
t = TrafficDay
q = _where(select(t.app, *_sum_cols(t), func.count(distinct(t.host))), t, start, end, host)
q = q.group_by(t.app).order_by((func.sum(t.up) + func.sum(t.down)).desc())
if limit:
q = q.limit(limit)
out = []
for app, up, down, flows, hosts in db.execute(q):
rate = engine.app_rate.get(app, (0, 0)) if not host else (0, 0)
out.append({**app_meta(app), "up": int(up), "down": int(down), "total": int(up) + int(down),
"flows": int(flows), "hosts": int(hosts), "rate_up": rate[0], "rate_down": rate[1]})
return out
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})
c["up"] += a["up"]
c["down"] += a["down"]
c["total"] += a["total"]
c["apps"] += 1
return sorted(agg.values(), key=lambda c: c["total"], reverse=True)
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()
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,
"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})
rate = engine.host_rate.get(k, (0, 0))
out[k]["rate_up"], out[k]["rate_down"] = rate
return out
def top_hosts(db, start, end, app=None, limit=None) -> list[dict]:
t = TrafficDay
q = _where(select(t.host, *_sum_cols(t)), t, start, end, app=app)
q = q.group_by(t.host).order_by((func.sum(t.up) + func.sum(t.down)).desc())
if limit:
q = q.limit(limit)
rows = db.execute(q).all()
meta = host_meta(db, [r[0] for r in rows])
return [{**meta[h], "up": int(u), "down": int(d), "total": int(u) + int(d), "flows": int(f)}
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)}
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})
cur = timeutil.day_bucket(cur + 86400 + 3600) # +1h 兼容夏令时
else:
cur = start // step * step
while cur < end:
u, d = data.get(cur, (0, 0))
out.append({"ts": cur, "up": u, "down": d})
cur += step
return out
def all_hosts(db) -> list[dict]:
"""主机监控列表:每台终端的 今日/本周/本月 流量。"""
per: dict[str, dict] = defaultdict(dict)
for r in ("today", "week", "month"):
start, end = timeutil.range_bounds(r)
q = select(TrafficDay.host, *_sum_cols(TrafficDay)).where(TrafficDay.ts >= start, TrafficDay.ts < end) \
.group_by(TrafficDay.host)
for h, u, d, _f in db.execute(q):
per[h][r] = {"up": int(u), "down": int(d), "total": int(u) + int(d)}
keys = set(per) | set(db.scalars(select(Host.key)).all())
meta = host_meta(db, keys)
zero = {"up": 0, "down": 0, "total": 0}
rows = [{**meta[k], **{r: per[k].get(r, zero) for r in ("today", "week", "month")}} for k in keys]
rows.sort(key=lambda x: (x["today"]["total"], x["month"]["total"]), reverse=True)
return rows
def top_app_per_host(db, start, end) -> dict[str, dict]:
t = TrafficDay
q = _where(select(t.host, t.app, (func.sum(t.up) + func.sum(t.down)).label("b")), t, start, end) \
.group_by(t.host, t.app)
best: dict[str, tuple[str, int]] = {}
for h, a, b in db.execute(q):
if a in GENERIC_APPS:
continue
if h not in best or b > best[h][1]:
best[h] = (a, int(b))
return {h: app_meta(a) for h, (a, _b) in best.items()}
def host_domains(db, host, start, end, limit=100) -> list[dict]:
t = HostDomainDay
q = select(t.domain, t.app, func.sum(t.bytes), func.max(t.ts)).where(t.host == host, t.ts >= start, t.ts < end) \
.group_by(t.domain, t.app).order_by(func.sum(t.bytes).desc()).limit(limit)
return [{"domain": d, "app": app_meta(a), "bytes": int(b), "last_day": int(ts)} for d, a, b, ts in db.execute(q)]
def unknown_domains(db, start, end, limit=100) -> list[dict]:
t = HostDomainDay
q = select(t.domain, func.sum(t.bytes), func.count(distinct(t.host))) \
.where(t.ts >= start, t.ts < end, t.app.in_(list(GENERIC_APPS))) \
.group_by(t.domain).order_by(func.sum(t.bytes).desc()).limit(limit)
return [{"domain": d, "bytes": int(b), "hosts": int(h)} for d, b, h in db.execute(q)]
def online_count(db) -> int:
return db.scalar(select(func.count()).select_from(Host).where(Host.last_seen > time.time() - ONLINE_SECONDS)) or 0
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)]
host_tot: dict[str, int] = defaultdict(int)
app_tot: dict[str, int] = defaultdict(int)
for h, a, u, d in rows:
host_tot[h] += u + d
app_tot[a] += u + d
top_h = set(sorted(host_tot, key=host_tot.get, reverse=True)[:max_hosts])
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])
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))):
acc[key][0] += u
acc[key][1] += d
nodes: dict[str, dict] = {}
def node(nid, kind, **kw):
nodes.setdefault(nid, {"id": nid, "kind": kind, **kw})
return nid
links = []
for (hk, cat), (u, d) in hc.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"])
links.append({"source": src, "target": dst, "up": u, "down": d, "value": u + d})
for (cat, ak), (u, d) in ca.items():
if ak.startswith("__other_apps:"):
dst = node(f"a:{ak}", "app", app=None, category=cat)
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})
links = [x for x in links if x["value"] > 0]
return {"nodes": list(nodes.values()), "links": links, "total": sum(host_tot.values())}
+44
View File
@@ -0,0 +1,44 @@
"""时间分桶与统计区间(今日 / 本周 / 本月)。周从周一开始,按配置时区计算。"""
import time
from datetime import datetime, timedelta
from app.config import get_settings
def minute_bucket(ts: float) -> int:
return int(ts) // 60 * 60
def hour_bucket(ts: float) -> int:
# 小时按 UTC 对齐;整小时偏移的时区(如 Asia/Shanghai)与本地整点一致
return int(ts) // 3600 * 3600
def day_bucket(ts: float) -> int:
z = get_settings().zone
d = datetime.fromtimestamp(ts, z)
return int(d.replace(hour=0, minute=0, second=0, microsecond=0).timestamp())
def range_bounds(rng: str, now: float | None = None) -> tuple[int, int]:
"""返回 [start, end) 的 epoch 秒。rng: today | yesterday | week | month | last7 | last30。"""
now = now or time.time()
z = get_settings().zone
d = datetime.fromtimestamp(now, z).replace(hour=0, minute=0, second=0, microsecond=0)
end = int(now) + 1
if rng == "today":
start = d
elif rng == "yesterday":
start = d - timedelta(days=1)
end = int(d.timestamp())
elif rng == "week":
start = d - timedelta(days=d.weekday())
elif rng == "month":
start = d.replace(day=1)
elif rng == "last7":
start = d - timedelta(days=6)
elif rng == "last30":
start = d - timedelta(days=29)
else:
raise ValueError(f"unknown range {rng}")
return int(start.timestamp()), end
+13
View File
@@ -0,0 +1,13 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
pymysql==1.1.1
cryptography==44.0.0
pydantic==2.10.4
pydantic-settings==2.7.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.2.1
httpx==0.28.1
apscheduler==3.11.0
pyyaml==6.0.2
+18
View File
@@ -0,0 +1,18 @@
import os
import sys
import tempfile
_tmp = tempfile.mkdtemp(prefix="rosdpi-test-")
os.environ["DATABASE_URL"] = f"sqlite:///{_tmp}/test.db"
os.environ["LAN_CIDRS"] = "192.168.88.0/24,2408:8000:1:2::/64"
os.environ["ROS_URL"] = ""
os.environ["ADMIN_PASSWORD"] = ""
os.environ["TZ"] = "Asia/Shanghai"
os.environ["IPFIX_PORT"] = "0"
os.environ["TZSP_PORT"] = "0"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app.database import init_db # noqa: E402
init_db(retries=1)
+88
View File
@@ -0,0 +1,88 @@
"""构造 IPFIX / NetFlow v9 / TZSP-DNS 测试包(测试和 scripts/simulate.py 共用)。"""
import ipaddress
import struct
import time
# (field_id, length) — 模仿 RouterOS IPFIX 模板的主要字段
V4_FIELDS = [(8, 4), (12, 4), (7, 2), (11, 2), (4, 1), (1, 8), (2, 8), (56, 6), (225, 4), (226, 4)]
V6_FIELDS = [(27, 16), (28, 16), (7, 2), (11, 2), (4, 1), (1, 8), (2, 8)]
def _rec_v4(src, dst, sport, dport, proto, nbytes, pkts=1, mac="00:00:00:00:00:00", post_src="0.0.0.0",
post_dst="0.0.0.0"):
return (ipaddress.IPv4Address(src).packed + ipaddress.IPv4Address(dst).packed
+ struct.pack("!HHBQQ", sport, dport, proto, nbytes, pkts)
+ bytes.fromhex(mac.replace(":", ""))
+ ipaddress.IPv4Address(post_src).packed + ipaddress.IPv4Address(post_dst).packed)
def _rec_v6(src, dst, sport, dport, proto, nbytes, pkts=1):
return (ipaddress.IPv6Address(src).packed + ipaddress.IPv6Address(dst).packed
+ struct.pack("!HHBQQ", sport, dport, proto, nbytes, pkts))
def _tmpl(tid, fields):
body = struct.pack("!HH", tid, len(fields)) + b"".join(struct.pack("!HH", f, n) for f, n in fields)
return body
def _set(set_id, body):
pad = (-len(body)) % 4
body += b"\0" * pad
return struct.pack("!HH", set_id, len(body) + 4) + body
def ipfix(flows_v4=(), flows_v6=(), with_template=True, domain=0, seq=1):
"""flows_v4: [dict(src,dst,sport,dport,proto,nbytes,mac?,post_dst?...)]"""
sets = b""
if with_template:
sets += _set(2, _tmpl(256, V4_FIELDS) + _tmpl(257, V6_FIELDS))
if flows_v4:
sets += _set(256, b"".join(_rec_v4(**f) for f in flows_v4))
if flows_v6:
sets += _set(257, b"".join(_rec_v6(**f) for f in flows_v6))
hdr = struct.pack("!HHIII", 10, 16 + len(sets), int(time.time()), seq, domain)
return hdr + sets
def netflow9(flows_v4=(), with_template=True, source_id=0):
sets = b""
count = 0
if with_template:
sets += _set(0, _tmpl(300, V4_FIELDS))
count += 1
if flows_v4:
sets += _set(300, b"".join(_rec_v4(**f) for f in flows_v4))
count += len(flows_v4)
hdr = struct.pack("!HHIIII", 9, count, 1000, int(time.time()), 1, source_id)
return hdr + sets
def _name(n):
return b"".join(bytes([len(p)]) + p.encode() for p in n.split(".")) + b"\0"
def dns_response(qname, ips, cnames=(), ttl=60):
an = []
owner = b"\xc0\x0c"
for c in cnames:
rd = _name(c)
an.append(owner + struct.pack("!HHIH", 5, 1, ttl, len(rd)) + rd)
for ip in ips:
a = ipaddress.ip_address(ip)
an.append(owner + struct.pack("!HHIH", 1 if a.version == 4 else 28, 1, ttl, len(a.packed)) + a.packed)
hdr = struct.pack("!HHHHHH", 0x1234, 0x8180, 1, len(an), 0, 0)
return hdr + _name(qname) + struct.pack("!HH", 1, 1) + b"".join(an)
def tzsp_dns(client, qname, ips, cnames=(), server="192.168.88.1", vlan=None):
payload = dns_response(qname, ips, cnames)
udp = struct.pack("!HHHH", 53, 40000, 8 + len(payload), 0) + payload
iph = struct.pack("!BBHHHBBH4s4s", 0x45, 0, 20 + len(udp), 0, 0, 64, 17, 0,
ipaddress.IPv4Address(server).packed, ipaddress.IPv4Address(client).packed)
eth = b"\x11" * 6 + b"\x22" * 6
if vlan is not None:
eth += struct.pack("!HH", 0x8100, vlan)
eth += b"\x08\x00"
# TZSP v1, type 0 (received), encap 1 (Ethernet),带一个填充和结束标记
return b"\x01\x00\x00\x01" + b"\x00" + b"\x01" + eth + iph + udp
+66
View File
@@ -0,0 +1,66 @@
import time
from fastapi.testclient import TestClient
from app.engine import engine
from app.main import app
def test_api_flow():
with TestClient(app) as c:
assert c.get("/api/overview").status_code == 401
assert c.get("/api/auth/status").json() == {"initialized": False}
tok = c.post("/api/auth/setup", json={"username": "admin", "password": "secret1"}).json()["token"]
assert c.post("/api/auth/setup", json={"password": "secret2"}).status_code == 400
assert c.post("/api/auth/login", json={"username": "admin", "password": "bad"}).status_code == 401
h = {"Authorization": f"Bearer {tok}"}
with engine.lock:
engine.add_flow(dict(src="192.168.88.50", dst="8.8.4.4", sport=1, dport=443, proto=6, bytes=777,
src_mac="AA:00:00:00:00:50"), time.time())
engine.flush()
ov = c.get("/api/overview?range=week", headers=h).json()
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
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 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"
bad = c.post("/api/rules", json={"kind": "domain", "pattern": "x", "app_id": "douyin"}, headers=h)
assert bad.status_code == 400
r = c.post("/api/rules", json={"kind": "cidr", "pattern": "8.8.4.0/24", "app_id": "google"}, headers=h)
assert r.status_code == 200
assert engine.classifier.classify("8.8.4.4", 6, 443, None, None) == "google"
assert c.delete(f"/api/rules/{r.json()['id']}", headers=h).json()["ok"]
s = c.put("/api/settings", json={"lan_cidrs": "192.168.88.0/24\n10.0.0.0/8"}, headers=h).json()
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
def test_flow_graph():
from app import stats, timeutil
from app.database import SessionLocal
with engine.lock:
engine.add_flow(dict(src="192.168.88.60", dst="91.108.4.1", sport=1, dport=443, proto=6, bytes=300,
src_mac="AA:00:00:00:00:60"), time.time())
engine.flush()
with SessionLocal() as db:
g = stats.flow_graph(db, *timeutil.range_bounds("today"), max_hosts=1, max_apps=1)
ids = {n["id"] for n in g["nodes"]}
assert all(l["source"] in ids and l["target"] in ids for l in g["links"])
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:"))
assert hc == ca == g["total"]
+92
View File
@@ -0,0 +1,92 @@
import time
from datetime import datetime
from types import SimpleNamespace
from zoneinfo import ZoneInfo
from packets import ipfix, tzsp_dns
from app import stats, timeutil
from app.classify import Classifier
from app.database import SessionLocal
from app.engine import Engine
Z = ZoneInfo("Asia/Shanghai")
def test_classifier_longest_suffix_and_cname():
c = Classifier()
assert c.classify("1.1.1.1", 6, 443, "v26-web.douyinvod.com", None) == "douyin"
assert c.classify("1.1.1.1", 6, 443, "y.qq.com", None) == "qq_music" # 比 qq.com 更具体
assert c.classify("1.1.1.1", 6, 443, "www.qq.com", None) == "qq"
assert c.classify("1.1.1.1", 6, 443, "unknown.example", "x.bilivideo.com") == "bilibili"
assert c.classify("91.108.4.5", 6, 443, None, None) == "telegram"
assert c.classify("9.9.9.9", 17, 443, None, None) == "quic"
assert c.classify("9.9.9.9", 6, 12345, None, None) == "other"
def test_custom_rule_overrides():
c = Classifier()
c.load_custom([SimpleNamespace(kind="domain", pattern="douyin.com", app_id="myapp", app_name="我的",
category="video")])
assert c.classify("1.1.1.1", 6, 443, "www.douyin.com", None) == "myapp"
assert c.apps["myapp"].name == "我的"
def _engine():
e = Engine()
e.set_lan("192.168.88.0/24,2408:8000:1:2::/64")
return e
def test_direction_dns_and_mac_learning():
e = _engine()
e.on_tzsp(tzsp_dns("192.168.88.10", "www.bilibili.com", ["5.5.5.5"]))
now = time.time()
up = e.add_flow(dict(src="192.168.88.10", dst="5.5.5.5", sport=1, dport=443, proto=6, bytes=100,
src_mac="AA:00:00:00:00:01"), now)
down = e.add_flow(dict(src="5.5.5.5", dst="192.168.88.10", sport=443, dport=1, proto=6, bytes=900), now)
assert up == ("AA:00:00:00:00:01", "bilibili", "up")
assert down == ("AA:00:00:00:00:01", "bilibili", "down")
# 内网互访、路由器自身流量不计
assert e.add_flow(dict(src="192.168.88.10", dst="192.168.88.20", proto=6, bytes=5), now) is None
assert e.add_flow(dict(src="100.64.1.2", dst="8.8.8.8", proto=17, bytes=5), now) is None
def test_post_nat_download():
e = _engine()
r = e.add_flow(dict(src="5.5.5.5", dst="100.64.1.2", post_dst="192.168.88.30", sport=443, dport=1, proto=6,
bytes=10), time.time())
assert r == ("IP:192.168.88.30", "https", "down")
def test_flush_and_ranges():
e = _engine()
e.on_tzsp(tzsp_dns("192.168.88.11", "store.steampowered.com", ["6.6.6.6"]))
e.on_ipfix(ipfix([dict(src="192.168.88.11", dst="6.6.6.6", sport=1, dport=443, proto=6, nbytes=1000,
mac="AA:00:00:00:00:11"),
dict(src="192.168.88.11", dst="7.7.7.7", sport=1, dport=443, proto=6, nbytes=50,
mac="AA:00:00:00:00:11")]), "10.0.0.1")
now = time.time()
e.flush(now)
e.add_flow(dict(src="6.6.6.6", dst="192.168.88.11", sport=443, dport=1, proto=6, bytes=4000), now)
e.flush(now) # 同一分钟再写一次,应累加
with SessionLocal() as db:
t = stats.total(db, *timeutil.range_bounds("today"), host="AA:00:00:00:00:11")
assert t == {"up": 1050, "down": 4000, "total": 5050, "flows": 3}
apps = {a["id"]: a for a in stats.top_apps(db, *timeutil.range_bounds("month"), host="AA:00:00:00:00:11")}
assert apps["steam"]["total"] == 5000 and apps["https"]["total"] == 50
hosts = {h["key"]: h for h in stats.all_hosts(db)}
assert hosts["AA:00:00:00:00:11"]["today"]["total"] == 5050
assert hosts["AA:00:00:00:00:11"]["online"]
doms = stats.host_domains(db, "AA:00:00:00:00:11", *timeutil.range_bounds("today"))
assert doms[0]["domain"] == "store.steampowered.com" and doms[0]["bytes"] == 5000
def test_range_bounds_week_starts_monday():
# 2026-10-21 是周三
now = datetime(2026, 10, 21, 15, 0, tzinfo=Z).timestamp()
start, _ = timeutil.range_bounds("week", now)
assert datetime.fromtimestamp(start, Z) == datetime(2026, 10, 19, tzinfo=Z)
start, _ = timeutil.range_bounds("month", now)
assert datetime.fromtimestamp(start, Z) == datetime(2026, 10, 1, tzinfo=Z)
assert timeutil.day_bucket(now) == datetime(2026, 10, 21, tzinfo=Z).timestamp()
+72
View File
@@ -0,0 +1,72 @@
from packets import dns_response, ipfix, netflow9, tzsp_dns
from app.collector.ipfix import FlowDecoder
from app.collector.tzsp import parse_dns_response, parse_tzsp_dns
F1 = dict(src="192.168.88.10", dst="1.2.3.4", sport=50000, dport=443, proto=6, nbytes=1500,
mac="AA:BB:CC:00:00:01")
def test_ipfix_template_and_data():
d = FlowDecoder()
recs = d.decode(ipfix([F1]), "10.0.0.1")
assert len(recs) == 1
r = recs[0]
assert (r["src"], r["dst"], r["sport"], r["dport"], r["proto"], r["bytes"]) == \
("192.168.88.10", "1.2.3.4", 50000, 443, 6, 1500)
assert r["src_mac"] == "AA:BB:CC:00:00:01"
assert r["post_dst"] == "0.0.0.0"
def test_ipfix_data_before_template_is_dropped_then_decoded():
d = FlowDecoder()
assert d.decode(ipfix([F1], with_template=False), "x") == []
assert d.stats["no_template"] == 1
d.decode(ipfix(with_template=True), "x")
assert len(d.decode(ipfix([F1, F1], with_template=False), "x")) == 2
def test_templates_are_per_exporter():
d = FlowDecoder()
d.decode(ipfix(), "a")
assert d.decode(ipfix([F1], with_template=False), "b") == []
def test_ipfix_ipv6():
d = FlowDecoder()
recs = d.decode(ipfix(flows_v6=[dict(src="2408:8000:1:2::10", dst="2400:3200::1", sport=1, dport=443,
proto=17, nbytes=99)]), "x")
assert recs[0]["src"] == "2408:8000:1:2::10" and recs[0]["bytes"] == 99
def test_netflow_v9():
d = FlowDecoder()
recs = d.decode(netflow9([F1]), "x")
assert len(recs) == 1 and recs[0]["dst"] == "1.2.3.4"
def test_garbage_does_not_raise():
d = FlowDecoder()
for junk in (b"", b"\x00\x0a", b"\x00\x0a\x00\x20" + b"\xff" * 40, b"\x00\x05" + b"\x00" * 30):
assert d.decode(junk, "x") == []
def test_dns_response_with_cname():
msg = dns_response("www.douyin.com", ["1.1.1.1", "2400::1"], cnames=["www.douyin.com.w.cdngslb.com"])
qname, ips, cnames = parse_dns_response(msg)
assert qname == "www.douyin.com"
assert [ip for ip, _ in ips] == ["1.1.1.1", "2400::1"]
assert cnames == ["www.douyin.com.w.cdngslb.com"]
def test_tzsp_dns_and_vlan():
for vlan in (None, 20):
ans = parse_tzsp_dns(tzsp_dns("192.168.88.10", "api.bilibili.com", ["5.5.5.5"], vlan=vlan))
assert ans.client == "192.168.88.10"
assert ans.qname == "api.bilibili.com"
assert ans.ips[0][0] == "5.5.5.5"
def test_tzsp_ignores_non_dns():
assert parse_tzsp_dns(b"\x01\x00\x00\x01\x01" + b"\x00" * 10) is None
assert parse_tzsp_dns(b"garbage") is None
+39
View File
@@ -0,0 +1,39 @@
# ROS-DPI:RouterOS 终端流量 / 应用统计
# 使用:cp .env.example .env,修改密码后 docker compose up -d
services:
ros-dpi:
image: ghcr.io/suyijun8182/ros-dpi:latest
# 本地构建时改用:build: .
container_name: ros-dpi
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
env_file: .env
environment:
DATABASE_URL: mysql+pymysql://rosdpi:${MYSQL_PASSWORD}@mysql:3306/rosdpi?charset=utf8mb4
ports:
- "8080:8080/tcp"
- "2055:2055/udp" # IPFIX / NetFlow
- "37008:37008/udp" # TZSP(DNS 镜像)
volumes:
- ./data:/app/data
mysql:
image: mysql:8.4
container_name: ros-dpi-mysql
restart: unless-stopped
environment:
MYSQL_DATABASE: rosdpi
MYSQL_USER: rosdpi
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
TZ: ${TZ:-Asia/Shanghai}
command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci", "--innodb-buffer-pool-size=256M"]
volumes:
- ./mysql:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 20
+71
View File
@@ -0,0 +1,71 @@
# RouterOS 配置说明
下文以容器主机 IP `192.168.88.10`、路由器 LAN `192.168.88.1` 为例。命令适用于 RouterOS 7。
## 1. 流量采集(必需)
```routeros
/ip traffic-flow set enabled=yes interfaces=all active-flow-timeout=1m inactive-flow-timeout=15s
/ip traffic-flow target add dst-address=192.168.88.10 port=2055 version=ipfix
```
- `interfaces=all`:上行包在 LAN 口入方向采集,下行包在 WAN 口入方向采集,两者合起来才完整。ROS-DPI 会按 LAN 网段判断方向,并自动丢弃内网互访和路由器自身的流量,不会重复计数。
- `active-flow-timeout=1m`:长连接每分钟上报一次,统计和速率才能接近实时。
- 也可用 `version=9`(NetFlow v9),两者都支持。
**FastTrack**:被 FastTrack 的连接可能不经过 Traffic Flow,导致统计偏少。对比概览页的“WAN 实时速率”(来自接口计数)和“采集速率”可以判断。如果差距明显,可以缩小 FastTrack 范围,或者关闭它(会增加 CPU 占用)。
## 2. DNS 嗅探(应用识别,强烈建议)
```routeros
/ip firewall mangle add chain=output protocol=udp src-port=53 action=sniff-tzsp sniff-target=192.168.88.10 sniff-target-port=37008 comment=ROSDPI-DNS
/ip firewall mangle add chain=forward protocol=udp src-port=53 action=sniff-tzsp sniff-target=192.168.88.10 sniff-target-port=37008 comment=ROSDPI-DNS
```
- `output`:路由器自己作为 DNS 服务器时,发给终端的应答。
- `forward`:终端直连外部 DNS(如 223.5.5.5)时的应答。
- 只镜像 DNS 应答,数据量很小。
提高识别率:
- 在 DHCP 里把 DNS 下发为路由器地址,并开启 `/ip dns set allow-remote-requests=yes`。
- 浏览器 / 系统里的“安全 DNS(DoH)”会绕过嗅探,可以在终端上关闭,或用防火墙拦截常见 DoH 地址。
## 3. 只读 API 账号(可选)
```routeros
/user group add name=rosdpi policy=read,rest-api,api,!write,!policy,!sensitive
/user add name=rosdpi group=rosdpi password=<强密码> address=192.168.88.10
/ip service set www-ssl disabled=no # REST API 走 HTTPS(或用 www 的 HTTP)
```
然后在 `.env` 设置:
```
ROS_URL=https://192.168.88.1
ROS_USER=rosdpi
ROS_PASSWORD=<强密码>
```
用于:DHCP 主机名、ARP / IPv6 邻居(IP↔MAC)、WAN 口实时速率、路由器 CPU / 内存,以及自动发现 LAN 的 IPv6 公网前缀。ROS-DPI 只发 GET 请求,不会修改路由器配置。
> 修改了这个用户组的权限后,已登录的会话仍沿用旧权限,需要重启容器或执行 `/user active request-logout [find name=rosdpi]`。
## 4. LAN 网段
ROS-DPI 用 LAN 网段判断哪一端是终端。默认是三个私网段,以下情况需要手动修改(`.env` 的 `LAN_CIDRS`,或 **系统设置 → 网络**):
- **双 NAT**(WAN 口也是 192.168.x.x 等私网地址):只填内网实际网段,否则 WAN 侧地址会被当成终端。
- **IPv6**:配置了 ROS API 时会自动加入 LAN 接口上的公网前缀;否则需要手动填写。
## 排错
**系统设置 → 运行状态** 会显示:
| 现象 | 检查 |
|---|---|
| 流量采集一直“等待数据” | 容器的 udp/2055 是否映射;主机防火墙;`/ip traffic-flow target print` |
| 已处理流很多,但全部“丢弃” | LAN 网段填错 |
| DNS 应答为 0 | mangle 规则是否命中(`/ip firewall mangle print stats`);udp/37008 是否映射 |
| 识别率低 | 终端是否在用 DoH;在 **识别规则 → 未识别域名 Top** 里补规则 |
| 统计明显偏少 | FastTrack,见上文 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>ROS-DPI</title>
<script>
try { var t = localStorage.getItem('rosdpi.theme'); if (t) document.documentElement.dataset.theme = t } catch (e) {}
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1847
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ros-dpi-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"i18n:check": "node scripts/check-i18n.mjs",
"prebuild": "node scripts/check-i18n.mjs",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/barlow": "^5.3.0",
"@fontsource/barlow-semi-condensed": "^5.3.0",
"axios": "^1.7.9",
"echarts": "^5.6.0",
"vue": "^3.5.13",
"vue-i18n": "^10.0.5",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" rx="5" fill="#1b262c"/><rect x="4" y="5" width="3" height="14" rx="1.5" fill="#e07a2e"/><rect x="8.3" y="8" width="3" height="11" rx="1.5" fill="#2f9a57"/><rect x="12.6" y="3.5" width="3" height="15.5" rx="1.5" fill="#2c6bc9"/><rect x="17" y="11" width="3" height="8" rx="1.5" fill="#b88660"/></svg>

After

Width:  |  Height:  |  Size: 389 B

+14
View File
@@ -0,0 +1,14 @@
// 构建前检查中英文案 key 是否一致
import zh from '../src/i18n/zh.js'
import en from '../src/i18n/en.js'
const flat = (o, p = '') => Object.entries(o).flatMap(([k, v]) =>
v && typeof v === 'object' ? flat(v, p + k + '.') : [p + k])
const a = new Set(flat(zh)), b = new Set(flat(en))
const onlyZh = [...a].filter(k => !b.has(k)), onlyEn = [...b].filter(k => !a.has(k))
if (onlyZh.length || onlyEn.length) {
if (onlyZh.length) console.error('en 缺少:', onlyZh.join(', '))
if (onlyEn.length) console.error('zh 缺少:', onlyEn.join(', '))
process.exit(1)
}
console.log(`i18n ok (${a.size} keys)`)
+79
View File
@@ -0,0 +1,79 @@
<template>
<router-view v-if="$route.meta.public" />
<div v-else class="shell">
<header class="top">
<div class="top-in">
<router-link to="/" class="brand"><Mark :size="22" /><span>ROS-DPI</span></router-link>
<nav class="nav" aria-label="main">
<router-link v-for="n in nav" :key="n.to" :to="n.to" :class="{ on: isOn(n) }">{{ t(n.label) }}</router-link>
</nav>
<div class="tools">
<button class="btn ghost sm" @click="toggleTheme" :aria-label="t('common.theme')" :title="t('common.theme')">
<svg v-if="ui.theme === 'dark'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
<button class="btn ghost sm" @click="setLang(locale === 'zh' ? 'en' : 'zh')">{{ t('common.lang') }}</button>
<button class="btn ghost sm" @click="logout">{{ t('nav.logout') }}</button>
</div>
</div>
</header>
<main class="main">
<div class="page"><router-view /></div>
</main>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { setAuth } from './api.js'
import Mark from './components/Mark.vue'
import { setLang } from './i18n/index.js'
import { toggleTheme, ui } from './state.js'
const { t, locale } = useI18n()
const route = useRoute()
const router = useRouter()
const nav = [
{ to: '/', label: 'nav.overview' },
{ to: '/hosts', label: 'nav.hosts' },
{ to: '/apps', label: 'nav.apps' },
{ to: '/flow', label: 'nav.flow' },
{ to: '/rules', label: 'nav.rules' },
{ to: '/settings', label: 'nav.settings' },
]
const isOn = n => (n.to === '/' ? route.path === '/' : route.path.startsWith(n.to))
function logout() {
setAuth('')
router.push('/login')
}
</script>
<style scoped>
.top { background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 30; }
.top-in { max-width: 1488px; margin: 0 auto; padding: 0 24px; height: 56px; display: flex; align-items: center; gap: 28px; }
.brand { display: flex; align-items: center; gap: 9px; font: 700 19px/1 var(--cond); letter-spacing: .02em; flex: none; }
.nav { display: flex; gap: 4px; height: 100%; flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
.nav::-webkit-scrollbar { display: none; }
.nav a {
display: flex; align-items: center; padding: 0 12px; color: var(--muted); font-weight: 500;
position: relative; white-space: nowrap;
}
.nav a:hover { color: var(--ink); }
.nav a.on { color: var(--ink); }
/* 当前页:底部一段蓝对色线 */
.nav a.on::after { content: ""; position: absolute; left: 12px; right: 12px; bottom: 0; height: 3px; border-radius: 3px 3px 0 0; background: var(--pair-blue); }
.tools { display: flex; gap: 2px; flex: none; }
.main { padding: 24px 24px 56px; }
@media (max-width: 860px) {
.top-in { flex-wrap: wrap; height: auto; padding: 10px 16px 0; gap: 8px 12px; }
.brand { order: 1; }
.tools { order: 2; margin-left: auto; }
.nav { order: 3; flex-basis: 100%; height: 42px; margin: 0 -8px; }
.main { padding: 18px 16px 40px; }
}
</style>
+37
View File
@@ -0,0 +1,37 @@
import axios from 'axios'
import { reactive } from 'vue'
import router from './router.js'
export const auth = reactive({ token: read('rosdpi.token'), username: read('rosdpi.user') })
function read(k) {
try { return localStorage.getItem(k) || '' } catch (e) { return '' }
}
export function setAuth(token, username) {
auth.token = token || ''
auth.username = username || ''
try {
if (token) { localStorage.setItem('rosdpi.token', token); localStorage.setItem('rosdpi.user', username) }
else { localStorage.removeItem('rosdpi.token'); localStorage.removeItem('rosdpi.user') }
} catch (e) { /* ignore */ }
}
const http = axios.create({ baseURL: '/api', timeout: 20000 })
http.interceptors.request.use(cfg => {
if (auth.token) cfg.headers.Authorization = `Bearer ${auth.token}`
return cfg
})
http.interceptors.response.use(r => r.data, err => {
if (err.response?.status === 401 && !err.config.url.startsWith('/auth/')) {
setAuth('')
router.push({ name: 'login', query: { next: router.currentRoute.value.fullPath } })
}
err.message = err.response?.data?.detail || err.message
return Promise.reject(err)
})
export const enc = s => encodeURIComponent(s)
export default http
+97
View File
@@ -0,0 +1,97 @@
// ECharts 配置生成。颜色从 CSS 变量读取,切换主题/语言后重新生成即可。
import { appName, bytes, catColor, 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'
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,
}
}
function fmtTime(ts, gran) {
const d = new Date(ts * 1000)
const p = n => String(n).padStart(2, '0')
if (gran === 'day') return `${d.getMonth() + 1}/${d.getDate()}`
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])),
})
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 },
axisLabel: { color: css('--faint'), hideOverlap: true, fontFamily: FONT, fontSize: 12 },
},
yAxis: {
type: 'value', splitNumber: 4,
splitLine: { lineStyle: { color: css('--line') } },
axisLabel: { color: css('--faint'), fontFamily: FONT, fontSize: 12, formatter: v => (v === 0 ? '0' : fmt(v).replace('.0 ', ' ')) },
},
series: [series(t('common.down'), 'down', down, 1), series(t('common.up'), 'up', up, -1)],
}
}
/** 流向桑基图:终端 → 分类 → 应用 */
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.app) {
const cat = graph.nodes.find(x => x.id === `c:${n.category}`)
return t('flow.otherApps', { cat: cat ? (zh ? cat.name : cat.name_en) : '' })
}
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))
return {
textStyle: { fontFamily: FONT },
tooltip: tooltip({
trigger: 'item',
formatter: p => (p.dataType === 'edge'
? `${label(p.data.s)} → ${label(p.data.tg)}<br/><b>${bytes(p.data.value)}</b> <span style="color:${css('--down')}">↓${bytes(p.data.down)}</span> <span style="color:${css('--up')}">↑${bytes(p.data.up)}</span>`
: `${p.name}<br/><b>${bytes(p.value)}</b>`),
}),
series: [{
type: 'sankey', left: 4, right: 150, top: 8, bottom: 8, nodeWidth: 10, nodeGap: 9, 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)}}`),
},
lineStyle: { color: 'gradient', opacity: 0.32, curveness: 0.5 },
itemStyle: { borderWidth: 0 },
data: graph.nodes.map(n => ({ name: label(n), id: n.id, raw: n, itemStyle: { color: color(n) } })),
// 用下标连线:终端备注名可能和应用/分类重名
links: graph.links.map(l => {
const si = idx.get(l.source), ti = idx.get(l.target)
return { source: si, target: ti, value: l.value, up: l.up, down: l.down, s: graph.nodes[si], tg: graph.nodes[ti] }
}),
}],
}
}
+49
View File
@@ -0,0 +1,49 @@
<template>
<ol class="bars">
<li v-if="!items.length" class="empty">{{ t('common.empty') }}</li>
<li v-for="it in items" :key="it.key">
<component :is="it.to ? 'router-link' : 'div'" :to="it.to" class="row">
<span class="lab">
<span v-if="it.color" class="chip" :style="{ background: it.color }" />
<span v-else class="dot" :class="{ on: it.online }" />
<span class="name">{{ it.label }}</span>
<span class="sub faint" v-if="it.sub">{{ it.sub }}</span>
</span>
<span class="val num">{{ bytes(it.value) }}</span>
<!-- 长度 = 相对最大项;颜色按上下行拆成蓝/橙两段 -->
<span class="track" :style="{ width: Math.max(it.value / max * 100, 0.8) + '%' }">
<i class="d" :style="{ flexGrow: it.down ?? it.value }" />
<i class="u" :style="{ flexGrow: it.up ?? 0 }" />
</span>
</component>
</li>
</ol>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { bytes } from '../format.js'
const props = defineProps({ items: { type: Array, default: () => [] } })
const { t } = useI18n()
const max = computed(() => Math.max(1, ...props.items.map(i => i.value)))
</script>
<style scoped>
.bars { list-style: none; margin: 0; padding: 0; }
.row {
display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 12px; row-gap: 6px;
padding: 8px 6px 10px; margin: 0 -6px; border-radius: var(--r-sm);
}
a.row:hover { background: var(--sunk); }
a.row:hover .name { text-decoration: underline; text-underline-offset: 3px; }
.lab { display: flex; gap: 8px; align-items: center; min-width: 0; }
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.sub { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.val { font-size: 16px; font-weight: 600; }
.track { grid-column: 1 / -1; display: flex; height: 4px; border-radius: 2px; overflow: hidden; }
.track i { display: block; height: 100%; }
.track .d { background: var(--down); }
.track .u { background: var(--up); }
</style>
+46
View File
@@ -0,0 +1,46 @@
<template>
<div ref="el" class="chart" :style="{ height }" />
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts/core'
import { BarChart, LineChart, PieChart, SankeyChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { ui } from '../state.js'
echarts.use([LineChart, PieChart, BarChart, SankeyChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
const props = defineProps({ option: Object, height: { type: String, default: '260px' } })
const emit = defineEmits(['click'])
const el = ref(null)
let chart = null
let ro = null
function render() {
if (!chart || !props.option) return
chart.setOption(props.option, true)
}
onMounted(() => {
chart = echarts.init(el.value, null, { renderer: 'canvas' })
chart.on('click', p => emit('click', p))
render()
ro = new ResizeObserver(() => chart && chart.resize())
ro.observe(el.value)
})
watch(() => props.option, render, { deep: true })
watch(() => ui.theme, render)
onBeforeUnmount(() => {
ro && ro.disconnect()
chart && chart.dispose()
chart = null
})
</script>
<style scoped>
.chart { width: 100%; }
</style>
+13
View File
@@ -0,0 +1,13 @@
<template>
<!-- 品牌标记:RJ45 的四对线色(橙、绿、蓝、棕) -->
<svg :width="size" :height="size" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="4" width="3" height="16" rx="1.5" fill="var(--pair-orange)" />
<rect x="8" y="7" width="3" height="13" rx="1.5" fill="var(--pair-green)" />
<rect x="13" y="2" width="3" height="18" rx="1.5" fill="var(--pair-blue)" />
<rect x="18" y="10" width="3" height="10" rx="1.5" fill="var(--pair-brown)" />
</svg>
</template>
<script setup>
defineProps({ size: { type: Number, default: 24 } })
</script>
+13
View File
@@ -0,0 +1,13 @@
<template>
<div class="seg">
<button v-for="r in list" :key="r" :class="{ on: ui.range === r }" @click="ui.range = r">{{ t('range.' + r) }}</button>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { RANGES, ui } from '../state.js'
defineProps({ list: { type: Array, default: () => RANGES } })
const { t } = useI18n()
</script>
+50
View File
@@ -0,0 +1,50 @@
<template>
<!-- 流量构成条:按分类占比横向切分,像一束线缆的截面 -->
<div class="spec">
<div class="bar" role="img" :aria-label="aria">
<component :is="clickable ? 'button' : 'span'" v-for="c in segs" :key="c.id" class="seg-i"
:style="{ flexGrow: c.total, background: catColor(c.id) }"
:title="`${catName(c)} ${bytes(c.total)} ${pct(c.total / sum)}`"
@click="clickable && $emit('pick', c.id)" />
<span v-if="!segs.length" class="seg-i none" />
</div>
<ul class="legend" v-if="legend && segs.length">
<li v-for="c in segs.slice(0, legendMax)" :key="c.id">
<component :is="clickable ? 'button' : 'span'" class="lg" @click="clickable && $emit('pick', c.id)">
<span class="chip" :style="{ background: catColor(c.id) }" />
<span>{{ catName(c) }}</span>
<span class="num faint">{{ pct(c.total / sum, 0) }}</span>
</component>
</li>
</ul>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { bytes, catColor, catName, pct } from '../format.js'
const props = defineProps({
cats: { type: Array, default: () => [] },
legend: { type: Boolean, default: true },
legendMax: { type: Number, default: 12 },
clickable: Boolean,
height: { type: Number, default: 14 },
})
defineEmits(['pick'])
const segs = computed(() => props.cats.filter(c => c.total > 0))
const sum = computed(() => segs.value.reduce((s, c) => s + c.total, 0) || 1)
const aria = computed(() => segs.value.slice(0, 6).map(c => `${catName(c)} ${pct(c.total / sum.value, 0)}`).join(', '))
</script>
<style scoped>
.bar { display: flex; gap: 2px; height: v-bind('props.height + "px"'); }
.seg-i { min-width: 3px; height: 100%; border: 0; padding: 0; border-radius: 2px; display: block; }
button.seg-i { cursor: pointer; }
button.seg-i:hover { filter: brightness(1.12); }
.seg-i.none { flex: 1; background: var(--line); }
.legend { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 18px; }
.lg { display: inline-flex; align-items: center; gap: 7px; font-size: 13.5px; background: none; border: 0; padding: 0; color: var(--ink); }
button.lg { cursor: pointer; }
button.lg:hover { text-decoration: underline; text-underline-offset: 3px; }
</style>
+29
View File
@@ -0,0 +1,29 @@
<template>
<div class="stat">
<div class="lbl">{{ label }}</div>
<div class="val num"><slot>{{ value }}</slot></div>
<template v-if="up != null || down != null">
<div class="sub">
<span><span class="num down">↓ {{ fmt(down) }}</span></span>
<span><span class="num up">↑ {{ fmt(up) }}</span></span>
</div>
<span class="pair"><i class="d" :style="{ flexGrow: down || 0 }" /><i class="u" :style="{ flexGrow: up || 0 }" /></span>
</template>
<div class="sub muted" v-else-if="sub">{{ sub }}</div>
</div>
</template>
<script setup>
import { bytes, rate } from '../format.js'
const props = defineProps({ label: String, value: [String, Number], sub: String, up: Number, down: Number, isRate: Boolean })
const fmt = v => (props.isRate ? rate(v) : bytes(v))
</script>
<style scoped>
/* 不做卡片:顶部一条细线分隔,数字本身就是视觉重点 */
.stat { padding: 12px 0 4px; border-top: 2px solid var(--ink); min-width: 0; }
.lbl { color: var(--muted); font-size: 14px; }
.val { font-size: 34px; font-weight: 600; margin: 2px 0 6px; letter-spacing: -.02em; line-height: 1.1; }
.sub { display: flex; gap: 16px; font-size: 14.5px; flex-wrap: wrap; margin-bottom: 8px; }
</style>
+59
View File
@@ -0,0 +1,59 @@
import { i18n } from './i18n/index.js'
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
export function bytes(n, digits = 1) {
n = Number(n) || 0
let i = 0
while (Math.abs(n) >= 1024 && i < UNITS.length - 1) { n /= 1024; i++ }
return `${n.toFixed(i === 0 ? 0 : digits)} ${UNITS[i]}`
}
// 速率按网络习惯用 bit/s
export function rate(bytesPerSec) {
let n = (Number(bytesPerSec) || 0) * 8
const u = ['bps', 'Kbps', 'Mbps', 'Gbps']
let i = 0
while (n >= 1000 && i < u.length - 1) { n /= 1000; i++ }
return `${n.toFixed(i === 0 ? 0 : 1)} ${u[i]}`
}
export function pct(x, digits = 1) {
return `${((Number(x) || 0) * 100).toFixed(digits)}%`
}
export function ago(ts) {
if (!ts) return i18n.global.t('common.never')
const s = Math.max(0, Date.now() / 1000 - ts)
const zh = i18n.global.locale.value === 'zh'
if (s < 60) return zh ? '刚刚' : 'just now'
if (s < 3600) return zh ? `${Math.floor(s / 60)} 分钟前` : `${Math.floor(s / 60)} min ago`
if (s < 86400) return zh ? `${Math.floor(s / 3600)} 小时前` : `${Math.floor(s / 3600)} h ago`
return zh ? `${Math.floor(s / 86400)} 天前` : `${Math.floor(s / 86400)} d ago`
}
export function datetime(ts) {
if (!ts) return '-'
return new Date(ts * 1000).toLocaleString(i18n.global.locale.value === 'zh' ? 'zh-CN' : 'en', { hour12: false })
}
export function appName(a) {
if (!a) return '-'
return i18n.global.locale.value === 'zh' ? a.name : (a.name_en || a.name)
}
export function catName(a) {
if (!a) return '-'
if ('category_name' in a) return i18n.global.locale.value === 'zh' ? a.category_name : a.category_name_en
return i18n.global.locale.value === 'zh' ? a.name : (a.name_en || a.name)
}
// 分类固定配色,所有图表保持一致
export const CAT_COLORS = {
video: '#d9485f', short_video: '#e2638f', live: '#b65fc8', music: '#8a63d2', social: '#6d7be0',
im: '#2f9a57', game: '#e07a2e', shopping: '#d4a72c', news: '#7fa83a', search: '#2a9d8f', cloud: '#3c8fd0',
download: '#c0533a', update: '#7b8794', ai: '#1fa7b8', work: '#2c6bc9', finance: '#3aa57a', life: '#c98a3c',
ads: '#8b5e3c', iot: '#4bb3a0', network: '#a3aeb0', web: '#5f78b8', unknown: '#b5bec0',
}
export const catColor = c => CAT_COLORS[c] || '#64748b'
+58
View File
@@ -0,0 +1,58 @@
export default {
app: { title: 'ROS-DPI', subtitle: 'RouterOS traffic insight' },
nav: { overview: 'Overview', hosts: 'Hosts', apps: 'Applications', flow: 'Flows', rules: 'Rules', settings: 'Settings', logout: 'Sign out' },
range: { today: 'Today', yesterday: 'Yesterday', week: 'This week', month: 'This month', last7: 'Last 7 days', last30: 'Last 30 days' },
common: {
up: 'Upload', down: 'Download', total: 'Total', flows: 'Flows', rate: 'Live rate', hosts: 'Hosts', apps: 'Apps',
loading: 'Loading…', empty: 'No data', save: 'Save', saved: 'Saved', cancel: 'Cancel', delete: 'Delete',
edit: 'Edit', add: 'Add', search: 'Search', refresh: 'Refresh', back: 'Back', online: 'Online', offline: 'Offline',
name: 'Name', category: 'Category', share: 'Share', never: 'Never', confirmDelete: 'Delete this?', unknown: 'Unknown',
theme: 'Toggle light/dark', lang: '中文', allApps: 'All applications', allHosts: 'All hosts',
},
login: {
title: 'Sign in', setupTitle: 'First run: create admin', username: 'Username', password: 'Password',
password2: 'Confirm password', submit: 'Sign in', setup: 'Create admin', mismatch: 'Passwords do not match',
tooShort: 'At least 6 characters',
},
overview: {
today: 'Today', week: 'This week', month: 'This month', wanRate: 'WAN rate', flowRate: 'Collected rate',
online: 'Online hosts', identified: 'Identified', categories: 'Categories', topApps: 'Top applications', topHosts: 'Top hosts',
trend: 'Traffic trend', realtime: 'Last hour', router: 'Router', cpu: 'CPU', memory: 'Memory', uptime: 'Uptime',
noData: 'No traffic yet. Once RouterOS sends Traffic Flow to this host, numbers appear here.',
openGuide: 'Open setup guide',
heroToday: 'Through the router today', heroRange: 'Traffic, {range}', live: 'Right now', mix: 'Traffic mix',
weekShort: 'Week', monthShort: 'Month', viaWan: 'from WAN port {name}', viaFlow: 'from collected flows',
},
hosts: {
title: 'Hosts', count: '{n} hosts, {m} online', search: 'Search name / IP / MAC', topApp: 'Top app today',
lastSeen: 'Last seen', ip: 'IP address', mac: 'MAC address', hostname: 'Hostname', alias: 'Alias',
aliasPlaceholder: 'Give this device a name', onlineOnly: 'Online only', firstSeen: 'First seen', domains: 'Domains',
domain: 'Domain', dhcp: 'DHCP', notFound: 'Host not found',
},
apps: { title: 'Applications', showAll: 'Show all categories', usedBy: 'Used by', app: 'Application', byCategory: 'By category', byApp: 'By application' },
flow: {
title: 'Flows', lead: 'From hosts, through app categories, to individual apps. Thicker lines carry more traffic.',
hosts: 'Hosts shown', apps: 'Apps shown', otherHosts: 'Other hosts', otherApps: 'Other {cat}',
hint: 'Click a host or app to open it', direction: 'Direction', both: 'Up + down',
},
rules: {
title: 'Rules', intro: 'The built-in library covers common apps. Custom rules take priority and let you add or correct matches.',
custom: 'Custom rules', unknown: 'Top unidentified domains', unknownHint: 'Traffic to these domains is only identified as generic HTTPS/QUIC. Click "Classify" to create a rule.',
kind: 'Type', pattern: 'Match', target: 'Application', kinds: { domain: 'Domain suffix', cidr: 'IP range', port: 'Port' },
patternHint: { domain: 'e.g. example.com (includes subdomains)', cidr: 'e.g. 1.2.3.0/24', port: 'e.g. tcp/8080 or udp/3000-3010' },
existingApp: 'Existing app', newApp: 'New app', appId: 'App ID (lowercase, digits, underscore)', appName: 'App name',
classify: 'Classify', test: 'Test a domain', testResult: 'Matches: {app}', testNone: 'No domain rule matches', bytes: 'Traffic',
},
settings: {
title: 'Settings', network: 'Network', lan: 'LAN ranges', lanHint: 'One per line. Addresses in these ranges are treated as LAN hosts. Ranges on non-WAN router interfaces are added automatically (grey).',
lanAuto: 'Auto-detected', wan: 'WAN interface', wanHint: 'Used for the WAN rate. Leave empty to detect from the default route.',
wanDetected: 'Detected: {name}', status: 'Status', collector: 'Flow collector (IPFIX)', dns: 'DNS sniffer (TZSP)',
ros: 'RouterOS API', ok: 'OK', waiting: 'Waiting for data', disabled: 'Not configured', error: 'Error', test: 'Test',
flows: 'Flows processed', dropped: 'Dropped (LAN-to-LAN / router)', exporters: 'Exporters', dnsAnswers: 'DNS answers',
dnsEntries: 'IP→domain entries', last: 'Last', guide: 'Setup guide', guideIntro: 'Run these on the RouterOS terminal (replace {ip} with the IP of the host running this container):',
guideNote: 'Byte counts come from Traffic Flow; app identification relies on DNS sniffing. Clients using DoH/DoT reduce accuracy.',
copy: 'Copy', copied: 'Copied', password: 'Change password', oldPw: 'Current password', newPw: 'New password', retention: 'Retention',
retentionText: 'Minute detail {minute} d, hourly {hour} d, daily {day} d, domains {domain} d (set via environment variables)',
version: 'Version', wanAuto: 'Detect automatically', rosHint: 'Not configured. Set ROS_URL, ROS_USER and ROS_PASSWORD to show hostnames and WAN rate.',
},
}
+19
View File
@@ -0,0 +1,19 @@
import { createI18n } from 'vue-i18n'
import zh from './zh.js'
import en from './en.js'
function initial() {
try {
const saved = localStorage.getItem('rosdpi.lang')
if (saved) return saved
} catch (e) { /* 隐私模式 */ }
return (navigator.language || 'zh').toLowerCase().startsWith('zh') ? 'zh' : 'en'
}
export const i18n = createI18n({ legacy: false, locale: initial(), fallbackLocale: 'zh', messages: { zh, en } })
export function setLang(l) {
i18n.global.locale.value = l
document.documentElement.lang = l === 'zh' ? 'zh-CN' : 'en'
try { localStorage.setItem('rosdpi.lang', l) } catch (e) { /* ignore */ }
}
+58
View File
@@ -0,0 +1,58 @@
export default {
app: { title: 'ROS-DPI', subtitle: 'RouterOS 流量审计' },
nav: { overview: '全网概览', hosts: '主机监控', apps: '应用统计', flow: '流量流向', rules: '识别规则', settings: '系统设置', logout: '退出登录' },
range: { today: '今日', yesterday: '昨日', week: '本周', month: '本月', last7: '近 7 天', last30: '近 30 天' },
common: {
up: '上行', down: '下行', total: '总流量', flows: '连接数', rate: '实时速率', hosts: '终端', apps: '应用',
loading: '加载中…', empty: '暂无数据', save: '保存', saved: '已保存', cancel: '取消', delete: '删除',
edit: '编辑', add: '添加', search: '搜索', refresh: '刷新', back: '返回', online: '在线', offline: '离线',
name: '名称', category: '分类', share: '占比', never: '从未', confirmDelete: '确定删除?', unknown: '未知',
theme: '切换深浅色', lang: 'English', allApps: '全部应用', allHosts: '全部终端',
},
login: {
title: '登录', setupTitle: '首次使用:设置管理员', username: '用户名', password: '密码',
password2: '确认密码', submit: '登录', setup: '创建管理员', mismatch: '两次输入的密码不一致',
tooShort: '密码至少 6 位',
},
overview: {
today: '今日流量', week: '本周流量', month: '本月流量', wanRate: 'WAN 实时速率', flowRate: '采集速率',
online: '在线终端', identified: '应用识别率', categories: '应用分类', topApps: 'Top 应用', topHosts: 'Top 终端',
trend: '流量趋势', realtime: '最近 1 小时', router: '路由器', cpu: 'CPU', memory: '内存', uptime: '运行时间',
noData: '还没有收到流量数据。在 RouterOS 上开启 Traffic Flow 并发送到本机后,这里会开始显示。',
openGuide: '打开接入向导',
heroToday: '今天经过路由器的流量', heroRange: '{range}流量', live: '此刻', mix: '流量构成',
weekShort: '本周', monthShort: '本月', viaWan: '取自 WAN 口 {name}', viaFlow: '取自流量采集',
},
hosts: {
title: '主机监控', count: '共 {n} 台终端,在线 {m} 台', search: '搜索名称 / IP / MAC', topApp: '今日主要应用',
lastSeen: '最后活跃', ip: 'IP 地址', mac: 'MAC 地址', hostname: '主机名', alias: '备注名',
aliasPlaceholder: '给这台设备起个名字', onlineOnly: '只看在线', firstSeen: '首次出现', domains: '访问域名',
domain: '域名', dhcp: 'DHCP', notFound: '找不到该终端',
},
apps: { title: '应用统计', showAll: '显示全部分类', usedBy: '使用终端', app: '应用', byCategory: '按分类', byApp: '按应用' },
flow: {
title: '流量流向', lead: '从终端,经过应用分类,流到具体应用。线越粗流量越大。',
hosts: '显示终端数', apps: '显示应用数', otherHosts: '其余终端', otherApps: '其余{cat}',
hint: '点击终端或应用可查看详情', direction: '方向', both: '上下行合计',
},
rules: {
title: '识别规则', intro: '内置规则库覆盖常见国内外应用。自定义规则优先级最高,用于补充或纠正识别结果。',
custom: '自定义规则', unknown: '未识别域名 Top', unknownHint: '这些域名的流量目前只能识别为 HTTPS/QUIC 等通用协议,点「归类」快速建规则。',
kind: '类型', pattern: '匹配内容', target: '归类到', kinds: { domain: '域名后缀', cidr: 'IP 段', port: '端口' },
patternHint: { domain: '如 example.com(含子域名)', cidr: '如 1.2.3.0/24', port: '如 tcp/8080 或 udp/3000-3010' },
existingApp: '已有应用', newApp: '新建应用', appId: '应用 ID(小写字母/数字/下划线)', appName: '应用名称',
classify: '归类', test: '测试域名', testResult: '识别为:{app}', testNone: '未命中任何域名规则', bytes: '流量',
},
settings: {
title: '系统设置', network: '网络', lan: 'LAN 网段', lanHint: '每行一个,属于这些网段的地址被视为内网终端。路由器上非 WAN 接口的地址段会自动补充(下方灰色)。',
lanAuto: '自动发现', wan: 'WAN 接口', wanHint: '用于显示 WAN 实时速率,留空则按默认路由自动识别。',
wanDetected: '当前识别为:{name}', status: '运行状态', collector: '流量采集 (IPFIX)', dns: 'DNS 嗅探 (TZSP)',
ros: 'RouterOS API', ok: '正常', waiting: '等待数据', disabled: '未配置', error: '异常', test: '测试连接',
flows: '已处理流', dropped: '丢弃(内网/路由器自身)', exporters: '发送端', dnsAnswers: 'DNS 应答',
dnsEntries: 'IP→域名 映射', last: '最近', guide: '接入向导', guideIntro: '在 RouterOS 终端执行以下命令(把 {ip} 换成运行本容器的主机 IP):',
guideNote: '流量统计依赖 Traffic Flow;应用识别依赖 DNS 嗅探。终端使用 DoH/DoT 加密 DNS 时识别率会下降。',
copy: '复制', copied: '已复制', password: '修改密码', oldPw: '原密码', newPw: '新密码', retention: '数据保留',
retentionText: '分钟明细 {minute} 天,小时 {hour} 天,每日 {day} 天,域名 {domain} 天(通过环境变量修改)',
version: '版本', wanAuto: '自动识别', rosHint: '未配置。设置环境变量 ROS_URL、ROS_USER、ROS_PASSWORD 后,可显示主机名和 WAN 口速率。',
},
}
+13
View File
@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import '@fontsource/barlow/latin-400.css'
import '@fontsource/barlow/latin-500.css'
import '@fontsource/barlow/latin-600.css'
import '@fontsource/barlow-semi-condensed/latin-500.css'
import '@fontsource/barlow-semi-condensed/latin-600.css'
import '@fontsource/barlow-semi-condensed/latin-700.css'
import App from './App.vue'
import router from './router.js'
import { i18n } from './i18n/index.js'
import './style.css'
createApp(App).use(router).use(i18n).mount('#app')
+37
View File
@@ -0,0 +1,37 @@
import { onMounted, onUnmounted, ref, watch } from 'vue'
/** 加载数据并定时刷新(页面不可见时暂停)。deps 变化时立即重新加载。 */
export function usePoll(fn, ms = 30000, deps = null) {
const loading = ref(true)
const error = ref('')
let timer = null
let seq = 0
async function run() {
const my = ++seq
try {
await fn()
if (my === seq) error.value = ''
} catch (e) {
if (my === seq) error.value = e.message
} finally {
if (my === seq) loading.value = false
}
}
function tick() {
if (!document.hidden) run()
}
onMounted(() => {
run()
if (ms) timer = setInterval(tick, ms)
document.addEventListener('visibilitychange', tick)
})
onUnmounted(() => {
clearInterval(timer)
document.removeEventListener('visibilitychange', tick)
})
if (deps) watch(deps, () => { loading.value = true; run() })
return { loading, error, reload: run }
}
+23
View File
@@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
import { auth } from './api.js'
const routes = [
{ path: '/login', name: 'login', component: () => import('./views/Login.vue'), meta: { public: true } },
{ path: '/', name: 'overview', component: () => import('./views/Overview.vue') },
{ path: '/hosts', name: 'hosts', component: () => import('./views/Hosts.vue') },
{ path: '/hosts/:hostKey', name: 'host', component: () => import('./views/HostDetail.vue'), props: true },
{ path: '/apps', name: 'apps', component: () => import('./views/Apps.vue') },
{ path: '/apps/:id', name: 'app', component: () => import('./views/AppDetail.vue'), props: true },
{ path: '/flow', name: 'flow', component: () => import('./views/Flow.vue') },
{ path: '/rules', name: 'rules', component: () => import('./views/Rules.vue') },
{ path: '/settings', name: 'settings', component: () => import('./views/Settings.vue') },
{ path: '/:pathMatch(.*)*', redirect: '/' },
]
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach(to => {
if (!to.meta.public && !auth.token) return { name: 'login', query: { next: to.fullPath } }
})
export default router
+28
View File
@@ -0,0 +1,28 @@
import { reactive, watch } from 'vue'
function read(k, d) {
try { return localStorage.getItem(k) || d } catch (e) { return d }
}
const media = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null
const systemTheme = () => (media && media.matches ? 'dark' : 'light')
// 全局时间范围,各页面共用;主题默认跟随系统
export const ui = reactive({
range: read('rosdpi.range', 'today'),
theme: document.documentElement.dataset.theme || systemTheme(),
})
media && media.addEventListener && media.addEventListener('change', () => {
if (!document.documentElement.dataset.theme) ui.theme = systemTheme()
})
watch(() => ui.range, v => { try { localStorage.setItem('rosdpi.range', v) } catch (e) { /* ignore */ } })
export function toggleTheme() {
ui.theme = ui.theme === 'dark' ? 'light' : 'dark'
document.documentElement.dataset.theme = ui.theme
try { localStorage.setItem('rosdpi.theme', ui.theme) } catch (e) { /* ignore */ }
}
export const RANGES = ['today', 'yesterday', 'week', 'month', 'last7', 'last30']
+188
View File
@@ -0,0 +1,188 @@
/*
* 设计语言:取自网线 T568B 线序。
* 橙对 = 上行,蓝对 = 下行,绿对 = 在线/正常,棕对 = 辅助。
* 数字与标题用 Barlow Semi Condensed(等宽数字),正文 Barlow,中文回落系统字体。
*/
:root {
--paper: #eef1ef;
--panel: #ffffff;
--sunk: #e4e9e7;
--ink: #1b262c;
--muted: #5e6b70;
--faint: #8c989b;
--line: #d5dcda;
--line-strong: #b9c3c1;
--pair-orange: #e07a2e;
--pair-blue: #2c6bc9;
--pair-green: #2f9a57;
--pair-brown: #8b5e3c;
--alert: #d1443b;
--up: var(--pair-orange);
--down: var(--pair-blue);
--ok: var(--pair-green);
--warn: #d49a1e;
--bad: var(--alert);
--accent: var(--pair-blue);
--focus: #2c6bc9;
--r-sm: 5px;
--r: 8px;
--sans: "Barlow", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif;
--cond: "Barlow Semi Condensed", "Barlow", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--paper: #151a1c;
--panel: #1c2326;
--sunk: #12171a;
--ink: #e4eae8;
--muted: #93a1a3;
--faint: #67777a;
--line: #2a3437;
--line-strong: #3a4649;
--pair-orange: #f0954a;
--pair-blue: #5a92e8;
--pair-green: #4cc27a;
--pair-brown: #b88660;
--alert: #ef6158;
--focus: #7aaaf0;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--paper: #151a1c;
--panel: #1c2326;
--sunk: #12171a;
--ink: #e4eae8;
--muted: #93a1a3;
--faint: #67777a;
--line: #2a3437;
--line-strong: #3a4649;
--pair-orange: #f0954a;
--pair-blue: #5a92e8;
--pair-green: #4cc27a;
--pair-brown: #b88660;
--alert: #ef6158;
--focus: #7aaaf0;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
background: var(--paper);
color: var(--ink);
font: 15px/1.55 var(--sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a { color: inherit; text-decoration: none; }
button, input, select, textarea { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 3px; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
/* ---------- 文字 ---------- */
.num { font-family: var(--cond); font-variant-numeric: tabular-nums; letter-spacing: .01em; }
.addr { font-family: var(--cond); font-variant-numeric: tabular-nums; letter-spacing: .02em; }
.muted { color: var(--muted); }
.faint { color: var(--faint); }
.up { color: var(--up); }
.down { color: var(--down); }
.small { font-size: 13px; }
/* ---------- 版面 ---------- */
.page { max-width: 1440px; margin: 0 auto; }
.page-h { display: flex; align-items: flex-end; gap: 16px; margin: 6px 0 22px; flex-wrap: wrap; }
.page-h h1 { font: 600 30px/1.1 var(--cond); margin: 0; letter-spacing: -.01em; }
.page-h .sp { flex: 1; }
.page-h .lead { color: var(--muted); font-size: 14px; padding-bottom: 3px; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); min-width: 0; }
.panel-h { display: flex; align-items: center; gap: 12px; padding: 14px 18px 0; font: 600 17px/1.3 var(--cond); letter-spacing: .01em; flex-wrap: wrap; }
.panel-h > :not(h2) { font: 400 13.5px/1.4 var(--sans); letter-spacing: 0; }
.panel-h h2 { font: 600 17px/1.3 var(--cond); margin: 0; letter-spacing: .01em; }
.panel-h .sp { flex: 1; }
.panel-b { padding: 12px 18px 18px; }
.grid { display: grid; gap: 16px; }
.g4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.g3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.g2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.g-2-1 { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
.mt { margin-top: 16px; }
@media (max-width: 1100px) { .g4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .g-2-1, .g3 { grid-template-columns: 1fr; } }
@media (max-width: 640px) { .g4, .g2 { grid-template-columns: 1fr; } }
/* ---------- 控件 ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
height: 34px; padding: 0 14px;
border: 1px solid var(--line-strong); border-radius: var(--r-sm);
background: var(--panel); cursor: pointer; white-space: nowrap; font-weight: 500;
}
.btn:hover { border-color: var(--ink); }
.btn.primary { background: var(--ink); border-color: var(--ink); color: var(--paper); }
.btn.primary:hover { background: var(--pair-blue); border-color: var(--pair-blue); color: #fff; }
.btn.danger:hover { border-color: var(--bad); color: var(--bad); }
.btn.ghost { border-color: transparent; background: none; color: var(--muted); }
.btn.ghost:hover { color: var(--ink); border-color: var(--line); }
.btn.sm { height: 28px; padding: 0 10px; font-size: 13px; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.input {
height: 36px; padding: 0 11px; width: 100%;
border: 1px solid var(--line-strong); border-radius: var(--r-sm);
background: var(--panel); outline: none;
}
textarea.input { height: auto; padding: 9px 11px; font-family: var(--cond); font-size: 15px; line-height: 1.6; }
.input:focus { border-color: var(--focus); box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 18%, transparent); }
/* 分段选择器:选中项下方是一段"线色"下划线 */
.seg { display: inline-flex; gap: 2px; flex-wrap: wrap; border-bottom: 1px solid var(--line); }
.seg button {
border: 0; background: none; cursor: pointer; color: var(--muted);
padding: 6px 10px 7px; font-weight: 500; position: relative;
}
.seg button:hover { color: var(--ink); }
.seg button.on { color: var(--ink); }
.seg button.on::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px; background: var(--ink); }
/* ---------- 表格 ---------- */
table.tbl { width: 100%; border-collapse: collapse; }
.tbl th, .tbl td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap; }
.tbl th { color: var(--muted); font-weight: 500; font-size: 13px; position: sticky; top: 0; background: var(--panel); z-index: 1; }
.tbl th.sort { cursor: pointer; user-select: none; }
.tbl th.sort:hover, .tbl th.sorted { color: var(--ink); }
.tbl td.r, .tbl th.r { text-align: right; }
.tbl tbody tr:last-child td { border-bottom: 0; }
.tbl tbody tr:hover { background: color-mix(in srgb, var(--sunk) 60%, transparent); }
.tbl tr.link { cursor: pointer; }
.tbl-wrap { overflow-x: auto; }
.tbl td .num, .tbl td.num { font-size: 15px; }
/* ---------- 小部件 ---------- */
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--line-strong); flex: none; }
.dot.on { background: var(--ok); }
.dot.warn { background: var(--warn); }
.dot.bad { background: var(--bad); }
/* 应用/分类标记:左侧一小段色条,像线缆上的色标 */
.tag { display: inline-flex; align-items: center; gap: 7px; font-size: 13.5px; }
.chip { width: 4px; height: 14px; border-radius: 2px; display: inline-block; flex: none; }
.empty { padding: 36px 18px; text-align: center; color: var(--muted); }
.err { color: var(--bad); font-size: 14px; }
code, pre { font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace; font-size: 13px; }
pre.code { background: var(--sunk); border-radius: var(--r-sm); padding: 14px 16px; overflow-x: auto; margin: 0; line-height: 1.7; }
/* 上下行比例条:橙 / 蓝 两股 */
.pair { display: flex; height: 4px; border-radius: 2px; overflow: hidden; background: var(--line); }
.pair > i { display: block; height: 100%; }
.pair > .d { background: var(--down); }
.pair > .u { background: var(--up); }
+70
View File
@@ -0,0 +1,70 @@
<template>
<div>
<div class="page-h">
<router-link to="/apps" class="btn sm">← {{ t('common.back') }}</router-link>
<h1 v-if="d">{{ appName(d) }}</h1>
<span v-if="d" class="tag"><span class="chip" :style="{ background: catColor(d.category) }" />{{ catName(d) }}</span>
<span class="sp" />
<RangeTabs />
</div>
<div v-if="error" class="err">{{ error }}</div>
<template v-if="d">
<div class="grid g4">
<Stat :label="t('range.today')" :value="bytes(d.totals.today.total)" :up="d.totals.today.up" :down="d.totals.today.down" />
<Stat :label="t('range.week')" :value="bytes(d.totals.week.total)" :up="d.totals.week.up" :down="d.totals.week.down" />
<Stat :label="t('range.month')" :value="bytes(d.totals.month.total)" :up="d.totals.month.up" :down="d.totals.month.down" />
<Stat :label="t('apps.usedBy')" :value="d.hosts.length" :sub="t('range.' + ui.range)" />
</div>
<div class="panel mt">
<div class="panel-h">{{ t('overview.trend') }}</div>
<div class="panel-b"><Chart :option="trendOpt" height="240px" /></div>
</div>
<div class="panel mt">
<div class="panel-h">{{ t('apps.usedBy') }}</div>
<div class="tbl-wrap">
<table class="tbl">
<thead><tr><th>{{ t('common.name') }}</th><th>{{ t('hosts.ip') }}</th><th class="r">{{ t('common.down') }}</th><th class="r">{{ t('common.up') }}</th><th class="r">{{ t('common.total') }}</th><th class="r">{{ t('common.share') }}</th></tr></thead>
<tbody>
<tr v-for="h in d.hosts" :key="h.key" class="link" @click="$router.push(`/hosts/${enc(h.key)}`)">
<td><span class="nm"><span class="dot" :class="{ on: h.online }" />{{ h.name }}</span></td>
<td class="addr">{{ h.ip }}</td>
<td class="r num">{{ bytes(h.down) }}</td>
<td class="r num">{{ bytes(h.up) }}</td>
<td class="r num"><b>{{ bytes(h.total) }}</b></td>
<td class="r num muted">{{ pct(h.total / (d.range_total.total || 1)) }}</td>
</tr>
</tbody>
</table>
<div v-if="!d.hosts.length" class="empty">{{ t('common.empty') }}</div>
</div>
</div>
</template>
<div v-else-if="loading" class="empty">{{ t('common.loading') }}</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import http, { enc } from '../api.js'
import { trendOption } from '../charts.js'
import Chart from '../components/Chart.vue'
import RangeTabs from '../components/RangeTabs.vue'
import Stat from '../components/Stat.vue'
import { appName, bytes, catColor, catName, pct } from '../format.js'
import { usePoll } from '../poll.js'
import { ui } from '../state.js'
const props = defineProps({ id: String })
const { t, locale } = useI18n()
const d = ref(null)
const { loading, error } = usePoll(async () => {
d.value = await http.get(`/apps/${enc(props.id)}/detail`, { params: { range: ui.range } })
}, 60000, () => [ui.range, props.id])
const trendOpt = computed(() => (ui.theme, locale.value, trendOption(d.value.trend, ['today', 'yesterday'].includes(ui.range) ? 'hour' : 'day')))
</script>
<style scoped>
.mt { margin-top: 14px; }
.nm { display: inline-flex; align-items: center; gap: 8px; }
</style>
+95
View File
@@ -0,0 +1,95 @@
<template>
<div>
<div class="page-h">
<h1>{{ t('apps.title') }}</h1>
<span class="lead" v-if="d">{{ bytes(d.range_total.total) }}</span>
<span class="sp" />
<RangeTabs />
</div>
<div v-if="error" class="err">{{ error }}</div>
<template v-if="d">
<section class="mix">
<Spectrum :cats="d.categories" clickable :height="18" :legend-max="30" @pick="pick" />
</section>
<div class="panel mt">
<div class="panel-h">
{{ cat ? catLabel : t('apps.byApp') }}
<button v-if="cat" class="btn sm" @click="pick('')">{{ t('apps.showAll') }}</button>
<span class="sp" />
<input class="input search" v-model="q" :placeholder="t('common.search')" :aria-label="t('common.search')" />
</div>
<div class="tbl-wrap">
<table class="tbl">
<thead><tr>
<th>{{ t('apps.app') }}</th><th>{{ t('common.category') }}</th>
<th class="r">{{ t('common.down') }}</th><th class="r">{{ t('common.up') }}</th><th class="r">{{ t('common.total') }}</th>
<th class="r">{{ t('common.share') }}</th><th class="r">{{ t('apps.usedBy') }}</th><th class="r">{{ t('common.flows') }}</th>
</tr></thead>
<tbody>
<tr v-for="a in shown" :key="a.id" class="link" @click="$router.push(`/apps/${enc(a.id)}`)">
<td class="nm">{{ appName(a) }}</td>
<td><span class="tag"><span class="chip" :style="{ background: catColor(a.category) }" />{{ catName(a) }}</span></td>
<td class="r num">{{ bytes(a.down) }}</td>
<td class="r num">{{ bytes(a.up) }}</td>
<td class="r num"><b>{{ bytes(a.total) }}</b></td>
<td class="r share">
<span class="num muted">{{ pct(a.total / (d.range_total.total || 1)) }}</span>
<span class="pair"><i class="d" :style="{ flexGrow: a.down }" /><i class="u" :style="{ flexGrow: a.up }" /></span>
</td>
<td class="r num">{{ a.hosts }}</td>
<td class="r num muted">{{ a.flows.toLocaleString() }}</td>
</tr>
</tbody>
</table>
<div v-if="!shown.length" class="empty">{{ t('common.empty') }}</div>
</div>
</div>
</template>
<div v-else-if="loading" class="empty">{{ t('common.loading') }}</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import http, { enc } from '../api.js'
import RangeTabs from '../components/RangeTabs.vue'
import Spectrum from '../components/Spectrum.vue'
import { appName, bytes, catColor, catName, pct } from '../format.js'
import { usePoll } from '../poll.js'
import { ui } from '../state.js'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const d = ref(null)
const q = ref('')
const cat = computed(() => route.query.cat || '')
const { loading, error } = usePoll(async () => { d.value = await http.get('/apps', { params: { range: ui.range } }) },
60000, () => ui.range)
function pick(c) {
router.replace({ query: c && c !== cat.value ? { cat: c } : {} })
}
const shown = computed(() => {
const s = q.value.trim().toLowerCase()
return d.value.apps.filter(a => (!cat.value || a.category === cat.value) &&
(!s || [a.id, a.name, a.name_en].some(v => v && v.toLowerCase().includes(s))))
})
const catLabel = computed(() => {
const c = d.value.categories.find(x => x.id === cat.value)
return c ? catName(c) : cat.value
})
</script>
<style scoped>
.mix { padding-bottom: 4px; }
.search { width: 220px; height: 32px; }
.nm { font-weight: 500; }
.share { min-width: 110px; }
.share .pair { margin-top: 5px; width: 90px; margin-left: auto; }
@media (max-width: 640px) { .search { width: 100%; } }
</style>
+75
View File
@@ -0,0 +1,75 @@
<template>
<div>
<div class="page-h">
<h1>{{ t('flow.title') }}</h1>
<span class="sp" />
<RangeTabs />
</div>
<p class="lead muted">{{ t('flow.lead') }}</p>
<div class="ctl">
<label>{{ t('flow.hosts') }}
<select class="input sm" v-model.number="nHosts"><option v-for="n in [8, 12, 20, 30]" :key="n" :value="n">{{ n }}</option></select>
</label>
<label>{{ t('flow.apps') }}
<select class="input sm" v-model.number="nApps"><option v-for="n in [10, 16, 25, 40]" :key="n" :value="n">{{ n }}</option></select>
</label>
<span class="sp" />
<span class="faint small">{{ t('flow.hint') }}</span>
</div>
<div v-if="error" class="err">{{ error }}</div>
<div class="panel">
<div class="panel-b">
<Chart v-if="g && g.links.length" :option="opt" :height="height + 'px'" @click="open" />
<div v-else class="empty">{{ loading ? t('common.loading') : t('common.empty') }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import http, { enc } from '../api.js'
import { sankeyOption } from '../charts.js'
import Chart from '../components/Chart.vue'
import RangeTabs from '../components/RangeTabs.vue'
import { usePoll } from '../poll.js'
import { ui } from '../state.js'
const { t, locale } = useI18n()
const router = useRouter()
const g = ref(null)
const nHosts = ref(12)
const nApps = ref(16)
const { loading, error } = usePoll(async () => {
g.value = await http.get('/flow', { params: { range: ui.range, hosts: nHosts.value, apps: nApps.value } })
}, 60000, () => [ui.range, nHosts.value, nApps.value])
const opt = computed(() => (ui.theme, locale.value, sankeyOption(g.value)))
// 高度随两侧最多的节点数增长,保证每个节点和标签都有空间
const height = computed(() => {
if (!g.value) return 480
const hosts = g.value.nodes.filter(n => n.kind === 'host').length
const apps = g.value.nodes.filter(n => n.kind === 'app').length
return Math.max(420, Math.max(hosts, apps) * 30 + 40)
})
function open(p) {
const n = p.data && p.data.raw
if (!n) return
if (n.kind === 'host' && n.key) router.push(`/hosts/${enc(n.key)}`)
else if (n.kind === 'app' && n.app) router.push(`/apps/${enc(n.app)}`)
else if (n.kind === 'category') router.push({ path: '/apps', query: { cat: n.category } })
}
</script>
<style scoped>
.lead { margin: -12px 0 18px; max-width: 70ch; }
.ctl { display: flex; align-items: center; gap: 20px; margin-bottom: 12px; flex-wrap: wrap; font-size: 14px; color: var(--muted); }
.ctl label { display: flex; align-items: center; gap: 8px; }
.input.sm { height: 30px; width: auto; padding: 0 8px; }
</style>
+134
View File
@@ -0,0 +1,134 @@
<template>
<div>
<div class="page-h">
<router-link to="/hosts" class="btn sm">← {{ t('common.back') }}</router-link>
<h1 v-if="d"><span class="dot" :class="{ on: d.online }" /> {{ d.name }}</h1>
<span class="sp" />
<RangeTabs />
</div>
<div v-if="error" class="err">{{ error }}</div>
<template v-if="d">
<div class="panel info">
<div><span class="k">{{ t('hosts.ip') }}</span><span class="addr">{{ d.ip || '-' }}</span></div>
<div><span class="k">{{ t('hosts.mac') }}</span><span class="addr">{{ d.mac || '-' }}</span></div>
<div><span class="k">{{ t('hosts.hostname') }}</span>{{ d.hostname || '-' }}</div>
<div><span class="k">{{ t('hosts.firstSeen') }}</span>{{ datetime(d.first_seen) }}</div>
<div><span class="k">{{ t('hosts.lastSeen') }}</span>{{ ago(d.last_seen) }}</div>
<div><span class="k">{{ t('common.rate') }}</span><span class="num"><span class="down">↓{{ rate(d.rate_down) }}</span> <span class="up">↑{{ rate(d.rate_up) }}</span></span></div>
<div class="alias">
<span class="k">{{ t('hosts.alias') }}</span>
<input class="input" v-model="alias" :placeholder="t('hosts.aliasPlaceholder')" @keyup.enter="saveAlias" />
<button class="btn sm" @click="saveAlias" :disabled="alias === (d.alias || '')">{{ saved ? t('common.saved') : t('common.save') }}</button>
</div>
</div>
<div class="grid mt" :class="extraRange ? 'g4' : 'g3'">
<Stat :label="t('range.today')" :value="bytes(d.totals.today.total)" :up="d.totals.today.up" :down="d.totals.today.down" />
<Stat :label="t('range.week')" :value="bytes(d.totals.week.total)" :up="d.totals.week.up" :down="d.totals.week.down" />
<Stat :label="t('range.month')" :value="bytes(d.totals.month.total)" :up="d.totals.month.up" :down="d.totals.month.down" />
<Stat v-if="extraRange" :label="t('range.' + ui.range)" :value="bytes(d.range_total.total)" :up="d.range_total.up" :down="d.range_total.down" />
</div>
<section class="mix mt">
<h2>{{ t('overview.mix') }}</h2>
<Spectrum :cats="d.categories" />
</section>
<div class="panel mt">
<div class="panel-h">{{ t('overview.trend') }}</div>
<div class="panel-b"><Chart :option="trendOpt" height="240px" /></div>
</div>
<div class="grid g2 mt">
<div class="panel">
<div class="panel-h">{{ t('nav.apps') }}</div>
<div class="tbl-wrap">
<table class="tbl">
<thead><tr><th>{{ t('apps.app') }}</th><th>{{ t('common.category') }}</th><th class="r">{{ t('common.down') }}</th><th class="r">{{ t('common.up') }}</th><th class="r">{{ t('common.share') }}</th></tr></thead>
<tbody>
<tr v-for="a in d.apps" :key="a.id" class="link" @click="$router.push(`/apps/${enc(a.id)}`)">
<td>{{ appName(a) }}</td>
<td><span class="tag"><span class="chip" :style="{ background: catColor(a.category) }" />{{ catName(a) }}</span></td>
<td class="r num">{{ bytes(a.down) }}</td>
<td class="r num">{{ bytes(a.up) }}</td>
<td class="r num muted">{{ pct(a.total / (d.range_total.total || 1)) }}</td>
</tr>
</tbody>
</table>
<div v-if="!d.apps.length" class="empty">{{ t('common.empty') }}</div>
</div>
</div>
<div class="panel">
<div class="panel-h">{{ t('hosts.domains') }}</div>
<div class="tbl-wrap dom">
<table class="tbl">
<thead><tr><th>{{ t('hosts.domain') }}</th><th>{{ t('apps.app') }}</th><th class="r">{{ t('common.total') }}</th></tr></thead>
<tbody>
<tr v-for="x in d.domains" :key="x.domain">
<td class="dn" :title="x.domain">{{ x.domain }}</td>
<td><span class="tag"><span class="chip" :style="{ background: catColor(x.app.category) }" />{{ appName(x.app) }}</span></td>
<td class="r num">{{ bytes(x.bytes) }}</td>
</tr>
</tbody>
</table>
<div v-if="!d.domains.length" class="empty">{{ t('common.empty') }}</div>
</div>
</div>
</div>
</template>
<div v-else-if="loading" class="empty">{{ t('common.loading') }}</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import http, { enc } from '../api.js'
import { trendOption } from '../charts.js'
import Chart from '../components/Chart.vue'
import RangeTabs from '../components/RangeTabs.vue'
import Spectrum from '../components/Spectrum.vue'
import Stat from '../components/Stat.vue'
import { ago, appName, bytes, catColor, catName, datetime, pct, rate } from '../format.js'
import { usePoll } from '../poll.js'
import { ui } from '../state.js'
// 参数不能叫 key(Vue 保留属性,不会作为 prop 传入)
const props = defineProps({ hostKey: String })
const { t, locale } = useI18n()
const d = ref(null)
const alias = ref('')
const saved = ref(false)
const { loading, error } = usePoll(async () => {
const first = !d.value
d.value = await http.get(`/hosts/${enc(props.hostKey)}/detail`, { params: { range: ui.range } })
if (first) alias.value = d.value.alias || ''
}, 30000, () => [ui.range, props.hostKey])
// 今日/本周/本月 已有固定卡片,只有选其他范围时才多显示一张
const extraRange = computed(() => !['today', 'week', 'month'].includes(ui.range))
const deps = () => [ui.theme, locale.value]
const trendOpt = computed(() => (deps(), trendOption(d.value.trend, ['today', 'yesterday'].includes(ui.range) ? 'hour' : 'day')))
async function saveAlias() {
await http.patch(`/hosts/${enc(props.hostKey)}`, { alias: alias.value })
d.value.alias = alias.value || null
d.value.name = alias.value || d.value.hostname || d.value.ip
saved.value = true
setTimeout(() => (saved.value = false), 1500)
}
</script>
<style scoped>
.mt { margin-top: 14px; }
h1 { display: flex; align-items: center; gap: 10px; }
.info { padding: 14px 16px; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px 20px; font-size: 13px; }
.info > div { display: flex; gap: 10px; align-items: center; min-width: 0; }
.k { color: var(--muted); flex: none; min-width: 64px; }
.alias { grid-column: 1 / -1; max-width: 520px; }
.dom { max-height: 520px; overflow-y: auto; }
.mix h2 { font: 600 17px/1.3 var(--cond); margin: 0 0 10px; }
.dn { max-width: 320px; overflow: hidden; text-overflow: ellipsis; }
</style>
+91
View File
@@ -0,0 +1,91 @@
<template>
<div>
<div class="page-h">
<h1>{{ t('hosts.title') }}</h1>
<span class="muted" v-if="rows.length">{{ t('hosts.count', { n: rows.length, m: onlineN }) }}</span>
<span class="sp" />
<label class="chk"><input type="checkbox" v-model="onlineOnly" /> {{ t('hosts.onlineOnly') }}</label>
<input class="input search" v-model="q" :placeholder="t('hosts.search')" />
</div>
<div v-if="error" class="err">{{ error }}</div>
<div class="panel">
<div class="tbl-wrap">
<table class="tbl">
<thead>
<tr>
<th class="sort" @click="sortBy('name')">{{ t('common.name') }} {{ arrow('name') }}</th>
<th class="sort" @click="sortBy('ip')">{{ t('hosts.ip') }} {{ arrow('ip') }}</th>
<th>{{ t('hosts.mac') }}</th>
<th class="r sort" @click="sortBy('rate')">{{ t('common.rate') }} {{ arrow('rate') }}</th>
<th class="r sort" @click="sortBy('today')">{{ t('range.today') }} {{ arrow('today') }}</th>
<th class="r sort" @click="sortBy('week')">{{ t('range.week') }} {{ arrow('week') }}</th>
<th class="r sort" @click="sortBy('month')">{{ t('range.month') }} {{ arrow('month') }}</th>
<th>{{ t('hosts.topApp') }}</th>
<th class="sort" @click="sortBy('last')">{{ t('hosts.lastSeen') }} {{ arrow('last') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="h in shown" :key="h.key" class="link" @click="$router.push(`/hosts/${enc(h.key)}`)">
<td><span class="nm"><span class="dot" :class="{ on: h.online }" />{{ h.name }}</span>
<span class="faint hn" v-if="h.alias && h.hostname">{{ h.hostname }}</span></td>
<td class="addr">{{ h.ip }}</td>
<td class="addr faint">{{ h.mac || '-' }}</td>
<td class="r num small"><span class="down">↓{{ rate(h.rate_down) }}</span><br /><span class="up">↑{{ rate(h.rate_up) }}</span></td>
<td class="r num">{{ bytes(h.today.total) }}</td>
<td class="r num">{{ bytes(h.week.total) }}</td>
<td class="r num">{{ bytes(h.month.total) }}</td>
<td><span v-if="h.top_app" class="tag"><span class="chip" :style="{ background: catColor(h.top_app.category) }" />{{ appName(h.top_app) }}</span></td>
<td class="muted small">{{ ago(h.last_seen) }}</td>
</tr>
</tbody>
</table>
<div v-if="!shown.length" class="empty">{{ loading ? t('common.loading') : t('common.empty') }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import http, { enc } from '../api.js'
import { ago, appName, bytes, catColor, rate } from '../format.js'
import { usePoll } from '../poll.js'
const { t } = useI18n()
const rows = ref([])
const q = ref('')
const onlineOnly = ref(false)
const sort = ref({ key: 'today', desc: true })
const { loading, error } = usePoll(async () => { rows.value = await http.get('/hosts') }, 30000)
const onlineN = computed(() => rows.value.filter(h => h.online).length)
const ipNum = ip => (ip || '').split('.').reduce((s, x) => s * 256 + (+x || 0), 0)
const keyFn = {
name: h => (h.name || '').toLowerCase(), ip: h => ipNum(h.ip), rate: h => h.rate_up + h.rate_down,
today: h => h.today.total, week: h => h.week.total, month: h => h.month.total, last: h => h.last_seen || 0,
}
const shown = computed(() => {
const s = q.value.trim().toLowerCase()
let list = rows.value.filter(h => (!onlineOnly.value || h.online) &&
(!s || [h.name, h.ip, h.mac, h.hostname, h.alias].some(v => v && v.toLowerCase().includes(s))))
const f = keyFn[sort.value.key]
const dir = sort.value.desc ? -1 : 1
return [...list].sort((a, b) => (f(a) > f(b) ? dir : f(a) < f(b) ? -dir : 0))
})
function sortBy(k) {
sort.value = { key: k, desc: sort.value.key === k ? !sort.value.desc : k !== 'name' && k !== 'ip' }
}
const arrow = k => (sort.value.key === k ? (sort.value.desc ? '↓' : '↑') : '')
</script>
<style scoped>
.search { width: 240px; }
.chk { display: flex; gap: 6px; align-items: center; color: var(--muted); font-size: 13px; cursor: pointer; }
.nm { display: inline-flex; align-items: center; gap: 8px; }
.hn { margin-left: 8px; font-size: 12px; }
.small { font-size: 12px; line-height: 1.35; }
@media (max-width: 640px) { .search { width: 100%; } }
</style>
+77
View File
@@ -0,0 +1,77 @@
<template>
<div class="wrap">
<form class="panel box" @submit.prevent="submit">
<div class="brand">
<Mark :size="34" />
<div>
<div class="b1">ROS-DPI</div>
<div class="muted b2">{{ t('app.subtitle') }}</div>
</div>
</div>
<h2>{{ setup ? t('login.setupTitle') : t('login.title') }}</h2>
<label>{{ t('login.username') }}<input class="input" v-model="username" autocomplete="username" /></label>
<label>{{ t('login.password') }}<input class="input" type="password" v-model="password" :autocomplete="setup ? 'new-password' : 'current-password'" /></label>
<label v-if="setup">{{ t('login.password2') }}<input class="input" type="password" v-model="password2" autocomplete="new-password" /></label>
<div class="err" v-if="error">{{ error }}</div>
<button class="btn primary full" :disabled="busy">{{ setup ? t('login.setup') : t('login.submit') }}</button>
<div class="foot">
<button type="button" class="btn ghost sm" @click="setLang(locale === 'zh' ? 'en' : 'zh')">{{ t('common.lang') }}</button>
<button type="button" class="btn ghost sm" @click="toggleTheme">{{ t('common.theme') }}</button>
</div>
</form>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import http, { setAuth } from '../api.js'
import Mark from '../components/Mark.vue'
import { setLang } from '../i18n/index.js'
import { toggleTheme } from '../state.js'
const { t, locale } = useI18n()
const route = useRoute()
const router = useRouter()
const setup = ref(false)
const username = ref('admin')
const password = ref('')
const password2 = ref('')
const error = ref('')
const busy = ref(false)
onMounted(async () => {
try { setup.value = !(await http.get('/auth/status')).initialized } catch (e) { error.value = e.message }
})
async function submit() {
error.value = ''
if (setup.value) {
if (password.value.length < 6) return (error.value = t('login.tooShort'))
if (password.value !== password2.value) return (error.value = t('login.mismatch'))
}
busy.value = true
try {
const r = await http.post(setup.value ? '/auth/setup' : '/auth/login', { username: username.value, password: password.value })
setAuth(r.token, r.username)
router.replace(route.query.next || '/')
} catch (e) {
error.value = e.message
} finally {
busy.value = false
}
}
</script>
<style scoped>
.wrap { min-height: 100vh; display: grid; place-items: center; padding: 16px; }
.box { width: 100%; max-width: 380px; padding: 32px 28px 22px; display: flex; flex-direction: column; gap: 16px; border-top: 3px solid var(--pair-blue); }
.brand { display: flex; gap: 12px; align-items: center; }
.b1 { font: 700 24px/1 var(--cond); letter-spacing: .02em; }
.b2 { font-size: 12px; }
h2 { font: 600 20px/1.2 var(--cond); margin: 10px 0 0; }
label { display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: var(--muted); }
.full { width: 100%; justify-content: center; height: 38px; }
.foot { display: flex; gap: 8px; justify-content: center; }
</style>
+167
View File
@@ -0,0 +1,167 @@
<template>
<div>
<div class="page-h">
<h1>{{ t('nav.overview') }}</h1>
<span class="lead" v-if="d && d.router.identity">{{ d.router.identity }}</span>
<span class="sp" />
<RangeTabs />
</div>
<div v-if="error" class="err">{{ error }}</div>
<template v-if="d">
<div v-if="!d.totals.month.total" class="panel nodata">
<span>{{ t('overview.noData') }}</span>
<router-link to="/settings#guide" class="btn sm">{{ t('overview.openGuide') }}</router-link>
</div>
<!-- 首屏:本期流量 + 此刻速率 + 流量构成 -->
<section class="hero">
<div class="hero-main">
<div class="hero-lbl">{{ ui.range === 'today' ? t('overview.heroToday') : t('overview.heroRange', { range: t('range.' + ui.range) }) }}</div>
<div class="hero-num num">{{ big[0] }}<span class="unit">{{ big[1] }}</span></div>
<div class="hero-sub">
<span><b class="num down">{{ bytes(d.range_total.down) }}</b> {{ t('common.down') }}</span>
<span><b class="num up">{{ bytes(d.range_total.up) }}</b> {{ t('common.up') }}</span>
<span class="pair hero-pair"><i class="d" :style="{ flexGrow: d.range_total.down }" /><i class="u" :style="{ flexGrow: d.range_total.up }" /></span>
</div>
<dl class="hero-meta">
<div v-if="ui.range !== 'today'"><dt>{{ t('range.today') }}</dt><dd class="num">{{ bytes(d.totals.today.total) }}</dd></div>
<div v-if="ui.range !== 'week'"><dt>{{ t('overview.weekShort') }}</dt><dd class="num">{{ bytes(d.totals.week.total) }}</dd></div>
<div v-if="ui.range !== 'month'"><dt>{{ t('overview.monthShort') }}</dt><dd class="num">{{ bytes(d.totals.month.total) }}</dd></div>
<div><dt>{{ t('overview.online') }}</dt><dd class="num">{{ d.online }}<span class="faint"> / {{ d.hosts_total }}</span></dd></div>
<div><dt>{{ t('overview.identified') }}</dt><dd class="num">{{ pct(d.identified_ratio, 0) }}</dd></div>
</dl>
</div>
<div class="hero-live">
<div class="hero-lbl"><span class="dot on pulse" />{{ t('overview.live') }}</div>
<div class="live-row"><span class="arrow down">↓</span><span class="num live-v">{{ rate(liveDown) }}</span></div>
<div class="live-row"><span class="arrow up">↑</span><span class="num live-v">{{ rate(liveUp) }}</span></div>
<div class="faint small">{{ wanFrom }}</div>
</div>
</section>
<section class="mix">
<div class="mix-h"><h2>{{ t('overview.mix') }}</h2></div>
<Spectrum :cats="d.categories" clickable :height="18" @pick="c => $router.push({ path: '/apps', query: { cat: c } })" />
</section>
<div class="grid g-2-1 mt">
<div class="panel">
<div class="panel-h"><h2>{{ t('overview.trend') }}</h2><span class="faint small">{{ t('range.' + ui.range) }}</span></div>
<div class="panel-b"><Chart :option="trendOpt" height="260px" /></div>
</div>
<div class="panel">
<div class="panel-h"><h2>{{ t('overview.realtime') }}</h2></div>
<div class="panel-b"><Chart :option="rtOpt" height="260px" /></div>
</div>
</div>
<div class="grid g2 mt">
<div class="panel">
<div class="panel-h"><h2>{{ t('overview.topApps') }}</h2><span class="sp" /><router-link to="/apps" class="small link">{{ t('common.allApps') }}</router-link></div>
<div class="panel-b"><BarList :items="appItems" /></div>
</div>
<div class="panel">
<div class="panel-h"><h2>{{ t('overview.topHosts') }}</h2><span class="sp" /><router-link to="/hosts" class="small link">{{ t('common.allHosts') }}</router-link></div>
<div class="panel-b"><BarList :items="hostItems" /></div>
</div>
</div>
<footer class="router" v-if="d.router.enabled">
<span class="dot" :class="d.router.ok ? 'on' : 'bad'" />
<b>{{ d.router.identity || 'RouterOS' }}</b>
<span class="muted">{{ d.router.board }}</span>
<span class="muted">RouterOS {{ d.router.version }}</span>
<span v-if="d.router.cpu_load != null">{{ t('overview.cpu') }} <b class="num">{{ d.router.cpu_load }}%</b></span>
<span v-if="d.router.mem_total">{{ t('overview.memory') }} <b class="num">{{ pct(1 - d.router.mem_free / d.router.mem_total, 0) }}</b></span>
<span v-if="d.router.uptime">{{ t('overview.uptime') }} <b class="num">{{ d.router.uptime }}</b></span>
<span v-if="!d.router.ok" class="err">{{ d.router.error }}</span>
</footer>
</template>
<div v-else-if="loading" class="empty">{{ t('common.loading') }}</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import http, { enc } from '../api.js'
import { trendOption } from '../charts.js'
import BarList from '../components/BarList.vue'
import Chart from '../components/Chart.vue'
import RangeTabs from '../components/RangeTabs.vue'
import Spectrum from '../components/Spectrum.vue'
import { appName, bytes, catColor, catName, pct, rate } from '../format.js'
import { usePoll } from '../poll.js'
import { ui } from '../state.js'
const { t, locale } = useI18n()
const d = ref(null)
const { loading, error } = usePoll(async () => { d.value = await http.get('/overview', { params: { range: ui.range } }) },
30000, () => ui.range)
const useWan = computed(() => d.value.router.enabled && d.value.router.ok)
const liveUp = computed(() => (useWan.value ? d.value.rate.wan_up : d.value.rate.flow_up))
const liveDown = computed(() => (useWan.value ? d.value.rate.wan_down : d.value.rate.flow_down))
const wanFrom = computed(() => (useWan.value && d.value.router.wan_interface
? t('overview.viaWan', { name: d.value.router.wan_interface }) : t('overview.viaFlow')))
// 大数字拆成 数值 + 单位,单位单独排小号
const big = computed(() => {
const [v, u] = bytes(d.value.range_total.total).split(' ')
return [v, u]
})
const gran = computed(() => (['today', 'yesterday'].includes(ui.range) ? 'hour' : 'day'))
const deps = () => [ui.theme, locale.value]
const trendOpt = computed(() => (deps(), trendOption(d.value.trend, gran.value)))
const rtOpt = computed(() => (deps(), trendOption(d.value.realtime, 'minute', true)))
const appItems = computed(() => d.value.top_apps.map(a => ({
key: a.id, label: appName(a), sub: catName(a), value: a.total, up: a.up, down: a.down,
color: catColor(a.category), to: `/apps/${enc(a.id)}`,
})))
const hostItems = computed(() => d.value.top_hosts.map(h => ({
key: h.key, label: h.name, sub: h.name !== h.ip ? h.ip : '', value: h.total, up: h.up, down: h.down,
online: h.online, to: `/hosts/${enc(h.key)}`,
})))
</script>
<style scoped>
.nodata { padding: 14px 18px; margin-bottom: 20px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; border-left: 3px solid var(--warn); }
.hero { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 24px 48px; align-items: end; padding: 4px 0 26px; }
.hero-lbl { color: var(--muted); font-size: 14.5px; display: flex; align-items: center; gap: 8px; }
.hero-num { font-size: clamp(64px, 9vw, 112px); font-weight: 600; line-height: .92; letter-spacing: -.035em; margin: 10px 0 14px; }
.unit { font-size: .36em; font-weight: 500; letter-spacing: 0; margin-left: .18em; color: var(--muted); }
.hero-sub { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; font-size: 14.5px; color: var(--muted); }
.hero-sub b { font-size: 20px; font-weight: 600; margin-right: 2px; }
.hero-pair { width: 160px; height: 6px; border-radius: 3px; }
.hero-meta { display: flex; gap: 10px 36px; flex-wrap: wrap; margin: 22px 0 0; }
.hero-meta div { display: flex; flex-direction: column; }
.hero-meta dt { color: var(--muted); font-size: 13px; }
.hero-meta dd { margin: 0; font-size: 22px; font-weight: 600; }
.hero-live { min-width: 240px; padding-left: 28px; border-left: 1px solid var(--line); }
.live-row { display: flex; align-items: baseline; gap: 10px; margin-top: 6px; }
.arrow { font: 600 22px/1 var(--cond); width: 16px; }
.live-v { font-size: 34px; font-weight: 600; letter-spacing: -.01em; }
.hero-live .small { margin-top: 10px; }
/* 唯一的一个动效:在线指示点的呼吸,提示数据是实时的 */
.pulse { animation: pulse 2.4s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .3; } }
.mix { padding: 18px 0 8px; border-top: 1px solid var(--line); }
.mix-h h2 { font: 600 17px/1.3 var(--cond); margin: 0 0 12px; }
.link { color: var(--pair-blue); }
.link:hover { text-decoration: underline; text-underline-offset: 3px; }
.router { margin-top: 28px; padding-top: 16px; border-top: 1px solid var(--line); display: flex; gap: 8px 22px; align-items: center; flex-wrap: wrap; font-size: 14px; }
@media (max-width: 860px) {
.hero { grid-template-columns: 1fr; }
.hero-live { border-left: 0; padding-left: 0; border-top: 1px solid var(--line); padding-top: 16px; }
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<template>
<div>
<div class="page-h"><h1>{{ t('rules.title') }}</h1></div>
<p class="muted intro">{{ t('rules.intro') }}</p>
<div class="grid g2">
<div class="panel">
<div class="panel-h">{{ t('common.add') }}</div>
<form class="panel-b form" @submit.prevent="add">
<div class="seg">
<button type="button" v-for="k in ['domain', 'cidr', 'port']" :key="k" :class="{ on: f.kind === k }" @click="f.kind = k">{{ t('rules.kinds.' + k) }}</button>
</div>
<input class="input" v-model="f.pattern" :placeholder="t('rules.patternHint.' + f.kind)" />
<div class="seg">
<button type="button" :class="{ on: !newApp }" @click="newApp = false">{{ t('rules.existingApp') }}</button>
<button type="button" :class="{ on: newApp }" @click="newApp = true">{{ t('rules.newApp') }}</button>
</div>
<select v-if="!newApp" class="input" v-model="f.app_id">
<optgroup v-for="c in meta.categories" :key="c.id" :label="catName(c)">
<option v-for="a in meta.apps.filter(x => x.category === c.id)" :key="a.id" :value="a.id">{{ appName(a) }}</option>
</optgroup>
</select>
<template v-else>
<input class="input" v-model="f.app_id" :placeholder="t('rules.appId')" />
<input class="input" v-model="f.app_name" :placeholder="t('rules.appName')" />
<select class="input" v-model="f.category">
<option v-for="c in meta.categories" :key="c.id" :value="c.id">{{ catName(c) }}</option>
</select>
</template>
<div class="err" v-if="formErr">{{ formErr }}</div>
<div><button class="btn primary" :disabled="!f.pattern || !f.app_id">{{ t('common.add') }}</button></div>
</form>
<div class="panel-b test">
<input class="input" v-model="testDomain" :placeholder="t('rules.test')" @keyup.enter="test" />
<button class="btn" @click="test">{{ t('rules.test') }}</button>
<span v-if="testRes" class="muted">{{ testRes.app ? t('rules.testResult', { app: appName(testRes.app) }) : t('rules.testNone') }}</span>
</div>
</div>
<div class="panel">
<div class="panel-h">{{ t('rules.custom') }} <span class="faint">{{ rules.length }}</span></div>
<div class="tbl-wrap">
<table class="tbl">
<thead><tr><th>{{ t('rules.kind') }}</th><th>{{ t('rules.pattern') }}</th><th>{{ t('rules.target') }}</th><th /></tr></thead>
<tbody>
<tr v-for="r in rules" :key="r.id">
<td><span class="tag">{{ t('rules.kinds.' + r.kind) }}</span></td>
<td class="addr">{{ r.pattern }}</td>
<td>{{ appLabel(r.app_id) }}</td>
<td class="r"><button class="btn sm danger" @click="del(r)">{{ t('common.delete') }}</button></td>
</tr>
</tbody>
</table>
<div v-if="!rules.length" class="empty">{{ t('common.empty') }}</div>
</div>
</div>
</div>
<div class="panel mt">
<div class="panel-h">{{ t('rules.unknown') }}<span class="sp" /><RangeTabs :list="['today', 'week', 'month']" /></div>
<p class="panel-b muted hint">{{ t('rules.unknownHint') }}</p>
<div class="tbl-wrap">
<table class="tbl">
<thead><tr><th>{{ t('hosts.domain') }}</th><th class="r">{{ t('rules.bytes') }}</th><th class="r">{{ t('common.hosts') }}</th><th /></tr></thead>
<tbody>
<tr v-for="u in unknown" :key="u.domain">
<td>{{ u.domain }}</td>
<td class="r num">{{ bytes(u.bytes) }}</td>
<td class="r num">{{ u.hosts }}</td>
<td class="r"><button class="btn sm" @click="classify(u.domain)">{{ t('rules.classify') }}</button></td>
</tr>
</tbody>
</table>
<div v-if="!unknown.length" class="empty">{{ t('common.empty') }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import http from '../api.js'
import RangeTabs from '../components/RangeTabs.vue'
import { appName, bytes, catName } from '../format.js'
import { ui } from '../state.js'
const { t } = useI18n()
const meta = ref({ categories: [], apps: [] })
const rules = ref([])
const unknown = ref([])
const newApp = ref(false)
const f = reactive({ kind: 'domain', pattern: '', app_id: '', app_name: '', category: 'web' })
const formErr = ref('')
const testDomain = ref('')
const testRes = ref(null)
const unknownRange = () => (['today', 'week', 'month'].includes(ui.range) ? ui.range : 'week')
async function load() {
;[meta.value, rules.value] = await Promise.all([http.get('/meta'), http.get('/rules')])
}
async function loadUnknown() {
unknown.value = await http.get('/rules/unknown-domains', { params: { range: unknownRange() } })
}
onMounted(() => { load(); loadUnknown() })
watch(() => ui.range, loadUnknown)
function appLabel(id) {
const a = meta.value.apps.find(x => x.id === id)
return a ? appName(a) : id
}
// 取注册域名(简单处理:最后两段;.com.cn 这类取三段)
function baseDomain(d) {
const p = d.split('.')
const n = /^(com|net|org|gov|edu)\.[a-z]{2}$/.test(p.slice(-2).join('.')) ? 3 : 2
return p.slice(-n).join('.')
}
function classify(domain) {
f.kind = 'domain'
f.pattern = baseDomain(domain)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function add() {
formErr.value = ''
try {
await http.post('/rules', { ...f, app_name: newApp.value ? f.app_name : null, category: newApp.value ? f.category : null })
f.pattern = ''
await load()
await loadUnknown()
} catch (e) { formErr.value = e.message }
}
async function del(r) {
if (!confirm(t('common.confirmDelete'))) return
await http.delete(`/rules/${r.id}`)
await load()
}
async function test() {
if (testDomain.value.trim()) testRes.value = await http.get('/rules/test', { params: { domain: testDomain.value.trim() } })
}
</script>
<style scoped>
.intro { margin: -6px 0 14px; }
.mt { margin-top: 14px; }
.form { display: flex; flex-direction: column; gap: 10px; }
.test { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border-top: 1px solid var(--line); }
.test .input { flex: 1; min-width: 160px; }
.hint { margin: 0; padding-bottom: 4px; font-size: 13px; }
</style>
+170
View File
@@ -0,0 +1,170 @@
<template>
<div>
<div class="page-h"><h1>{{ t('settings.title') }}</h1><span class="sp" /><span class="faint" v-if="sys">{{ t('settings.version') }} {{ sys.version }}</span></div>
<div class="panel" v-if="sys">
<div class="panel-h">{{ t('settings.status') }}<span class="sp" /><button class="btn sm" @click="loadSys">{{ t('common.refresh') }}</button></div>
<div class="panel-b grid g3">
<div class="st">
<div class="st-h"><span class="dot" :class="sys.collector.flow_ok ? 'on' : 'warn'" />{{ t('settings.collector') }}
<span class="faint">udp/{{ s?.ports.ipfix }}</span></div>
<div class="kv"><span>{{ t('settings.flows') }}</span><b class="num">{{ sys.collector.flows.toLocaleString() }}</b></div>
<div class="kv"><span>{{ t('settings.dropped') }}</span><span class="num">{{ sys.collector.flows_dropped.toLocaleString() }}</span></div>
<div class="kv"><span>{{ t('settings.exporters') }}</span><span class="num">{{ Object.keys(sys.collector.exporters).join(', ') || '-' }}</span></div>
<div class="kv"><span>{{ t('settings.last') }}</span><span>{{ ago(sys.collector.last_flow) }}</span></div>
</div>
<div class="st">
<div class="st-h"><span class="dot" :class="sys.dns.ok ? 'on' : 'warn'" />{{ t('settings.dns') }}
<span class="faint">udp/{{ s?.ports.tzsp }}</span></div>
<div class="kv"><span>{{ t('settings.dnsAnswers') }}</span><b class="num">{{ sys.dns.answers.toLocaleString() }}</b></div>
<div class="kv"><span>{{ t('settings.dnsEntries') }}</span><span class="num">{{ sys.dns.entries.toLocaleString() }}</span></div>
<div class="kv"><span>{{ t('settings.last') }}</span><span>{{ ago(sys.dns.last) }}</span></div>
</div>
<div class="st">
<div class="st-h"><span class="dot" :class="!sys.router.enabled ? '' : sys.router.ok ? 'on' : 'bad'" />{{ t('settings.ros') }}
<button v-if="sys.router.enabled" class="btn sm" @click="testRos">{{ t('settings.test') }}</button></div>
<template v-if="sys.router.enabled">
<div class="kv"><span>URL</span><span class="num">{{ s?.ros_url }}</span></div>
<div class="kv"><span>{{ t('login.username') }}</span><span>{{ s?.ros_user }}</span></div>
<div class="kv" v-if="sys.router.identity"><span>Identity</span><span>{{ sys.router.identity }} · {{ sys.router.version }}</span></div>
<div class="kv"><span>{{ t('settings.last') }}</span><span>{{ ago(sys.router.last_poll) }}</span></div>
<div class="err" v-if="sys.router.error">{{ sys.router.error }}</div>
</template>
<div v-else class="muted small">{{ t('settings.rosHint') }}</div>
</div>
</div>
</div>
<div class="grid g2 mt" v-if="s">
<div class="panel">
<div class="panel-h">{{ t('settings.network') }}</div>
<div class="panel-b form">
<label>{{ t('settings.lan') }}</label>
<textarea class="input" rows="5" v-model="lan" />
<div class="faint small">{{ t('settings.lanHint') }}</div>
<div v-if="s.lan_auto.length" class="small"><span class="muted">{{ t('settings.lanAuto') }}:</span>
<span class="num faint">{{ s.lan_auto.join(', ') }}</span></div>
<label>{{ t('settings.wan') }}</label>
<select class="input" v-model="wan">
<option value="">{{ t('settings.wanAuto') }}</option>
<option v-for="i in s.interfaces" :key="i" :value="i">{{ i }}</option>
<option v-if="wan && !s.interfaces.includes(wan)" :value="wan">{{ wan }}</option>
</select>
<div class="faint small">{{ t('settings.wanHint') }} <span v-if="s.wan_detected">{{ t('settings.wanDetected', { name: s.wan_detected }) }}</span></div>
<div class="err" v-if="saveErr">{{ saveErr }}</div>
<div><button class="btn primary" @click="save">{{ saved ? t('common.saved') : t('common.save') }}</button></div>
<div class="faint small">{{ t('settings.retention') }}:{{ t('settings.retentionText', s.retention) }}</div>
</div>
</div>
<div class="panel">
<div class="panel-h">{{ t('settings.password') }}</div>
<form class="panel-b form" @submit.prevent="changePw">
<input type="text" autocomplete="username" :value="auth.username" hidden />
<input class="input" type="password" v-model="pw.old" :placeholder="t('settings.oldPw')" autocomplete="current-password" />
<input class="input" type="password" v-model="pw.new" :placeholder="t('settings.newPw')" autocomplete="new-password" />
<div class="err" v-if="pwErr">{{ pwErr }}</div>
<div><button class="btn" :disabled="!pw.old || pw.new.length < 6">{{ pwOk ? t('common.saved') : t('common.save') }}</button></div>
</form>
</div>
</div>
<div class="panel mt" id="guide">
<div class="panel-h">{{ t('settings.guide') }}<span class="sp" /><button class="btn sm" @click="copy">{{ copied ? t('settings.copied') : t('settings.copy') }}</button></div>
<div class="panel-b">
<p class="muted guide">{{ t('settings.guideIntro', { ip: '<HOST_IP>' }) }}</p>
<div class="ipin"><span class="muted small">HOST_IP</span><input class="input num" v-model="hostIp" /></div>
<pre class="code">{{ guide }}</pre>
<p class="faint small">{{ t('settings.guideNote') }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import http, { auth } from '../api.js'
import { ago } from '../format.js'
const { t } = useI18n()
const s = ref(null)
const sys = ref(null)
const lan = ref('')
const wan = ref('')
const saved = ref(false)
const saveErr = ref('')
const pw = reactive({ old: '', new: '' })
const pwErr = ref('')
const pwOk = ref(false)
const copied = ref(false)
const hostIp = ref(location.hostname)
async function loadSys() { sys.value = await http.get('/system') }
onMounted(async () => {
s.value = await http.get('/settings')
lan.value = s.value.lan_cidrs.split(',').join('\n')
wan.value = s.value.wan_interface
loadSys()
})
async function save() {
saveErr.value = ''
try {
s.value = await http.put('/settings', { lan_cidrs: lan.value, wan_interface: wan.value })
saved.value = true
setTimeout(() => (saved.value = false), 1500)
} catch (e) { saveErr.value = e.message }
}
async function testRos() {
try { sys.value.router = await http.post('/ros/test') } catch (e) { sys.value.router.error = e.message }
}
async function changePw() {
pwErr.value = ''
try {
await http.post('/auth/password', { old_password: pw.old, new_password: pw.new })
pw.old = pw.new = ''
pwOk.value = true
setTimeout(() => (pwOk.value = false), 1500)
} catch (e) { pwErr.value = e.message }
}
const guide = computed(() => {
const ip = hostIp.value || '<HOST_IP>'
const p = s.value?.ports || { ipfix: 2055, tzsp: 37008 }
return `# 1. 流量采集(IPFIX)
/ip traffic-flow set enabled=yes interfaces=all active-flow-timeout=1m inactive-flow-timeout=15s
/ip traffic-flow target add dst-address=${ip} port=${p.ipfix} version=ipfix
# 2. DNS 嗅探(应用识别):路由器自身 DNS 的应答 + 终端直连外部 DNS 的应答
/ip firewall mangle add chain=output protocol=udp src-port=53 action=sniff-tzsp sniff-target=${ip} sniff-target-port=${p.tzsp} comment=ROSDPI-DNS
/ip firewall mangle add chain=forward protocol=udp src-port=53 action=sniff-tzsp sniff-target=${ip} sniff-target-port=${p.tzsp} comment=ROSDPI-DNS
# 3. 只读 API 账号(可选,用于主机名、WAN 速率)
/user group add name=rosdpi policy=read,rest-api,api,!write,!policy,!sensitive
/user add name=rosdpi group=rosdpi password=<强密码> address=${ip}`
})
async function copy() {
try {
await navigator.clipboard.writeText(guide.value)
copied.value = true
setTimeout(() => (copied.value = false), 1500)
} catch (e) { /* 非 https 下剪贴板不可用 */ }
}
</script>
<style scoped>
.mt { margin-top: 14px; }
.small { font-size: 12.5px; }
.st { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
.st-h { display: flex; align-items: center; gap: 8px; font-weight: 600; margin-bottom: 4px; }
.kv { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); }
.kv > :last-child { color: var(--text); text-align: right; overflow: hidden; text-overflow: ellipsis; }
.form { display: flex; flex-direction: column; gap: 8px; }
.form label { font-size: 13px; color: var(--muted); margin-top: 4px; }
.guide { margin: 0 0 10px; }
.ipin { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; max-width: 320px; }
</style>
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: { '/api': 'http://127.0.0.1:8080' },
},
build: { chunkSizeWarningLimit: 1200 },
})
+76
View File
@@ -0,0 +1,76 @@
"""向本机 ROS-DPI 发送模拟的 DNS 应答(TZSP)和 IPFIX 流量,用于开发调试和演示。
用法:python scripts/simulate.py [--host 127.0.0.1] [--minutes 5] [--hosts 14]
需在 backend/.venv 环境下运行(借用 tests/packets.py 的构包函数)。
"""
import argparse
import os
import random
import socket
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend", "tests"))
from packets import ipfix, tzsp_dns # noqa: E402
# (域名, 典型下行 MB/分钟, 上行比例)
SITES = [
("v26.douyinvod.com", 60, .03), ("upos-sz-mirrorcos.bilivideo.com", 80, .02), ("api.bilibili.com", 2, .2),
("szshort.weixin.qq.com", 3, .4), ("mmbiz.qpic.cn", 6, .05), ("rr3---sn.googlevideo.com", 90, .02),
("steamcdn-a.akamaihd.net.steamcontent.com", 120, .01), ("music.163.com", 4, .05),
("chatgpt.com", 1.5, .3), ("claude.ai", 1.2, .35), ("github.com", 3, .1), ("swcdn.apple.com", 70, .01),
("dl.delivery.mp.microsoft.com", 50, .01), ("pan.baidu.com", 30, .3), ("www.taobao.com", 5, .08),
("img.alicdn.com", 8, .03), ("www.zhihu.com", 3, .08), ("xiaohongshu.com", 9, .05),
("cdn.example-unknown.net", 6, .1), ("static.some-cdn.io", 4, .1), ("io.mi.com", .2, .5),
("tuyacn.com", .1, .5), ("www.netflix.com", 2, .05), ("ipv4-c001.nflxvideo.net", 70, .01),
]
DEVICES = ["iPhone", "MacBook", "iPad", "小米电视", "Win 台式机", "Switch", "安卓手机", "NAS", "摄像头", "扫地机"]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--ipfix-port", type=int, default=2055)
ap.add_argument("--tzsp-port", type=int, default=37008)
ap.add_argument("--minutes", type=float, default=3)
ap.add_argument("--hosts", type=int, default=14)
ap.add_argument("--fast", action="store_true", help="不等待,一次性发完")
a = ap.parse_args()
rnd = random.Random(42)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
ips = {d: f"203.0.113.{i + 10}" for i, (d, *_x) in enumerate(SITES)}
hosts = [(f"192.168.88.{100 + i}", f"AA:BB:CC:00:00:{i:02X}") for i in range(a.hosts)]
# 每台终端偏好几个站点
prefs = {ip: rnd.sample(SITES, k=rnd.randint(3, 8)) for ip, _ in hosts}
for d, ip in ips.items():
sock.sendto(tzsp_dns(hosts[0][0], d, [ip]), (a.host, a.tzsp_port))
print(f"已发送 {len(ips)} 条 DNS 应答")
end = time.time() + a.minutes * 60
rounds = 0
while True:
flows = []
for ip, mac in hosts:
for d, mb, up_ratio in prefs[ip]:
down = int(mb * 1024 * 1024 / 6 * rnd.uniform(.3, 1.6)) # 10 秒一轮
up = int(down * up_ratio)
port = 443
flows.append(dict(src=ip, dst=ips[d], sport=rnd.randint(30000, 60000), dport=port, proto=6,
nbytes=up, mac=mac))
flows.append(dict(src=ips[d], dst=ip, sport=port, dport=rnd.randint(30000, 60000), proto=6,
nbytes=down))
for i in range(0, len(flows), 20):
sock.sendto(ipfix(flows[i:i + 20], seq=rounds), (a.host, a.ipfix_port))
rounds += 1
if a.fast and rounds >= a.minutes * 6 or not a.fast and time.time() > end:
break
if not a.fast:
time.sleep(10)
print(f"已发送 {rounds} 轮流量,共 {len(hosts)} 台终端")
print("终端建议备注名:", ", ".join(DEVICES))
if __name__ == "__main__":
main()