commit f3e9fdb2affb28a0f1c4b94c1b2d0e3c52fad100 Author: Su Yijun Date: Wed Oct 21 23:50:56 2026 +0800 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d11ef0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +**/node_modules +**/.venv +**/__pycache__ +**/.pytest_cache +frontend/dist +data +mysql +.env +*.md +docs +scripts +backend/tests diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e3bf924 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d0d6371 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.png binary diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..40df172 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -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<> "$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; } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bc49bfa --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63db043 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.env +data/ +mysql/ +node_modules/ +frontend/dist/ +__pycache__/ +*.pyc +.venv/ +.pytest_cache/ +*.db +*.db-wal +*.db-shm +项目说明.txt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d6993b6 --- /dev/null +++ b/CHANGELOG.md @@ -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)。 diff --git a/DOCKERHUB.md b/DOCKERHUB.md new file mode 100644 index 0000000..d396d67 --- /dev/null +++ b/DOCKERHUB.md @@ -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://: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) | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2a9e118 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..12745d9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e834e4 --- /dev/null +++ b/README.md @@ -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://:8080`. RouterOS commands are listed above and in **Settings → Setup guide** inside the app. + +## License + +[MIT](LICENSE) diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/backend/app/classify/__init__.py b/backend/app/classify/__init__.py new file mode 100644 index 0000000..d8ff9e2 --- /dev/null +++ b/backend/app/classify/__init__.py @@ -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} diff --git a/backend/app/classify/apps.yaml b/backend/app/classify/apps.yaml new file mode 100644 index 0000000..e870c41 --- /dev/null +++ b/backend/app/classify/apps.yaml @@ -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]} diff --git a/backend/app/collector/__init__.py b/backend/app/collector/__init__.py new file mode 100644 index 0000000..594fa35 --- /dev/null +++ b/backend/app/collector/__init__.py @@ -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 diff --git a/backend/app/collector/ipfix.py b/backend/app/collector/ipfix.py new file mode 100644 index 0000000..1337825 --- /dev/null +++ b/backend/app/collector/ipfix.py @@ -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 diff --git a/backend/app/collector/tzsp.py b/backend/app/collector/tzsp.py new file mode 100644 index 0000000..7503912 --- /dev/null +++ b/backend/app/collector/tzsp.py @@ -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) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..db799dc --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..cd3045f --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/dnsmap.py b/backend/app/dnsmap.py new file mode 100644 index 0000000..59fd164 --- /dev/null +++ b/backend/app/dnsmap.py @@ -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) diff --git a/backend/app/engine.py b/backend/app/engine.py new file mode 100644 index 0000000..a92d92a --- /dev/null +++ b/backend/app/engine.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..c8255ba --- /dev/null +++ b/backend/app/main.py @@ -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")) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..73a70e3 --- /dev/null +++ b/backend/app/models.py @@ -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) diff --git a/backend/app/ros_poller.py b/backend/app/ros_poller.py new file mode 100644 index 0000000..6e9e8d1 --- /dev/null +++ b/backend/app/ros_poller.py @@ -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() diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..1d1ae1c --- /dev/null +++ b/backend/app/routers/admin.py @@ -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() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..9e5605d --- /dev/null +++ b/backend/app/routers/auth.py @@ -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} diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py new file mode 100644 index 0000000..2ef3de5 --- /dev/null +++ b/backend/app/routers/stats.py @@ -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) diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..f60696e --- /dev/null +++ b/backend/app/security.py @@ -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"] diff --git a/backend/app/stats.py b/backend/app/stats.py new file mode 100644 index 0000000..2a039ff --- /dev/null +++ b/backend/app/stats.py @@ -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())} diff --git a/backend/app/timeutil.py b/backend/app/timeutil.py new file mode 100644 index 0000000..b00d73b --- /dev/null +++ b/backend/app/timeutil.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..cc74ad2 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..be2e9f4 --- /dev/null +++ b/backend/tests/conftest.py @@ -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) diff --git a/backend/tests/packets.py b/backend/tests/packets.py new file mode 100644 index 0000000..3028d83 --- /dev/null +++ b/backend/tests/packets.py @@ -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 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..b747f6a --- /dev/null +++ b/backend/tests/test_api.py @@ -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"] diff --git a/backend/tests/test_engine.py b/backend/tests/test_engine.py new file mode 100644 index 0000000..c0b9cdf --- /dev/null +++ b/backend/tests/test_engine.py @@ -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() diff --git a/backend/tests/test_parsers.py b/backend/tests/test_parsers.py new file mode 100644 index 0000000..de2141c --- /dev/null +++ b/backend/tests/test_parsers.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..23a8442 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/RouterOS配置.md b/docs/RouterOS配置.md new file mode 100644 index 0000000..dbcb775 --- /dev/null +++ b/docs/RouterOS配置.md @@ -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,见上文 | diff --git a/docs/images/flow-light.png b/docs/images/flow-light.png new file mode 100644 index 0000000..91cd2b0 Binary files /dev/null and b/docs/images/flow-light.png differ diff --git a/docs/images/host-light.png b/docs/images/host-light.png new file mode 100644 index 0000000..a78b00a Binary files /dev/null and b/docs/images/host-light.png differ diff --git a/docs/images/overview-dark.png b/docs/images/overview-dark.png new file mode 100644 index 0000000..b4f8641 Binary files /dev/null and b/docs/images/overview-dark.png differ diff --git a/docs/images/overview-light.png b/docs/images/overview-light.png new file mode 100644 index 0000000..b227b57 Binary files /dev/null and b/docs/images/overview-light.png differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..afff153 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + ROS-DPI + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..32230ea --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1847 @@ +{ + "name": "ros-dpi-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ros-dpi-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource/barlow": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/barlow/-/barlow-5.3.0.tgz", + "integrity": "sha512-tGR0y6QObRtKv71MOth44222+mdNFM/ZWFWDV1VLzNbG4oK9PNRlyJzmkck4vHAi9ibHBXgt4SlY51amFP55CA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/barlow-semi-condensed": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/barlow-semi-condensed/-/barlow-semi-condensed-5.3.0.tgz", + "integrity": "sha512-EdpQ1GlLMofGqDsQhEw+g6DonNKmzt21p8y/oNbTl4BFzEkqYkFyE2fj25YGxmZBL/LZZGAxe74FlvEWcgc31w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@intlify/core-base": { + "version": "10.0.7", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.7.tgz", + "integrity": "sha512-mE71aUH5baH0me8duB4FY5qevUJizypHsYw3eCvmOx07QvmKppgOONx3dYINxuA89Z2qkAGb/K6Nrpi7aAMwew==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "10.0.7", + "@intlify/shared": "10.0.7" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "10.0.7", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.7.tgz", + "integrity": "sha512-nrC4cDL/UHZSUqd8sRbVz+DPukzZ8NnG5OK+EB/nlxsH35deyzyVkXP/QuR8mFZrISJ+4hCd6VtCQCcT+RO+5g==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "10.0.7", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "10.0.7", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.7.tgz", + "integrity": "sha512-oeoq0L5+5P4ShXa6jBQcx+BT+USe3MjX0xJexZO1y7rfDJdwZ9+QP3jO4tcS1nxhBYYdjvFTqe4bmnLijV0GxQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.12.tgz", + "integrity": "sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.12.tgz", + "integrity": "sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.12.tgz", + "integrity": "sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.12.tgz", + "integrity": "sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.12.tgz", + "integrity": "sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.12.tgz", + "integrity": "sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.12.tgz", + "integrity": "sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.12.tgz", + "integrity": "sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.12.tgz", + "integrity": "sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.12.tgz", + "integrity": "sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.12.tgz", + "integrity": "sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.12.tgz", + "integrity": "sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.12.tgz", + "integrity": "sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.12.tgz", + "integrity": "sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.12.tgz", + "integrity": "sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.12.tgz", + "integrity": "sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.12.tgz", + "integrity": "sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.12.tgz", + "integrity": "sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.12.tgz", + "integrity": "sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.12.tgz", + "integrity": "sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.12.tgz", + "integrity": "sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.12.tgz", + "integrity": "sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.12.tgz", + "integrity": "sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.12.tgz", + "integrity": "sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.12.tgz", + "integrity": "sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz", + "integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/shared": "3.5.44", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz", + "integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz", + "integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/compiler-core": "3.5.44", + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-ssr": "3.5.44", + "@vue/shared": "3.5.44", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.29", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", + "integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz", + "integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz", + "integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz", + "integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/runtime-core": "3.5.44", + "@vue/shared": "3.5.44", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz", + "integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz", + "integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.64.12", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.12.tgz", + "integrity": "sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.12", + "@rollup/rollup-android-arm64": "4.64.12", + "@rollup/rollup-darwin-arm64": "4.64.12", + "@rollup/rollup-darwin-x64": "4.64.12", + "@rollup/rollup-freebsd-arm64": "4.64.12", + "@rollup/rollup-freebsd-x64": "4.64.12", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.12", + "@rollup/rollup-linux-arm-musleabihf": "4.64.12", + "@rollup/rollup-linux-arm64-gnu": "4.64.12", + "@rollup/rollup-linux-arm64-musl": "4.64.12", + "@rollup/rollup-linux-loong64-gnu": "4.64.12", + "@rollup/rollup-linux-loong64-musl": "4.64.12", + "@rollup/rollup-linux-ppc64-gnu": "4.64.12", + "@rollup/rollup-linux-ppc64-musl": "4.64.12", + "@rollup/rollup-linux-riscv64-gnu": "4.64.12", + "@rollup/rollup-linux-riscv64-musl": "4.64.12", + "@rollup/rollup-linux-s390x-gnu": "4.64.12", + "@rollup/rollup-linux-x64-gnu": "4.64.12", + "@rollup/rollup-linux-x64-musl": "4.64.12", + "@rollup/rollup-openbsd-x64": "4.64.12", + "@rollup/rollup-openharmony-arm64": "4.64.12", + "@rollup/rollup-win32-arm64-msvc": "4.64.12", + "@rollup/rollup-win32-ia32-msvc": "4.64.12", + "@rollup/rollup-win32-x64-gnu": "4.64.12", + "@rollup/rollup-win32-x64-msvc": "4.64.12", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz", + "integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-sfc": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/server-renderer": "3.5.44", + "@vue/shared": "3.5.44" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-i18n": { + "version": "10.0.7", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.7.tgz", + "integrity": "sha512-bKsk0PYwP9gdYF4nqSAT0kDpnLu1gZzlxFl885VH4mHVhEnqP16+/mAU05r1U6NIrc0fGDWP89tZ8GzeJZpe+w==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "10.0.7", + "@intlify/shared": "10.0.7", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d7bf90a --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6e3d530 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/frontend/scripts/check-i18n.mjs b/frontend/scripts/check-i18n.mjs new file mode 100644 index 0000000..332f9b9 --- /dev/null +++ b/frontend/scripts/check-i18n.mjs @@ -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)`) diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..f44b997 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..3a3809b --- /dev/null +++ b/frontend/src/api.js @@ -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 diff --git a/frontend/src/charts.js b/frontend/src/charts.js new file mode 100644 index 0000000..2a8dde4 --- /dev/null +++ b/frontend/src/charts.js @@ -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)}
${bytes(p.data.value)} ↓${bytes(p.data.down)} ↑${bytes(p.data.up)}` + : `${p.name}
${bytes(p.value)}`), + }), + 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] } + }), + }], + } +} diff --git a/frontend/src/components/BarList.vue b/frontend/src/components/BarList.vue new file mode 100644 index 0000000..216f9ec --- /dev/null +++ b/frontend/src/components/BarList.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/frontend/src/components/Chart.vue b/frontend/src/components/Chart.vue new file mode 100644 index 0000000..face2ea --- /dev/null +++ b/frontend/src/components/Chart.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/frontend/src/components/Mark.vue b/frontend/src/components/Mark.vue new file mode 100644 index 0000000..73f9b61 --- /dev/null +++ b/frontend/src/components/Mark.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/components/RangeTabs.vue b/frontend/src/components/RangeTabs.vue new file mode 100644 index 0000000..a54a026 --- /dev/null +++ b/frontend/src/components/RangeTabs.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/components/Spectrum.vue b/frontend/src/components/Spectrum.vue new file mode 100644 index 0000000..d0a13ae --- /dev/null +++ b/frontend/src/components/Spectrum.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/frontend/src/components/Stat.vue b/frontend/src/components/Stat.vue new file mode 100644 index 0000000..7895527 --- /dev/null +++ b/frontend/src/components/Stat.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/frontend/src/format.js b/frontend/src/format.js new file mode 100644 index 0000000..2271f95 --- /dev/null +++ b/frontend/src/format.js @@ -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' diff --git a/frontend/src/i18n/en.js b/frontend/src/i18n/en.js new file mode 100644 index 0000000..951aed9 --- /dev/null +++ b/frontend/src/i18n/en.js @@ -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.', + }, +} diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js new file mode 100644 index 0000000..468b6d3 --- /dev/null +++ b/frontend/src/i18n/index.js @@ -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 */ } +} diff --git a/frontend/src/i18n/zh.js b/frontend/src/i18n/zh.js new file mode 100644 index 0000000..ea7aa44 --- /dev/null +++ b/frontend/src/i18n/zh.js @@ -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 口速率。', + }, +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..e0089b8 --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/poll.js b/frontend/src/poll.js new file mode 100644 index 0000000..d328dff --- /dev/null +++ b/frontend/src/poll.js @@ -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 } +} diff --git a/frontend/src/router.js b/frontend/src/router.js new file mode 100644 index 0000000..1521e28 --- /dev/null +++ b/frontend/src/router.js @@ -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 diff --git a/frontend/src/state.js b/frontend/src/state.js new file mode 100644 index 0000000..2fa8aca --- /dev/null +++ b/frontend/src/state.js @@ -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'] diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..d5a72c0 --- /dev/null +++ b/frontend/src/style.css @@ -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); } diff --git a/frontend/src/views/AppDetail.vue b/frontend/src/views/AppDetail.vue new file mode 100644 index 0000000..6be39db --- /dev/null +++ b/frontend/src/views/AppDetail.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/frontend/src/views/Apps.vue b/frontend/src/views/Apps.vue new file mode 100644 index 0000000..8942b77 --- /dev/null +++ b/frontend/src/views/Apps.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/frontend/src/views/Flow.vue b/frontend/src/views/Flow.vue new file mode 100644 index 0000000..5daade8 --- /dev/null +++ b/frontend/src/views/Flow.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/views/HostDetail.vue b/frontend/src/views/HostDetail.vue new file mode 100644 index 0000000..cbfb62e --- /dev/null +++ b/frontend/src/views/HostDetail.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/frontend/src/views/Hosts.vue b/frontend/src/views/Hosts.vue new file mode 100644 index 0000000..cdbdb29 --- /dev/null +++ b/frontend/src/views/Hosts.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..f7e58ca --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/views/Overview.vue b/frontend/src/views/Overview.vue new file mode 100644 index 0000000..aa5c579 --- /dev/null +++ b/frontend/src/views/Overview.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend/src/views/Rules.vue b/frontend/src/views/Rules.vue new file mode 100644 index 0000000..37d0537 --- /dev/null +++ b/frontend/src/views/Rules.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..434d987 --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,170 @@ +