feat: ROS-DPI v0.1.0:RouterOS 终端流量与应用统计面板
- IPFIX / NetFlow v9 采集 + TZSP DNS 嗅探识别应用,可选 ROS 只读 REST 轮询 - 内置 100+ 应用规则库,支持自定义域名 / IP 段 / 端口规则 - 按分钟 / 小时 / 天汇总写入 MySQL - 全网概览、主机监控、应用统计、流量流向(桑基图)、识别规则、系统设置 - 中英双语,深浅色主题,T568B 线色设计语言 - Docker 多架构镜像,GitHub Actions 测试与发布 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3e9fdb2af
77 files changed
+7086
No files matched your search
@@ -0,0 +1,14 @@
|
||||
.git
|
||||
.github
|
||||
**/node_modules
|
||||
**/.venv
|
||||
**/__pycache__
|
||||
**/.pytest_cache
|
||||
frontend/dist
|
||||
data
|
||||
mysql
|
||||
.env
|
||||
*.md
|
||||
docs
|
||||
scripts
|
||||
backend/tests
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Test, build and publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags: [ 'v*' ]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Backend tests
|
||||
working-directory: backend
|
||||
run: |
|
||||
pip install -r requirements.txt pytest
|
||||
python -m pytest -q
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Frontend build (含 i18n 检查)
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci || npm install
|
||||
npm run build
|
||||
|
||||
build-and-push:
|
||||
needs: test
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
# 推送到 GHCR 需要 packages:write
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
HAS_DOCKERHUB: ${{ secrets.DOCKERHUB_TOKEN != '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# 没配置 DOCKERHUB_* secrets 时只推 GHCR
|
||||
- name: Login to Docker Hub
|
||||
if: env.HAS_DOCKERHUB == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Image names
|
||||
id: images
|
||||
run: |
|
||||
{
|
||||
echo 'list<<EOF'
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/ros-dpi"
|
||||
[ "$HAS_DOCKERHUB" = "true" ] && echo "suyijun8182/ros-dpi"
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Image tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.images.outputs.list }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# 把 DOCKERHUB.md 同步到 Docker Hub 的 Overview。独立 job:失败不影响镜像发布。
|
||||
# DOCKERHUB_TOKEN 需要 Read, Write, Delete 权限,Read & Write 只能推镜像、改不了说明。
|
||||
dockerhub-overview:
|
||||
needs: build-and-push
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DOCKERHUB_REPO: suyijun8182/ros-dpi
|
||||
DOCKERHUB_SHORT: "RouterOS per-device traffic & app (DPI) statistics via IPFIX + DNS. RouterOS 终端流量与应用统计。"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Sync Docker Hub overview (DOCKERHUB.md)
|
||||
if: env.DOCKERHUB_TOKEN != ''
|
||||
run: |
|
||||
set -euo pipefail
|
||||
JWT=$(curl -s -H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg u "$DOCKERHUB_USERNAME" --arg p "$DOCKERHUB_TOKEN" '{username:$u,password:$p}')" \
|
||||
https://hub.docker.com/v2/users/login/ | jq -r .token)
|
||||
[ -n "$JWT" ] && [ "$JWT" != "null" ] || { echo "Docker Hub 登录失败:检查 DOCKERHUB_USERNAME / DOCKERHUB_TOKEN"; exit 1; }
|
||||
BODY=$(jq -n --arg full "$(cat DOCKERHUB.md)" --arg short "$DOCKERHUB_SHORT" '{full_description:$full, description:$short}')
|
||||
CODE=$(curl -s -o /tmp/dh.json -w '%{http_code}' -X PATCH -H "Authorization: JWT $JWT" \
|
||||
-H "Content-Type: application/json" -d "$BODY" "https://hub.docker.com/v2/repositories/$DOCKERHUB_REPO/")
|
||||
[ "$CODE" = "200" ] && echo "Overview 已同步" || { echo "同步失败(HTTP $CODE)"; cat /tmp/dh.json; exit 1; }
|
||||
@@ -0,0 +1,34 @@
|
||||
name: GitHub Release
|
||||
|
||||
# 推送 v* tag 时创建 GitHub Release,正文取自 CHANGELOG.md 对应版本段落。
|
||||
on:
|
||||
push:
|
||||
tags: [ 'v*' ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF_NAME}"; VER="${TAG#v}"
|
||||
NOTES=$(awk -v v="## [$VER]" 'index($0,v)==1{f=1;next} /^## \[/{if(f)exit} f' CHANGELOG.md)
|
||||
[ -n "$NOTES" ] || NOTES="详见提交记录。"
|
||||
{
|
||||
echo '### 升级'
|
||||
echo
|
||||
echo '```bash'
|
||||
echo 'docker compose pull && docker compose up -d'
|
||||
echo '```'
|
||||
echo
|
||||
echo "$NOTES"
|
||||
} > notes.md
|
||||
gh release view "$TAG" >/dev/null 2>&1 && { echo "已存在 $TAG"; exit 0; }
|
||||
gh release create "$TAG" --title "$TAG" --notes-file notes.md --verify-tag
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
.env
|
||||
data/
|
||||
mysql/
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
项目说明.txt
|
||||
@@ -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)。
|
||||
@@ -0,0 +1,46 @@
|
||||
# ROS-DPI
|
||||
|
||||
RouterOS 终端流量与应用统计面板。按 今日 / 本周 / 本月 统计每台终端的流量和应用使用情况,类似 Panabit / 碧海威。
|
||||
|
||||
Per-device traffic and per-application (DPI-style) statistics for MikroTik RouterOS, built on IPFIX + DNS sniffing.
|
||||
|
||||
Source & docs: https://github.com/suyijun8182/ros-dpi
|
||||
|
||||
## What's new
|
||||
|
||||
- **0.1.0**: first release. Overview, hosts, applications, flow Sankey, custom rules, setup guide. zh/en, light/dark.
|
||||
|
||||
## Features
|
||||
|
||||
- IPFIX / NetFlow v9 collector (udp/2055), TZSP DNS sniffer (udp/37008), optional read-only RouterOS REST polling
|
||||
- 100+ built-in apps in 20+ categories; custom domain / CIDR / port rules
|
||||
- Per-minute / hour / day aggregation in MySQL
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/suyijun8182/ros-dpi/main/docker-compose.yml
|
||||
curl -o .env https://raw.githubusercontent.com/suyijun8182/ros-dpi/main/.env.example
|
||||
# edit .env (MySQL passwords, LAN_CIDRS)
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `http://<host>:8080`. RouterOS commands are shown in **Settings → Setup guide**.
|
||||
|
||||
## Ports
|
||||
|
||||
| Port | Use |
|
||||
|---|---|
|
||||
| 8080/tcp | Web UI |
|
||||
| 2055/udp | IPFIX / NetFlow v9 |
|
||||
| 37008/udp | TZSP (DNS mirror) |
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | set in compose | `mysql+pymysql://user:pass@host:3306/rosdpi?charset=utf8mb4` |
|
||||
| `LAN_CIDRS` | private ranges | LAN ranges, comma separated |
|
||||
| `ADMIN_PASSWORD` | empty | initial password for `admin` |
|
||||
| `ROS_URL` / `ROS_USER` / `ROS_PASSWORD` | empty | RouterOS REST + read-only user |
|
||||
| `TZ` | `Asia/Shanghai` | statistics time zone (weeks start Monday) |
|
||||
+22
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,129 @@
|
||||
# ROS-DPI
|
||||
|
||||
**RouterOS 终端流量与应用统计面板**:像 Panabit / 碧海威那样,按 今日 / 本周 / 本月 统计 RouterOS 下每台终端用了多少流量、用在了哪些应用上。
|
||||
|
||||
[中文](#中文) · [English](#english)
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 中文
|
||||
|
||||
### 能做什么
|
||||
|
||||
- **全网概览**:今日 / 本周 / 本月流量,此刻的上下行速率,流量构成(按应用分类),Top 应用与 Top 终端,24 小时与 30 天趋势。
|
||||
- **主机监控**:所有终端的 IP、MAC、主机名、在线状态、当前速率、今日 / 本周 / 本月流量,可以给设备起备注名;详情页可看该终端的应用排行、分类占比和访问过的域名。
|
||||
- **应用统计**:100 多个内置应用(抖音、B 站、微信、Steam、Apple 更新、ChatGPT……)分 20 多个类别,可以查看每个应用被哪些终端使用。
|
||||
- **流量流向**:桑基图展示 终端 → 应用分类 → 应用 的流向。
|
||||
- **识别规则**:按域名后缀、IP 段、端口自定义识别规则;列出“未识别域名 Top”,一键归类。
|
||||
- 中英双语,深浅色主题,手机可用。
|
||||
|
||||

|
||||
|
||||
### 工作原理
|
||||
|
||||
RouterOS 本身没有 DPI,ROS-DPI 用两路数据拼出应用级统计:
|
||||
|
||||
```
|
||||
RouterOS ──IPFIX udp/2055 ──▶ 流量采集:每条连接的终端、远端 IP、端口、字节数
|
||||
RouterOS ──TZSP udp/37008──▶ DNS 嗅探:远端 IP 是哪个域名解析出来的
|
||||
RouterOS ◀─REST 只读(可选)── 主机名、IP↔MAC、WAN 口速率、LAN 前缀
|
||||
↓
|
||||
域名 → 应用规则库 → 按 分钟 / 小时 / 天 汇总写入 MySQL
|
||||
```
|
||||
|
||||
- 流量来自 `/ip traffic-flow`(IPFIX),统计的是真实字节数。
|
||||
- 应用识别靠 DNS:终端访问 `v26.douyinvod.com` 时,路由器把 DNS 应答镜像一份过来,之后发往该 IP 的流量就归到“抖音”。查不到域名时,依次按 IP 段、端口兜底(HTTPS / QUIC 等)。
|
||||
- 终端用 MAC 区分(来自 ROS 的 ARP / DHCP,或流记录中的源 MAC),IP 变化不影响统计。
|
||||
|
||||
**已知局限**:终端使用 DoH / DoT 加密 DNS,或者在路由器之外解析 DNS 时,这部分流量只能识别为 HTTPS / QUIC。可以在终端或路由器上禁用加密 DNS 来提高识别率。
|
||||
|
||||
### 部署
|
||||
|
||||
```bash
|
||||
mkdir ros-dpi && cd ros-dpi
|
||||
curl -O https://raw.githubusercontent.com/suyijun8182/ros-dpi/main/docker-compose.yml
|
||||
curl -o .env https://raw.githubusercontent.com/suyijun8182/ros-dpi/main/.env.example
|
||||
# 编辑 .env:修改 MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD,填写 LAN_CIDRS
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
打开 `http://<主机IP>:8080`,第一次访问时设置管理员密码。
|
||||
|
||||
镜像:`ghcr.io/suyijun8182/ros-dpi`(amd64 / arm64)。已有 MySQL 时,删掉 compose 里的 `mysql` 服务,把 `DATABASE_URL` 指向你的数据库即可。
|
||||
|
||||
### RouterOS 配置
|
||||
|
||||
把 `192.168.88.10` 换成运行容器的主机 IP(**系统设置 → 接入向导**里可以直接复制填好 IP 的命令):
|
||||
|
||||
```routeros
|
||||
# 1. 流量采集(IPFIX)
|
||||
/ip traffic-flow set enabled=yes interfaces=all active-flow-timeout=1m inactive-flow-timeout=15s
|
||||
/ip traffic-flow target add dst-address=192.168.88.10 port=2055 version=ipfix
|
||||
|
||||
# 2. DNS 嗅探(应用识别)
|
||||
/ip firewall mangle add chain=output protocol=udp src-port=53 action=sniff-tzsp sniff-target=192.168.88.10 sniff-target-port=37008 comment=ROSDPI-DNS
|
||||
/ip firewall mangle add chain=forward protocol=udp src-port=53 action=sniff-tzsp sniff-target=192.168.88.10 sniff-target-port=37008 comment=ROSDPI-DNS
|
||||
|
||||
# 3. 只读 API 账号(可选:主机名、WAN 速率)
|
||||
/user group add name=rosdpi policy=read,rest-api,api,!write,!policy,!sensitive
|
||||
/user add name=rosdpi group=rosdpi password=<强密码> address=192.168.88.10
|
||||
```
|
||||
|
||||
更多说明(FastTrack、双 NAT、IPv6、排错)见 [docs/RouterOS配置.md](docs/RouterOS配置.md)。
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | compose 中已设置 | `mysql+pymysql://user:pass@host:3306/rosdpi?charset=utf8mb4` |
|
||||
| `LAN_CIDRS` | 私网三段 + `fd00::/8` | 内网网段,逗号分隔,界面里也能改 |
|
||||
| `ADMIN_PASSWORD` | 空 | 首次启动创建 `admin` 用的密码;留空则在登录页设置 |
|
||||
| `ROS_URL` / `ROS_USER` / `ROS_PASSWORD` | 空 | RouterOS REST 地址与只读账号,如 `https://192.168.88.1` |
|
||||
| `ROS_VERIFY_TLS` | `false` | 是否校验 ROS 证书 |
|
||||
| `WAN_INTERFACE` | 自动 | WAN 接口名,默认按默认路由识别 |
|
||||
| `TZ` | `Asia/Shanghai` | 统计用时区,每周从周一开始 |
|
||||
| `HTTP_PORT` / `IPFIX_PORT` / `TZSP_PORT` | 8080 / 2055 / 37008 | 端口 |
|
||||
| `RETENTION_*_DAYS` | 2 / 45 / 730 / 31 | 分钟 / 小时 / 每日 / 域名明细的保留天数 |
|
||||
| `SECRET_KEY` | 自动生成 | 登录令牌签名密钥 |
|
||||
|
||||
### 开发
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend && python -m venv .venv && .venv/bin/pip install -r requirements.txt pytest
|
||||
DATABASE_URL=sqlite:///./dev.db .venv/bin/uvicorn app.main:app --port 8080
|
||||
.venv/bin/python -m pytest
|
||||
|
||||
# 前端
|
||||
cd frontend && npm install && npm run dev # http://localhost:5173,/api 代理到 8080
|
||||
|
||||
# 灌入模拟数据
|
||||
backend/.venv/bin/python scripts/simulate.py --minutes 5
|
||||
```
|
||||
|
||||
应用规则库在 [backend/app/classify/apps.yaml](backend/app/classify/apps.yaml),欢迎 PR 补充。
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
**ROS-DPI** shows per-device traffic and per-application usage (today / this week / this month) for networks behind a MikroTik RouterOS router, in the spirit of Panabit-style DPI dashboards.
|
||||
|
||||
- **Overview**: totals, live rate, traffic mix by category, top apps and hosts, trends.
|
||||
- **Hosts**: every device with IP, MAC, hostname, online state, live rate and daily / weekly / monthly totals; per-host app breakdown and visited domains.
|
||||
- **Applications**: 100+ built-in apps in 20+ categories.
|
||||
- **Flows**: a Sankey diagram from hosts through categories to apps.
|
||||
- **Rules**: custom domain / CIDR / port rules, and a list of top unidentified domains to classify.
|
||||
- Chinese and English UI, light and dark themes, works on phones.
|
||||
|
||||
**How it works.** RouterOS exports IPFIX (`/ip traffic-flow`) for byte counts, and mirrors DNS answers via `sniff-tzsp` so each remote IP can be mapped back to the domain the client looked up. A rule library maps domains to apps. An optional read-only REST user provides hostnames, IP-to-MAC, WAN rate and LAN prefixes. Data is aggregated per minute, hour and day into MySQL.
|
||||
|
||||
**Limitations.** Clients using DoH/DoT (or resolving DNS elsewhere) show up as generic HTTPS/QUIC.
|
||||
|
||||
**Deploy.** Copy `docker-compose.yml` and `.env.example` (as `.env`), set the MySQL passwords and `LAN_CIDRS`, run `docker compose up -d`, then open `http://<host>:8080`. RouterOS commands are listed above and in **Settings → Setup guide** inside the app.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
@@ -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}
|
||||
@@ -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]}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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())}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,71 @@
|
||||
# RouterOS 配置说明
|
||||
|
||||
下文以容器主机 IP `192.168.88.10`、路由器 LAN `192.168.88.1` 为例。命令适用于 RouterOS 7。
|
||||
|
||||
## 1. 流量采集(必需)
|
||||
|
||||
```routeros
|
||||
/ip traffic-flow set enabled=yes interfaces=all active-flow-timeout=1m inactive-flow-timeout=15s
|
||||
/ip traffic-flow target add dst-address=192.168.88.10 port=2055 version=ipfix
|
||||
```
|
||||
|
||||
- `interfaces=all`:上行包在 LAN 口入方向采集,下行包在 WAN 口入方向采集,两者合起来才完整。ROS-DPI 会按 LAN 网段判断方向,并自动丢弃内网互访和路由器自身的流量,不会重复计数。
|
||||
- `active-flow-timeout=1m`:长连接每分钟上报一次,统计和速率才能接近实时。
|
||||
- 也可用 `version=9`(NetFlow v9),两者都支持。
|
||||
|
||||
**FastTrack**:被 FastTrack 的连接可能不经过 Traffic Flow,导致统计偏少。对比概览页的“WAN 实时速率”(来自接口计数)和“采集速率”可以判断。如果差距明显,可以缩小 FastTrack 范围,或者关闭它(会增加 CPU 占用)。
|
||||
|
||||
## 2. DNS 嗅探(应用识别,强烈建议)
|
||||
|
||||
```routeros
|
||||
/ip firewall mangle add chain=output protocol=udp src-port=53 action=sniff-tzsp sniff-target=192.168.88.10 sniff-target-port=37008 comment=ROSDPI-DNS
|
||||
/ip firewall mangle add chain=forward protocol=udp src-port=53 action=sniff-tzsp sniff-target=192.168.88.10 sniff-target-port=37008 comment=ROSDPI-DNS
|
||||
```
|
||||
|
||||
- `output`:路由器自己作为 DNS 服务器时,发给终端的应答。
|
||||
- `forward`:终端直连外部 DNS(如 223.5.5.5)时的应答。
|
||||
- 只镜像 DNS 应答,数据量很小。
|
||||
|
||||
提高识别率:
|
||||
|
||||
- 在 DHCP 里把 DNS 下发为路由器地址,并开启 `/ip dns set allow-remote-requests=yes`。
|
||||
- 浏览器 / 系统里的“安全 DNS(DoH)”会绕过嗅探,可以在终端上关闭,或用防火墙拦截常见 DoH 地址。
|
||||
|
||||
## 3. 只读 API 账号(可选)
|
||||
|
||||
```routeros
|
||||
/user group add name=rosdpi policy=read,rest-api,api,!write,!policy,!sensitive
|
||||
/user add name=rosdpi group=rosdpi password=<强密码> address=192.168.88.10
|
||||
/ip service set www-ssl disabled=no # REST API 走 HTTPS(或用 www 的 HTTP)
|
||||
```
|
||||
|
||||
然后在 `.env` 设置:
|
||||
|
||||
```
|
||||
ROS_URL=https://192.168.88.1
|
||||
ROS_USER=rosdpi
|
||||
ROS_PASSWORD=<强密码>
|
||||
```
|
||||
|
||||
用于:DHCP 主机名、ARP / IPv6 邻居(IP↔MAC)、WAN 口实时速率、路由器 CPU / 内存,以及自动发现 LAN 的 IPv6 公网前缀。ROS-DPI 只发 GET 请求,不会修改路由器配置。
|
||||
|
||||
> 修改了这个用户组的权限后,已登录的会话仍沿用旧权限,需要重启容器或执行 `/user active request-logout [find name=rosdpi]`。
|
||||
|
||||
## 4. LAN 网段
|
||||
|
||||
ROS-DPI 用 LAN 网段判断哪一端是终端。默认是三个私网段,以下情况需要手动修改(`.env` 的 `LAN_CIDRS`,或 **系统设置 → 网络**):
|
||||
|
||||
- **双 NAT**(WAN 口也是 192.168.x.x 等私网地址):只填内网实际网段,否则 WAN 侧地址会被当成终端。
|
||||
- **IPv6**:配置了 ROS API 时会自动加入 LAN 接口上的公网前缀;否则需要手动填写。
|
||||
|
||||
## 排错
|
||||
|
||||
**系统设置 → 运行状态** 会显示:
|
||||
|
||||
| 现象 | 检查 |
|
||||
|---|---|
|
||||
| 流量采集一直“等待数据” | 容器的 udp/2055 是否映射;主机防火墙;`/ip traffic-flow target print` |
|
||||
| 已处理流很多,但全部“丢弃” | LAN 网段填错 |
|
||||
| DNS 应答为 0 | mangle 规则是否命中(`/ip firewall mangle print stats`);udp/37008 是否映射 |
|
||||
| 识别率低 | 终端是否在用 DoH;在 **识别规则 → 未识别域名 Top** 里补规则 |
|
||||
| 统计明显偏少 | FastTrack,见上文 |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 337 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>ROS-DPI</title>
|
||||
<script>
|
||||
try { var t = localStorage.getItem('rosdpi.theme'); if (t) document.documentElement.dataset.theme = t } catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1847
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" rx="5" fill="#1b262c"/><rect x="4" y="5" width="3" height="14" rx="1.5" fill="#e07a2e"/><rect x="8.3" y="8" width="3" height="11" rx="1.5" fill="#2f9a57"/><rect x="12.6" y="3.5" width="3" height="15.5" rx="1.5" fill="#2c6bc9"/><rect x="17" y="11" width="3" height="8" rx="1.5" fill="#b88660"/></svg>
|
||||
|
After Width: | Height: | Size: 389 B |
@@ -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)`)
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<router-view v-if="$route.meta.public" />
|
||||
<div v-else class="shell">
|
||||
<header class="top">
|
||||
<div class="top-in">
|
||||
<router-link to="/" class="brand"><Mark :size="22" /><span>ROS-DPI</span></router-link>
|
||||
<nav class="nav" aria-label="main">
|
||||
<router-link v-for="n in nav" :key="n.to" :to="n.to" :class="{ on: isOn(n) }">{{ t(n.label) }}</router-link>
|
||||
</nav>
|
||||
<div class="tools">
|
||||
<button class="btn ghost sm" @click="toggleTheme" :aria-label="t('common.theme')" :title="t('common.theme')">
|
||||
<svg v-if="ui.theme === 'dark'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||
</button>
|
||||
<button class="btn ghost sm" @click="setLang(locale === 'zh' ? 'en' : 'zh')">{{ t('common.lang') }}</button>
|
||||
<button class="btn ghost sm" @click="logout">{{ t('nav.logout') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="main">
|
||||
<div class="page"><router-view /></div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { setAuth } from './api.js'
|
||||
import Mark from './components/Mark.vue'
|
||||
import { setLang } from './i18n/index.js'
|
||||
import { toggleTheme, ui } from './state.js'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const nav = [
|
||||
{ to: '/', label: 'nav.overview' },
|
||||
{ to: '/hosts', label: 'nav.hosts' },
|
||||
{ to: '/apps', label: 'nav.apps' },
|
||||
{ to: '/flow', label: 'nav.flow' },
|
||||
{ to: '/rules', label: 'nav.rules' },
|
||||
{ to: '/settings', label: 'nav.settings' },
|
||||
]
|
||||
|
||||
const isOn = n => (n.to === '/' ? route.path === '/' : route.path.startsWith(n.to))
|
||||
|
||||
function logout() {
|
||||
setAuth('')
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.top { background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 30; }
|
||||
.top-in { max-width: 1488px; margin: 0 auto; padding: 0 24px; height: 56px; display: flex; align-items: center; gap: 28px; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font: 700 19px/1 var(--cond); letter-spacing: .02em; flex: none; }
|
||||
.nav { display: flex; gap: 4px; height: 100%; flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
||||
.nav::-webkit-scrollbar { display: none; }
|
||||
.nav a {
|
||||
display: flex; align-items: center; padding: 0 12px; color: var(--muted); font-weight: 500;
|
||||
position: relative; white-space: nowrap;
|
||||
}
|
||||
.nav a:hover { color: var(--ink); }
|
||||
.nav a.on { color: var(--ink); }
|
||||
/* 当前页:底部一段蓝对色线 */
|
||||
.nav a.on::after { content: ""; position: absolute; left: 12px; right: 12px; bottom: 0; height: 3px; border-radius: 3px 3px 0 0; background: var(--pair-blue); }
|
||||
.tools { display: flex; gap: 2px; flex: none; }
|
||||
.main { padding: 24px 24px 56px; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.top-in { flex-wrap: wrap; height: auto; padding: 10px 16px 0; gap: 8px 12px; }
|
||||
.brand { order: 1; }
|
||||
.tools { order: 2; margin-left: auto; }
|
||||
.nav { order: 3; flex-basis: 100%; height: 42px; margin: 0 -8px; }
|
||||
.main { padding: 18px 16px 40px; }
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
@@ -0,0 +1,97 @@
|
||||
// ECharts 配置生成。颜色从 CSS 变量读取,切换主题/语言后重新生成即可。
|
||||
import { appName, bytes, catColor, rate } from './format.js'
|
||||
import { i18n } from './i18n/index.js'
|
||||
|
||||
const css = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim()
|
||||
const t = (k, p) => i18n.global.t(k, p)
|
||||
const FONT = '"Barlow Semi Condensed", "Barlow", "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
|
||||
function tooltip(extra = {}) {
|
||||
return {
|
||||
backgroundColor: css('--panel'), borderColor: css('--line-strong'), borderWidth: 1, padding: [8, 12],
|
||||
textStyle: { color: css('--ink'), fontSize: 13, fontFamily: FONT }, extraCssText: 'box-shadow:none;border-radius:6px', ...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(ts, gran) {
|
||||
const d = new Date(ts * 1000)
|
||||
const p = n => String(n).padStart(2, '0')
|
||||
if (gran === 'day') return `${d.getMonth() + 1}/${d.getDate()}`
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
}
|
||||
|
||||
/** 上下行趋势:下行(蓝)在上、上行(橙)以负值画在零线下方,像一对双绞线。 */
|
||||
export function trendOption(points, gran = 'hour', asRate = false) {
|
||||
const step = { minute: 60, hour: 3600, day: 86400 }[gran]
|
||||
const val = v => (asRate ? v / step : v)
|
||||
const fmt = v => (asRate ? rate(Math.abs(v)) : bytes(Math.abs(v)))
|
||||
const down = css('--down'), up = css('--up')
|
||||
const series = (name, key, color, sign) => ({
|
||||
name, type: gran === 'day' ? 'bar' : 'line', barMaxWidth: 14, barGap: '-100%',
|
||||
smooth: 0.25, symbol: 'none', lineStyle: { width: 1.5, color },
|
||||
areaStyle: gran === 'day' ? undefined : { color, opacity: 0.22 },
|
||||
itemStyle: { color, borderRadius: sign > 0 ? [2, 2, 0, 0] : [0, 0, 2, 2] },
|
||||
data: points.map(p => sign * val(p[key])),
|
||||
})
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
grid: { left: 4, right: 8, top: 30, bottom: 2, containLabel: true },
|
||||
legend: { top: 0, left: 0, icon: 'rect', itemWidth: 12, itemHeight: 3, textStyle: { color: css('--muted'), fontFamily: FONT, fontSize: 13 } },
|
||||
tooltip: tooltip({ trigger: 'axis', axisPointer: { type: 'line', lineStyle: { color: css('--line-strong') } }, valueFormatter: fmt }),
|
||||
xAxis: {
|
||||
type: 'category', boundaryGap: gran === 'day', data: points.map(p => fmtTime(p.ts, gran)),
|
||||
axisLine: { show: false }, axisTick: { show: false },
|
||||
axisLabel: { color: css('--faint'), hideOverlap: true, fontFamily: FONT, fontSize: 12 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value', splitNumber: 4,
|
||||
splitLine: { lineStyle: { color: css('--line') } },
|
||||
axisLabel: { color: css('--faint'), fontFamily: FONT, fontSize: 12, formatter: v => (v === 0 ? '0' : fmt(v).replace('.0 ', ' ')) },
|
||||
},
|
||||
series: [series(t('common.down'), 'down', down, 1), series(t('common.up'), 'up', up, -1)],
|
||||
}
|
||||
}
|
||||
|
||||
/** 流向桑基图:终端 → 分类 → 应用 */
|
||||
export function sankeyOption(graph) {
|
||||
const zh = i18n.global.locale.value === 'zh'
|
||||
const ink = css('--ink'), muted = css('--muted')
|
||||
const label = n => {
|
||||
if (n.kind === 'host') return n.key ? n.name : t('flow.otherHosts')
|
||||
if (n.kind === 'category') return zh ? n.name : n.name_en
|
||||
if (!n.app) {
|
||||
const cat = graph.nodes.find(x => x.id === `c:${n.category}`)
|
||||
return t('flow.otherApps', { cat: cat ? (zh ? cat.name : cat.name_en) : '' })
|
||||
}
|
||||
return appName(n)
|
||||
}
|
||||
const minLabel = (graph.total || 0) * 0.012
|
||||
const idx = new Map(graph.nodes.map((n, i) => [n.id, i]))
|
||||
const color = n => (n.kind === 'host' ? (n.online ? css('--pair-green') : css('--line-strong')) : catColor(n.category))
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
tooltip: tooltip({
|
||||
trigger: 'item',
|
||||
formatter: p => (p.dataType === 'edge'
|
||||
? `${label(p.data.s)} → ${label(p.data.tg)}<br/><b>${bytes(p.data.value)}</b> <span style="color:${css('--down')}">↓${bytes(p.data.down)}</span> <span style="color:${css('--up')}">↑${bytes(p.data.up)}</span>`
|
||||
: `${p.name}<br/><b>${bytes(p.value)}</b>`),
|
||||
}),
|
||||
series: [{
|
||||
type: 'sankey', left: 4, right: 150, top: 8, bottom: 8, nodeWidth: 10, nodeGap: 9, draggable: false,
|
||||
layoutIterations: 48, emphasis: { focus: 'adjacency' },
|
||||
// 占比太小的节点挤在一起时标签会重叠,只在悬停时通过提示框查看
|
||||
label: {
|
||||
color: ink, fontFamily: FONT, fontSize: 13, rich: { v: { color: muted, fontSize: 12 } },
|
||||
formatter: p => (p.value < minLabel ? '' : `${p.name} {v|${bytes(p.value)}}`),
|
||||
},
|
||||
lineStyle: { color: 'gradient', opacity: 0.32, curveness: 0.5 },
|
||||
itemStyle: { borderWidth: 0 },
|
||||
data: graph.nodes.map(n => ({ name: label(n), id: n.id, raw: n, itemStyle: { color: color(n) } })),
|
||||
// 用下标连线:终端备注名可能和应用/分类重名
|
||||
links: graph.links.map(l => {
|
||||
const si = idx.get(l.source), ti = idx.get(l.target)
|
||||
return { source: si, target: ti, value: l.value, up: l.up, down: l.down, s: graph.nodes[si], tg: graph.nodes[ti] }
|
||||
}),
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<ol class="bars">
|
||||
<li v-if="!items.length" class="empty">{{ t('common.empty') }}</li>
|
||||
<li v-for="it in items" :key="it.key">
|
||||
<component :is="it.to ? 'router-link' : 'div'" :to="it.to" class="row">
|
||||
<span class="lab">
|
||||
<span v-if="it.color" class="chip" :style="{ background: it.color }" />
|
||||
<span v-else class="dot" :class="{ on: it.online }" />
|
||||
<span class="name">{{ it.label }}</span>
|
||||
<span class="sub faint" v-if="it.sub">{{ it.sub }}</span>
|
||||
</span>
|
||||
<span class="val num">{{ bytes(it.value) }}</span>
|
||||
<!-- 长度 = 相对最大项;颜色按上下行拆成蓝/橙两段 -->
|
||||
<span class="track" :style="{ width: Math.max(it.value / max * 100, 0.8) + '%' }">
|
||||
<i class="d" :style="{ flexGrow: it.down ?? it.value }" />
|
||||
<i class="u" :style="{ flexGrow: it.up ?? 0 }" />
|
||||
</span>
|
||||
</component>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { bytes } from '../format.js'
|
||||
|
||||
const props = defineProps({ items: { type: Array, default: () => [] } })
|
||||
const { t } = useI18n()
|
||||
const max = computed(() => Math.max(1, ...props.items.map(i => i.value)))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bars { list-style: none; margin: 0; padding: 0; }
|
||||
.row {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 12px; row-gap: 6px;
|
||||
padding: 8px 6px 10px; margin: 0 -6px; border-radius: var(--r-sm);
|
||||
}
|
||||
a.row:hover { background: var(--sunk); }
|
||||
a.row:hover .name { text-decoration: underline; text-underline-offset: 3px; }
|
||||
.lab { display: flex; gap: 8px; align-items: center; min-width: 0; }
|
||||
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
||||
.sub { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.val { font-size: 16px; font-weight: 600; }
|
||||
.track { grid-column: 1 / -1; display: flex; height: 4px; border-radius: 2px; overflow: hidden; }
|
||||
.track i { display: block; height: 100%; }
|
||||
.track .d { background: var(--down); }
|
||||
.track .u { background: var(--up); }
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div ref="el" class="chart" :style="{ height }" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { BarChart, LineChart, PieChart, SankeyChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { ui } from '../state.js'
|
||||
|
||||
echarts.use([LineChart, PieChart, BarChart, SankeyChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||
|
||||
const props = defineProps({ option: Object, height: { type: String, default: '260px' } })
|
||||
const emit = defineEmits(['click'])
|
||||
const el = ref(null)
|
||||
let chart = null
|
||||
let ro = null
|
||||
|
||||
function render() {
|
||||
if (!chart || !props.option) return
|
||||
chart.setOption(props.option, true)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
chart = echarts.init(el.value, null, { renderer: 'canvas' })
|
||||
chart.on('click', p => emit('click', p))
|
||||
render()
|
||||
ro = new ResizeObserver(() => chart && chart.resize())
|
||||
ro.observe(el.value)
|
||||
})
|
||||
|
||||
watch(() => props.option, render, { deep: true })
|
||||
watch(() => ui.theme, render)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ro && ro.disconnect()
|
||||
chart && chart.dispose()
|
||||
chart = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<!-- 品牌标记:RJ45 的四对线色(橙、绿、蓝、棕) -->
|
||||
<svg :width="size" :height="size" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="4" width="3" height="16" rx="1.5" fill="var(--pair-orange)" />
|
||||
<rect x="8" y="7" width="3" height="13" rx="1.5" fill="var(--pair-green)" />
|
||||
<rect x="13" y="2" width="3" height="18" rx="1.5" fill="var(--pair-blue)" />
|
||||
<rect x="18" y="10" width="3" height="10" rx="1.5" fill="var(--pair-brown)" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ size: { type: Number, default: 24 } })
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="seg">
|
||||
<button v-for="r in list" :key="r" :class="{ on: ui.range === r }" @click="ui.range = r">{{ t('range.' + r) }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RANGES, ui } from '../state.js'
|
||||
|
||||
defineProps({ list: { type: Array, default: () => RANGES } })
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<!-- 流量构成条:按分类占比横向切分,像一束线缆的截面 -->
|
||||
<div class="spec">
|
||||
<div class="bar" role="img" :aria-label="aria">
|
||||
<component :is="clickable ? 'button' : 'span'" v-for="c in segs" :key="c.id" class="seg-i"
|
||||
:style="{ flexGrow: c.total, background: catColor(c.id) }"
|
||||
:title="`${catName(c)} ${bytes(c.total)} ${pct(c.total / sum)}`"
|
||||
@click="clickable && $emit('pick', c.id)" />
|
||||
<span v-if="!segs.length" class="seg-i none" />
|
||||
</div>
|
||||
<ul class="legend" v-if="legend && segs.length">
|
||||
<li v-for="c in segs.slice(0, legendMax)" :key="c.id">
|
||||
<component :is="clickable ? 'button' : 'span'" class="lg" @click="clickable && $emit('pick', c.id)">
|
||||
<span class="chip" :style="{ background: catColor(c.id) }" />
|
||||
<span>{{ catName(c) }}</span>
|
||||
<span class="num faint">{{ pct(c.total / sum, 0) }}</span>
|
||||
</component>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { bytes, catColor, catName, pct } from '../format.js'
|
||||
|
||||
const props = defineProps({
|
||||
cats: { type: Array, default: () => [] },
|
||||
legend: { type: Boolean, default: true },
|
||||
legendMax: { type: Number, default: 12 },
|
||||
clickable: Boolean,
|
||||
height: { type: Number, default: 14 },
|
||||
})
|
||||
defineEmits(['pick'])
|
||||
const segs = computed(() => props.cats.filter(c => c.total > 0))
|
||||
const sum = computed(() => segs.value.reduce((s, c) => s + c.total, 0) || 1)
|
||||
const aria = computed(() => segs.value.slice(0, 6).map(c => `${catName(c)} ${pct(c.total / sum.value, 0)}`).join(', '))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bar { display: flex; gap: 2px; height: v-bind('props.height + "px"'); }
|
||||
.seg-i { min-width: 3px; height: 100%; border: 0; padding: 0; border-radius: 2px; display: block; }
|
||||
button.seg-i { cursor: pointer; }
|
||||
button.seg-i:hover { filter: brightness(1.12); }
|
||||
.seg-i.none { flex: 1; background: var(--line); }
|
||||
.legend { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 18px; }
|
||||
.lg { display: inline-flex; align-items: center; gap: 7px; font-size: 13.5px; background: none; border: 0; padding: 0; color: var(--ink); }
|
||||
button.lg { cursor: pointer; }
|
||||
button.lg:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="stat">
|
||||
<div class="lbl">{{ label }}</div>
|
||||
<div class="val num"><slot>{{ value }}</slot></div>
|
||||
<template v-if="up != null || down != null">
|
||||
<div class="sub">
|
||||
<span><span class="num down">↓ {{ fmt(down) }}</span></span>
|
||||
<span><span class="num up">↑ {{ fmt(up) }}</span></span>
|
||||
</div>
|
||||
<span class="pair"><i class="d" :style="{ flexGrow: down || 0 }" /><i class="u" :style="{ flexGrow: up || 0 }" /></span>
|
||||
</template>
|
||||
<div class="sub muted" v-else-if="sub">{{ sub }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { bytes, rate } from '../format.js'
|
||||
|
||||
const props = defineProps({ label: String, value: [String, Number], sub: String, up: Number, down: Number, isRate: Boolean })
|
||||
const fmt = v => (props.isRate ? rate(v) : bytes(v))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 不做卡片:顶部一条细线分隔,数字本身就是视觉重点 */
|
||||
.stat { padding: 12px 0 4px; border-top: 2px solid var(--ink); min-width: 0; }
|
||||
.lbl { color: var(--muted); font-size: 14px; }
|
||||
.val { font-size: 34px; font-weight: 600; margin: 2px 0 6px; letter-spacing: -.02em; line-height: 1.1; }
|
||||
.sub { display: flex; gap: 16px; font-size: 14.5px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
</style>
|
||||
@@ -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'
|
||||
@@ -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.',
|
||||
},
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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 口速率。',
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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); }
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-h">
|
||||
<router-link to="/apps" class="btn sm">← {{ t('common.back') }}</router-link>
|
||||
<h1 v-if="d">{{ appName(d) }}</h1>
|
||||
<span v-if="d" class="tag"><span class="chip" :style="{ background: catColor(d.category) }" />{{ catName(d) }}</span>
|
||||
<span class="sp" />
|
||||
<RangeTabs />
|
||||
</div>
|
||||
<div v-if="error" class="err">{{ error }}</div>
|
||||
<template v-if="d">
|
||||
<div class="grid g4">
|
||||
<Stat :label="t('range.today')" :value="bytes(d.totals.today.total)" :up="d.totals.today.up" :down="d.totals.today.down" />
|
||||
<Stat :label="t('range.week')" :value="bytes(d.totals.week.total)" :up="d.totals.week.up" :down="d.totals.week.down" />
|
||||
<Stat :label="t('range.month')" :value="bytes(d.totals.month.total)" :up="d.totals.month.up" :down="d.totals.month.down" />
|
||||
<Stat :label="t('apps.usedBy')" :value="d.hosts.length" :sub="t('range.' + ui.range)" />
|
||||
</div>
|
||||
<div class="panel mt">
|
||||
<div class="panel-h">{{ t('overview.trend') }}</div>
|
||||
<div class="panel-b"><Chart :option="trendOpt" height="240px" /></div>
|
||||
</div>
|
||||
<div class="panel mt">
|
||||
<div class="panel-h">{{ t('apps.usedBy') }}</div>
|
||||
<div class="tbl-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>{{ t('common.name') }}</th><th>{{ t('hosts.ip') }}</th><th class="r">{{ t('common.down') }}</th><th class="r">{{ t('common.up') }}</th><th class="r">{{ t('common.total') }}</th><th class="r">{{ t('common.share') }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="h in d.hosts" :key="h.key" class="link" @click="$router.push(`/hosts/${enc(h.key)}`)">
|
||||
<td><span class="nm"><span class="dot" :class="{ on: h.online }" />{{ h.name }}</span></td>
|
||||
<td class="addr">{{ h.ip }}</td>
|
||||
<td class="r num">{{ bytes(h.down) }}</td>
|
||||
<td class="r num">{{ bytes(h.up) }}</td>
|
||||
<td class="r num"><b>{{ bytes(h.total) }}</b></td>
|
||||
<td class="r num muted">{{ pct(h.total / (d.range_total.total || 1)) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!d.hosts.length" class="empty">{{ t('common.empty') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="loading" class="empty">{{ t('common.loading') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import http, { enc } from '../api.js'
|
||||
import { trendOption } from '../charts.js'
|
||||
import Chart from '../components/Chart.vue'
|
||||
import RangeTabs from '../components/RangeTabs.vue'
|
||||
import Stat from '../components/Stat.vue'
|
||||
import { appName, bytes, catColor, catName, pct } from '../format.js'
|
||||
import { usePoll } from '../poll.js'
|
||||
import { ui } from '../state.js'
|
||||
|
||||
const props = defineProps({ id: String })
|
||||
const { t, locale } = useI18n()
|
||||
const d = ref(null)
|
||||
const { loading, error } = usePoll(async () => {
|
||||
d.value = await http.get(`/apps/${enc(props.id)}/detail`, { params: { range: ui.range } })
|
||||
}, 60000, () => [ui.range, props.id])
|
||||
const trendOpt = computed(() => (ui.theme, locale.value, trendOption(d.value.trend, ['today', 'yesterday'].includes(ui.range) ? 'hour' : 'day')))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mt { margin-top: 14px; }
|
||||
.nm { display: inline-flex; align-items: center; gap: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-h">
|
||||
<h1>{{ t('apps.title') }}</h1>
|
||||
<span class="lead" v-if="d">{{ bytes(d.range_total.total) }}</span>
|
||||
<span class="sp" />
|
||||
<RangeTabs />
|
||||
</div>
|
||||
<div v-if="error" class="err">{{ error }}</div>
|
||||
<template v-if="d">
|
||||
<section class="mix">
|
||||
<Spectrum :cats="d.categories" clickable :height="18" :legend-max="30" @pick="pick" />
|
||||
</section>
|
||||
|
||||
<div class="panel mt">
|
||||
<div class="panel-h">
|
||||
{{ cat ? catLabel : t('apps.byApp') }}
|
||||
<button v-if="cat" class="btn sm" @click="pick('')">{{ t('apps.showAll') }}</button>
|
||||
<span class="sp" />
|
||||
<input class="input search" v-model="q" :placeholder="t('common.search')" :aria-label="t('common.search')" />
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr>
|
||||
<th>{{ t('apps.app') }}</th><th>{{ t('common.category') }}</th>
|
||||
<th class="r">{{ t('common.down') }}</th><th class="r">{{ t('common.up') }}</th><th class="r">{{ t('common.total') }}</th>
|
||||
<th class="r">{{ t('common.share') }}</th><th class="r">{{ t('apps.usedBy') }}</th><th class="r">{{ t('common.flows') }}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="a in shown" :key="a.id" class="link" @click="$router.push(`/apps/${enc(a.id)}`)">
|
||||
<td class="nm">{{ appName(a) }}</td>
|
||||
<td><span class="tag"><span class="chip" :style="{ background: catColor(a.category) }" />{{ catName(a) }}</span></td>
|
||||
<td class="r num">{{ bytes(a.down) }}</td>
|
||||
<td class="r num">{{ bytes(a.up) }}</td>
|
||||
<td class="r num"><b>{{ bytes(a.total) }}</b></td>
|
||||
<td class="r share">
|
||||
<span class="num muted">{{ pct(a.total / (d.range_total.total || 1)) }}</span>
|
||||
<span class="pair"><i class="d" :style="{ flexGrow: a.down }" /><i class="u" :style="{ flexGrow: a.up }" /></span>
|
||||
</td>
|
||||
<td class="r num">{{ a.hosts }}</td>
|
||||
<td class="r num muted">{{ a.flows.toLocaleString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!shown.length" class="empty">{{ t('common.empty') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="loading" class="empty">{{ t('common.loading') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import http, { enc } from '../api.js'
|
||||
import RangeTabs from '../components/RangeTabs.vue'
|
||||
import Spectrum from '../components/Spectrum.vue'
|
||||
import { appName, bytes, catColor, catName, pct } from '../format.js'
|
||||
import { usePoll } from '../poll.js'
|
||||
import { ui } from '../state.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const d = ref(null)
|
||||
const q = ref('')
|
||||
const cat = computed(() => route.query.cat || '')
|
||||
const { loading, error } = usePoll(async () => { d.value = await http.get('/apps', { params: { range: ui.range } }) },
|
||||
60000, () => ui.range)
|
||||
|
||||
function pick(c) {
|
||||
router.replace({ query: c && c !== cat.value ? { cat: c } : {} })
|
||||
}
|
||||
|
||||
const shown = computed(() => {
|
||||
const s = q.value.trim().toLowerCase()
|
||||
return d.value.apps.filter(a => (!cat.value || a.category === cat.value) &&
|
||||
(!s || [a.id, a.name, a.name_en].some(v => v && v.toLowerCase().includes(s))))
|
||||
})
|
||||
const catLabel = computed(() => {
|
||||
const c = d.value.categories.find(x => x.id === cat.value)
|
||||
return c ? catName(c) : cat.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mix { padding-bottom: 4px; }
|
||||
.search { width: 220px; height: 32px; }
|
||||
.nm { font-weight: 500; }
|
||||
.share { min-width: 110px; }
|
||||
.share .pair { margin-top: 5px; width: 90px; margin-left: auto; }
|
||||
@media (max-width: 640px) { .search { width: 100%; } }
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-h">
|
||||
<h1>{{ t('flow.title') }}</h1>
|
||||
<span class="sp" />
|
||||
<RangeTabs />
|
||||
</div>
|
||||
<p class="lead muted">{{ t('flow.lead') }}</p>
|
||||
|
||||
<div class="ctl">
|
||||
<label>{{ t('flow.hosts') }}
|
||||
<select class="input sm" v-model.number="nHosts"><option v-for="n in [8, 12, 20, 30]" :key="n" :value="n">{{ n }}</option></select>
|
||||
</label>
|
||||
<label>{{ t('flow.apps') }}
|
||||
<select class="input sm" v-model.number="nApps"><option v-for="n in [10, 16, 25, 40]" :key="n" :value="n">{{ n }}</option></select>
|
||||
</label>
|
||||
<span class="sp" />
|
||||
<span class="faint small">{{ t('flow.hint') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="err">{{ error }}</div>
|
||||
<div class="panel">
|
||||
<div class="panel-b">
|
||||
<Chart v-if="g && g.links.length" :option="opt" :height="height + 'px'" @click="open" />
|
||||
<div v-else class="empty">{{ loading ? t('common.loading') : t('common.empty') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import http, { enc } from '../api.js'
|
||||
import { sankeyOption } from '../charts.js'
|
||||
import Chart from '../components/Chart.vue'
|
||||
import RangeTabs from '../components/RangeTabs.vue'
|
||||
import { usePoll } from '../poll.js'
|
||||
import { ui } from '../state.js'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const g = ref(null)
|
||||
const nHosts = ref(12)
|
||||
const nApps = ref(16)
|
||||
|
||||
const { loading, error } = usePoll(async () => {
|
||||
g.value = await http.get('/flow', { params: { range: ui.range, hosts: nHosts.value, apps: nApps.value } })
|
||||
}, 60000, () => [ui.range, nHosts.value, nApps.value])
|
||||
|
||||
const opt = computed(() => (ui.theme, locale.value, sankeyOption(g.value)))
|
||||
// 高度随两侧最多的节点数增长,保证每个节点和标签都有空间
|
||||
const height = computed(() => {
|
||||
if (!g.value) return 480
|
||||
const hosts = g.value.nodes.filter(n => n.kind === 'host').length
|
||||
const apps = g.value.nodes.filter(n => n.kind === 'app').length
|
||||
return Math.max(420, Math.max(hosts, apps) * 30 + 40)
|
||||
})
|
||||
|
||||
function open(p) {
|
||||
const n = p.data && p.data.raw
|
||||
if (!n) return
|
||||
if (n.kind === 'host' && n.key) router.push(`/hosts/${enc(n.key)}`)
|
||||
else if (n.kind === 'app' && n.app) router.push(`/apps/${enc(n.app)}`)
|
||||
else if (n.kind === 'category') router.push({ path: '/apps', query: { cat: n.category } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lead { margin: -12px 0 18px; max-width: 70ch; }
|
||||
.ctl { display: flex; align-items: center; gap: 20px; margin-bottom: 12px; flex-wrap: wrap; font-size: 14px; color: var(--muted); }
|
||||
.ctl label { display: flex; align-items: center; gap: 8px; }
|
||||
.input.sm { height: 30px; width: auto; padding: 0 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-h">
|
||||
<router-link to="/hosts" class="btn sm">← {{ t('common.back') }}</router-link>
|
||||
<h1 v-if="d"><span class="dot" :class="{ on: d.online }" /> {{ d.name }}</h1>
|
||||
<span class="sp" />
|
||||
<RangeTabs />
|
||||
</div>
|
||||
<div v-if="error" class="err">{{ error }}</div>
|
||||
|
||||
<template v-if="d">
|
||||
<div class="panel info">
|
||||
<div><span class="k">{{ t('hosts.ip') }}</span><span class="addr">{{ d.ip || '-' }}</span></div>
|
||||
<div><span class="k">{{ t('hosts.mac') }}</span><span class="addr">{{ d.mac || '-' }}</span></div>
|
||||
<div><span class="k">{{ t('hosts.hostname') }}</span>{{ d.hostname || '-' }}</div>
|
||||
<div><span class="k">{{ t('hosts.firstSeen') }}</span>{{ datetime(d.first_seen) }}</div>
|
||||
<div><span class="k">{{ t('hosts.lastSeen') }}</span>{{ ago(d.last_seen) }}</div>
|
||||
<div><span class="k">{{ t('common.rate') }}</span><span class="num"><span class="down">↓{{ rate(d.rate_down) }}</span> <span class="up">↑{{ rate(d.rate_up) }}</span></span></div>
|
||||
<div class="alias">
|
||||
<span class="k">{{ t('hosts.alias') }}</span>
|
||||
<input class="input" v-model="alias" :placeholder="t('hosts.aliasPlaceholder')" @keyup.enter="saveAlias" />
|
||||
<button class="btn sm" @click="saveAlias" :disabled="alias === (d.alias || '')">{{ saved ? t('common.saved') : t('common.save') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid mt" :class="extraRange ? 'g4' : 'g3'">
|
||||
<Stat :label="t('range.today')" :value="bytes(d.totals.today.total)" :up="d.totals.today.up" :down="d.totals.today.down" />
|
||||
<Stat :label="t('range.week')" :value="bytes(d.totals.week.total)" :up="d.totals.week.up" :down="d.totals.week.down" />
|
||||
<Stat :label="t('range.month')" :value="bytes(d.totals.month.total)" :up="d.totals.month.up" :down="d.totals.month.down" />
|
||||
<Stat v-if="extraRange" :label="t('range.' + ui.range)" :value="bytes(d.range_total.total)" :up="d.range_total.up" :down="d.range_total.down" />
|
||||
</div>
|
||||
|
||||
<section class="mix mt">
|
||||
<h2>{{ t('overview.mix') }}</h2>
|
||||
<Spectrum :cats="d.categories" />
|
||||
</section>
|
||||
|
||||
<div class="panel mt">
|
||||
<div class="panel-h">{{ t('overview.trend') }}</div>
|
||||
<div class="panel-b"><Chart :option="trendOpt" height="240px" /></div>
|
||||
</div>
|
||||
|
||||
<div class="grid g2 mt">
|
||||
<div class="panel">
|
||||
<div class="panel-h">{{ t('nav.apps') }}</div>
|
||||
<div class="tbl-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>{{ t('apps.app') }}</th><th>{{ t('common.category') }}</th><th class="r">{{ t('common.down') }}</th><th class="r">{{ t('common.up') }}</th><th class="r">{{ t('common.share') }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="a in d.apps" :key="a.id" class="link" @click="$router.push(`/apps/${enc(a.id)}`)">
|
||||
<td>{{ appName(a) }}</td>
|
||||
<td><span class="tag"><span class="chip" :style="{ background: catColor(a.category) }" />{{ catName(a) }}</span></td>
|
||||
<td class="r num">{{ bytes(a.down) }}</td>
|
||||
<td class="r num">{{ bytes(a.up) }}</td>
|
||||
<td class="r num muted">{{ pct(a.total / (d.range_total.total || 1)) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!d.apps.length" class="empty">{{ t('common.empty') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h">{{ t('hosts.domains') }}</div>
|
||||
<div class="tbl-wrap dom">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>{{ t('hosts.domain') }}</th><th>{{ t('apps.app') }}</th><th class="r">{{ t('common.total') }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="x in d.domains" :key="x.domain">
|
||||
<td class="dn" :title="x.domain">{{ x.domain }}</td>
|
||||
<td><span class="tag"><span class="chip" :style="{ background: catColor(x.app.category) }" />{{ appName(x.app) }}</span></td>
|
||||
<td class="r num">{{ bytes(x.bytes) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!d.domains.length" class="empty">{{ t('common.empty') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="loading" class="empty">{{ t('common.loading') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import http, { enc } from '../api.js'
|
||||
import { trendOption } from '../charts.js'
|
||||
import Chart from '../components/Chart.vue'
|
||||
import RangeTabs from '../components/RangeTabs.vue'
|
||||
import Spectrum from '../components/Spectrum.vue'
|
||||
import Stat from '../components/Stat.vue'
|
||||
import { ago, appName, bytes, catColor, catName, datetime, pct, rate } from '../format.js'
|
||||
import { usePoll } from '../poll.js'
|
||||
import { ui } from '../state.js'
|
||||
|
||||
// 参数不能叫 key(Vue 保留属性,不会作为 prop 传入)
|
||||
const props = defineProps({ hostKey: String })
|
||||
const { t, locale } = useI18n()
|
||||
const d = ref(null)
|
||||
const alias = ref('')
|
||||
const saved = ref(false)
|
||||
|
||||
const { loading, error } = usePoll(async () => {
|
||||
const first = !d.value
|
||||
d.value = await http.get(`/hosts/${enc(props.hostKey)}/detail`, { params: { range: ui.range } })
|
||||
if (first) alias.value = d.value.alias || ''
|
||||
}, 30000, () => [ui.range, props.hostKey])
|
||||
|
||||
// 今日/本周/本月 已有固定卡片,只有选其他范围时才多显示一张
|
||||
const extraRange = computed(() => !['today', 'week', 'month'].includes(ui.range))
|
||||
const deps = () => [ui.theme, locale.value]
|
||||
const trendOpt = computed(() => (deps(), trendOption(d.value.trend, ['today', 'yesterday'].includes(ui.range) ? 'hour' : 'day')))
|
||||
|
||||
async function saveAlias() {
|
||||
await http.patch(`/hosts/${enc(props.hostKey)}`, { alias: alias.value })
|
||||
d.value.alias = alias.value || null
|
||||
d.value.name = alias.value || d.value.hostname || d.value.ip
|
||||
saved.value = true
|
||||
setTimeout(() => (saved.value = false), 1500)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mt { margin-top: 14px; }
|
||||
h1 { display: flex; align-items: center; gap: 10px; }
|
||||
.info { padding: 14px 16px; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px 20px; font-size: 13px; }
|
||||
.info > div { display: flex; gap: 10px; align-items: center; min-width: 0; }
|
||||
.k { color: var(--muted); flex: none; min-width: 64px; }
|
||||
.alias { grid-column: 1 / -1; max-width: 520px; }
|
||||
.dom { max-height: 520px; overflow-y: auto; }
|
||||
.mix h2 { font: 600 17px/1.3 var(--cond); margin: 0 0 10px; }
|
||||
.dn { max-width: 320px; overflow: hidden; text-overflow: ellipsis; }
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-h">
|
||||
<h1>{{ t('hosts.title') }}</h1>
|
||||
<span class="muted" v-if="rows.length">{{ t('hosts.count', { n: rows.length, m: onlineN }) }}</span>
|
||||
<span class="sp" />
|
||||
<label class="chk"><input type="checkbox" v-model="onlineOnly" /> {{ t('hosts.onlineOnly') }}</label>
|
||||
<input class="input search" v-model="q" :placeholder="t('hosts.search')" />
|
||||
</div>
|
||||
<div v-if="error" class="err">{{ error }}</div>
|
||||
<div class="panel">
|
||||
<div class="tbl-wrap">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sort" @click="sortBy('name')">{{ t('common.name') }} {{ arrow('name') }}</th>
|
||||
<th class="sort" @click="sortBy('ip')">{{ t('hosts.ip') }} {{ arrow('ip') }}</th>
|
||||
<th>{{ t('hosts.mac') }}</th>
|
||||
<th class="r sort" @click="sortBy('rate')">{{ t('common.rate') }} {{ arrow('rate') }}</th>
|
||||
<th class="r sort" @click="sortBy('today')">{{ t('range.today') }} {{ arrow('today') }}</th>
|
||||
<th class="r sort" @click="sortBy('week')">{{ t('range.week') }} {{ arrow('week') }}</th>
|
||||
<th class="r sort" @click="sortBy('month')">{{ t('range.month') }} {{ arrow('month') }}</th>
|
||||
<th>{{ t('hosts.topApp') }}</th>
|
||||
<th class="sort" @click="sortBy('last')">{{ t('hosts.lastSeen') }} {{ arrow('last') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="h in shown" :key="h.key" class="link" @click="$router.push(`/hosts/${enc(h.key)}`)">
|
||||
<td><span class="nm"><span class="dot" :class="{ on: h.online }" />{{ h.name }}</span>
|
||||
<span class="faint hn" v-if="h.alias && h.hostname">{{ h.hostname }}</span></td>
|
||||
<td class="addr">{{ h.ip }}</td>
|
||||
<td class="addr faint">{{ h.mac || '-' }}</td>
|
||||
<td class="r num small"><span class="down">↓{{ rate(h.rate_down) }}</span><br /><span class="up">↑{{ rate(h.rate_up) }}</span></td>
|
||||
<td class="r num">{{ bytes(h.today.total) }}</td>
|
||||
<td class="r num">{{ bytes(h.week.total) }}</td>
|
||||
<td class="r num">{{ bytes(h.month.total) }}</td>
|
||||
<td><span v-if="h.top_app" class="tag"><span class="chip" :style="{ background: catColor(h.top_app.category) }" />{{ appName(h.top_app) }}</span></td>
|
||||
<td class="muted small">{{ ago(h.last_seen) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!shown.length" class="empty">{{ loading ? t('common.loading') : t('common.empty') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import http, { enc } from '../api.js'
|
||||
import { ago, appName, bytes, catColor, rate } from '../format.js'
|
||||
import { usePoll } from '../poll.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
const rows = ref([])
|
||||
const q = ref('')
|
||||
const onlineOnly = ref(false)
|
||||
const sort = ref({ key: 'today', desc: true })
|
||||
const { loading, error } = usePoll(async () => { rows.value = await http.get('/hosts') }, 30000)
|
||||
|
||||
const onlineN = computed(() => rows.value.filter(h => h.online).length)
|
||||
const ipNum = ip => (ip || '').split('.').reduce((s, x) => s * 256 + (+x || 0), 0)
|
||||
const keyFn = {
|
||||
name: h => (h.name || '').toLowerCase(), ip: h => ipNum(h.ip), rate: h => h.rate_up + h.rate_down,
|
||||
today: h => h.today.total, week: h => h.week.total, month: h => h.month.total, last: h => h.last_seen || 0,
|
||||
}
|
||||
|
||||
const shown = computed(() => {
|
||||
const s = q.value.trim().toLowerCase()
|
||||
let list = rows.value.filter(h => (!onlineOnly.value || h.online) &&
|
||||
(!s || [h.name, h.ip, h.mac, h.hostname, h.alias].some(v => v && v.toLowerCase().includes(s))))
|
||||
const f = keyFn[sort.value.key]
|
||||
const dir = sort.value.desc ? -1 : 1
|
||||
return [...list].sort((a, b) => (f(a) > f(b) ? dir : f(a) < f(b) ? -dir : 0))
|
||||
})
|
||||
|
||||
function sortBy(k) {
|
||||
sort.value = { key: k, desc: sort.value.key === k ? !sort.value.desc : k !== 'name' && k !== 'ip' }
|
||||
}
|
||||
const arrow = k => (sort.value.key === k ? (sort.value.desc ? '↓' : '↑') : '')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search { width: 240px; }
|
||||
.chk { display: flex; gap: 6px; align-items: center; color: var(--muted); font-size: 13px; cursor: pointer; }
|
||||
.nm { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.hn { margin-left: 8px; font-size: 12px; }
|
||||
.small { font-size: 12px; line-height: 1.35; }
|
||||
@media (max-width: 640px) { .search { width: 100%; } }
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<form class="panel box" @submit.prevent="submit">
|
||||
<div class="brand">
|
||||
<Mark :size="34" />
|
||||
<div>
|
||||
<div class="b1">ROS-DPI</div>
|
||||
<div class="muted b2">{{ t('app.subtitle') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>{{ setup ? t('login.setupTitle') : t('login.title') }}</h2>
|
||||
<label>{{ t('login.username') }}<input class="input" v-model="username" autocomplete="username" /></label>
|
||||
<label>{{ t('login.password') }}<input class="input" type="password" v-model="password" :autocomplete="setup ? 'new-password' : 'current-password'" /></label>
|
||||
<label v-if="setup">{{ t('login.password2') }}<input class="input" type="password" v-model="password2" autocomplete="new-password" /></label>
|
||||
<div class="err" v-if="error">{{ error }}</div>
|
||||
<button class="btn primary full" :disabled="busy">{{ setup ? t('login.setup') : t('login.submit') }}</button>
|
||||
<div class="foot">
|
||||
<button type="button" class="btn ghost sm" @click="setLang(locale === 'zh' ? 'en' : 'zh')">{{ t('common.lang') }}</button>
|
||||
<button type="button" class="btn ghost sm" @click="toggleTheme">{{ t('common.theme') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import http, { setAuth } from '../api.js'
|
||||
import Mark from '../components/Mark.vue'
|
||||
import { setLang } from '../i18n/index.js'
|
||||
import { toggleTheme } from '../state.js'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const setup = ref(false)
|
||||
const username = ref('admin')
|
||||
const password = ref('')
|
||||
const password2 = ref('')
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try { setup.value = !(await http.get('/auth/status')).initialized } catch (e) { error.value = e.message }
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
if (setup.value) {
|
||||
if (password.value.length < 6) return (error.value = t('login.tooShort'))
|
||||
if (password.value !== password2.value) return (error.value = t('login.mismatch'))
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const r = await http.post(setup.value ? '/auth/setup' : '/auth/login', { username: username.value, password: password.value })
|
||||
setAuth(r.token, r.username)
|
||||
router.replace(route.query.next || '/')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrap { min-height: 100vh; display: grid; place-items: center; padding: 16px; }
|
||||
.box { width: 100%; max-width: 380px; padding: 32px 28px 22px; display: flex; flex-direction: column; gap: 16px; border-top: 3px solid var(--pair-blue); }
|
||||
.brand { display: flex; gap: 12px; align-items: center; }
|
||||
.b1 { font: 700 24px/1 var(--cond); letter-spacing: .02em; }
|
||||
.b2 { font-size: 12px; }
|
||||
h2 { font: 600 20px/1.2 var(--cond); margin: 10px 0 0; }
|
||||
label { display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: var(--muted); }
|
||||
.full { width: 100%; justify-content: center; height: 38px; }
|
||||
.foot { display: flex; gap: 8px; justify-content: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-h">
|
||||
<h1>{{ t('nav.overview') }}</h1>
|
||||
<span class="lead" v-if="d && d.router.identity">{{ d.router.identity }}</span>
|
||||
<span class="sp" />
|
||||
<RangeTabs />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="err">{{ error }}</div>
|
||||
|
||||
<template v-if="d">
|
||||
<div v-if="!d.totals.month.total" class="panel nodata">
|
||||
<span>{{ t('overview.noData') }}</span>
|
||||
<router-link to="/settings#guide" class="btn sm">{{ t('overview.openGuide') }}</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 首屏:本期流量 + 此刻速率 + 流量构成 -->
|
||||
<section class="hero">
|
||||
<div class="hero-main">
|
||||
<div class="hero-lbl">{{ ui.range === 'today' ? t('overview.heroToday') : t('overview.heroRange', { range: t('range.' + ui.range) }) }}</div>
|
||||
<div class="hero-num num">{{ big[0] }}<span class="unit">{{ big[1] }}</span></div>
|
||||
<div class="hero-sub">
|
||||
<span><b class="num down">{{ bytes(d.range_total.down) }}</b> {{ t('common.down') }}</span>
|
||||
<span><b class="num up">{{ bytes(d.range_total.up) }}</b> {{ t('common.up') }}</span>
|
||||
<span class="pair hero-pair"><i class="d" :style="{ flexGrow: d.range_total.down }" /><i class="u" :style="{ flexGrow: d.range_total.up }" /></span>
|
||||
</div>
|
||||
<dl class="hero-meta">
|
||||
<div v-if="ui.range !== 'today'"><dt>{{ t('range.today') }}</dt><dd class="num">{{ bytes(d.totals.today.total) }}</dd></div>
|
||||
<div v-if="ui.range !== 'week'"><dt>{{ t('overview.weekShort') }}</dt><dd class="num">{{ bytes(d.totals.week.total) }}</dd></div>
|
||||
<div v-if="ui.range !== 'month'"><dt>{{ t('overview.monthShort') }}</dt><dd class="num">{{ bytes(d.totals.month.total) }}</dd></div>
|
||||
<div><dt>{{ t('overview.online') }}</dt><dd class="num">{{ d.online }}<span class="faint"> / {{ d.hosts_total }}</span></dd></div>
|
||||
<div><dt>{{ t('overview.identified') }}</dt><dd class="num">{{ pct(d.identified_ratio, 0) }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="hero-live">
|
||||
<div class="hero-lbl"><span class="dot on pulse" />{{ t('overview.live') }}</div>
|
||||
<div class="live-row"><span class="arrow down">↓</span><span class="num live-v">{{ rate(liveDown) }}</span></div>
|
||||
<div class="live-row"><span class="arrow up">↑</span><span class="num live-v">{{ rate(liveUp) }}</span></div>
|
||||
<div class="faint small">{{ wanFrom }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mix">
|
||||
<div class="mix-h"><h2>{{ t('overview.mix') }}</h2></div>
|
||||
<Spectrum :cats="d.categories" clickable :height="18" @pick="c => $router.push({ path: '/apps', query: { cat: c } })" />
|
||||
</section>
|
||||
|
||||
<div class="grid g-2-1 mt">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>{{ t('overview.trend') }}</h2><span class="faint small">{{ t('range.' + ui.range) }}</span></div>
|
||||
<div class="panel-b"><Chart :option="trendOpt" height="260px" /></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>{{ t('overview.realtime') }}</h2></div>
|
||||
<div class="panel-b"><Chart :option="rtOpt" height="260px" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid g2 mt">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>{{ t('overview.topApps') }}</h2><span class="sp" /><router-link to="/apps" class="small link">{{ t('common.allApps') }}</router-link></div>
|
||||
<div class="panel-b"><BarList :items="appItems" /></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>{{ t('overview.topHosts') }}</h2><span class="sp" /><router-link to="/hosts" class="small link">{{ t('common.allHosts') }}</router-link></div>
|
||||
<div class="panel-b"><BarList :items="hostItems" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="router" v-if="d.router.enabled">
|
||||
<span class="dot" :class="d.router.ok ? 'on' : 'bad'" />
|
||||
<b>{{ d.router.identity || 'RouterOS' }}</b>
|
||||
<span class="muted">{{ d.router.board }}</span>
|
||||
<span class="muted">RouterOS {{ d.router.version }}</span>
|
||||
<span v-if="d.router.cpu_load != null">{{ t('overview.cpu') }} <b class="num">{{ d.router.cpu_load }}%</b></span>
|
||||
<span v-if="d.router.mem_total">{{ t('overview.memory') }} <b class="num">{{ pct(1 - d.router.mem_free / d.router.mem_total, 0) }}</b></span>
|
||||
<span v-if="d.router.uptime">{{ t('overview.uptime') }} <b class="num">{{ d.router.uptime }}</b></span>
|
||||
<span v-if="!d.router.ok" class="err">{{ d.router.error }}</span>
|
||||
</footer>
|
||||
</template>
|
||||
<div v-else-if="loading" class="empty">{{ t('common.loading') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import http, { enc } from '../api.js'
|
||||
import { trendOption } from '../charts.js'
|
||||
import BarList from '../components/BarList.vue'
|
||||
import Chart from '../components/Chart.vue'
|
||||
import RangeTabs from '../components/RangeTabs.vue'
|
||||
import Spectrum from '../components/Spectrum.vue'
|
||||
import { appName, bytes, catColor, catName, pct, rate } from '../format.js'
|
||||
import { usePoll } from '../poll.js'
|
||||
import { ui } from '../state.js'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const d = ref(null)
|
||||
const { loading, error } = usePoll(async () => { d.value = await http.get('/overview', { params: { range: ui.range } }) },
|
||||
30000, () => ui.range)
|
||||
|
||||
const useWan = computed(() => d.value.router.enabled && d.value.router.ok)
|
||||
const liveUp = computed(() => (useWan.value ? d.value.rate.wan_up : d.value.rate.flow_up))
|
||||
const liveDown = computed(() => (useWan.value ? d.value.rate.wan_down : d.value.rate.flow_down))
|
||||
const wanFrom = computed(() => (useWan.value && d.value.router.wan_interface
|
||||
? t('overview.viaWan', { name: d.value.router.wan_interface }) : t('overview.viaFlow')))
|
||||
// 大数字拆成 数值 + 单位,单位单独排小号
|
||||
const big = computed(() => {
|
||||
const [v, u] = bytes(d.value.range_total.total).split(' ')
|
||||
return [v, u]
|
||||
})
|
||||
|
||||
const gran = computed(() => (['today', 'yesterday'].includes(ui.range) ? 'hour' : 'day'))
|
||||
const deps = () => [ui.theme, locale.value]
|
||||
const trendOpt = computed(() => (deps(), trendOption(d.value.trend, gran.value)))
|
||||
const rtOpt = computed(() => (deps(), trendOption(d.value.realtime, 'minute', true)))
|
||||
|
||||
const appItems = computed(() => d.value.top_apps.map(a => ({
|
||||
key: a.id, label: appName(a), sub: catName(a), value: a.total, up: a.up, down: a.down,
|
||||
color: catColor(a.category), to: `/apps/${enc(a.id)}`,
|
||||
})))
|
||||
const hostItems = computed(() => d.value.top_hosts.map(h => ({
|
||||
key: h.key, label: h.name, sub: h.name !== h.ip ? h.ip : '', value: h.total, up: h.up, down: h.down,
|
||||
online: h.online, to: `/hosts/${enc(h.key)}`,
|
||||
})))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nodata { padding: 14px 18px; margin-bottom: 20px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; border-left: 3px solid var(--warn); }
|
||||
|
||||
.hero { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 24px 48px; align-items: end; padding: 4px 0 26px; }
|
||||
.hero-lbl { color: var(--muted); font-size: 14.5px; display: flex; align-items: center; gap: 8px; }
|
||||
.hero-num { font-size: clamp(64px, 9vw, 112px); font-weight: 600; line-height: .92; letter-spacing: -.035em; margin: 10px 0 14px; }
|
||||
.unit { font-size: .36em; font-weight: 500; letter-spacing: 0; margin-left: .18em; color: var(--muted); }
|
||||
.hero-sub { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; font-size: 14.5px; color: var(--muted); }
|
||||
.hero-sub b { font-size: 20px; font-weight: 600; margin-right: 2px; }
|
||||
.hero-pair { width: 160px; height: 6px; border-radius: 3px; }
|
||||
.hero-meta { display: flex; gap: 10px 36px; flex-wrap: wrap; margin: 22px 0 0; }
|
||||
.hero-meta div { display: flex; flex-direction: column; }
|
||||
.hero-meta dt { color: var(--muted); font-size: 13px; }
|
||||
.hero-meta dd { margin: 0; font-size: 22px; font-weight: 600; }
|
||||
|
||||
.hero-live { min-width: 240px; padding-left: 28px; border-left: 1px solid var(--line); }
|
||||
.live-row { display: flex; align-items: baseline; gap: 10px; margin-top: 6px; }
|
||||
.arrow { font: 600 22px/1 var(--cond); width: 16px; }
|
||||
.live-v { font-size: 34px; font-weight: 600; letter-spacing: -.01em; }
|
||||
.hero-live .small { margin-top: 10px; }
|
||||
|
||||
/* 唯一的一个动效:在线指示点的呼吸,提示数据是实时的 */
|
||||
.pulse { animation: pulse 2.4s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: .3; } }
|
||||
|
||||
.mix { padding: 18px 0 8px; border-top: 1px solid var(--line); }
|
||||
.mix-h h2 { font: 600 17px/1.3 var(--cond); margin: 0 0 12px; }
|
||||
|
||||
.link { color: var(--pair-blue); }
|
||||
.link:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
.router { margin-top: 28px; padding-top: 16px; border-top: 1px solid var(--line); display: flex; gap: 8px 22px; align-items: center; flex-wrap: wrap; font-size: 14px; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
.hero-live { border-left: 0; padding-left: 0; border-top: 1px solid var(--line); padding-top: 16px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-h"><h1>{{ t('rules.title') }}</h1></div>
|
||||
<p class="muted intro">{{ t('rules.intro') }}</p>
|
||||
|
||||
<div class="grid g2">
|
||||
<div class="panel">
|
||||
<div class="panel-h">{{ t('common.add') }}</div>
|
||||
<form class="panel-b form" @submit.prevent="add">
|
||||
<div class="seg">
|
||||
<button type="button" v-for="k in ['domain', 'cidr', 'port']" :key="k" :class="{ on: f.kind === k }" @click="f.kind = k">{{ t('rules.kinds.' + k) }}</button>
|
||||
</div>
|
||||
<input class="input" v-model="f.pattern" :placeholder="t('rules.patternHint.' + f.kind)" />
|
||||
<div class="seg">
|
||||
<button type="button" :class="{ on: !newApp }" @click="newApp = false">{{ t('rules.existingApp') }}</button>
|
||||
<button type="button" :class="{ on: newApp }" @click="newApp = true">{{ t('rules.newApp') }}</button>
|
||||
</div>
|
||||
<select v-if="!newApp" class="input" v-model="f.app_id">
|
||||
<optgroup v-for="c in meta.categories" :key="c.id" :label="catName(c)">
|
||||
<option v-for="a in meta.apps.filter(x => x.category === c.id)" :key="a.id" :value="a.id">{{ appName(a) }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<template v-else>
|
||||
<input class="input" v-model="f.app_id" :placeholder="t('rules.appId')" />
|
||||
<input class="input" v-model="f.app_name" :placeholder="t('rules.appName')" />
|
||||
<select class="input" v-model="f.category">
|
||||
<option v-for="c in meta.categories" :key="c.id" :value="c.id">{{ catName(c) }}</option>
|
||||
</select>
|
||||
</template>
|
||||
<div class="err" v-if="formErr">{{ formErr }}</div>
|
||||
<div><button class="btn primary" :disabled="!f.pattern || !f.app_id">{{ t('common.add') }}</button></div>
|
||||
</form>
|
||||
<div class="panel-b test">
|
||||
<input class="input" v-model="testDomain" :placeholder="t('rules.test')" @keyup.enter="test" />
|
||||
<button class="btn" @click="test">{{ t('rules.test') }}</button>
|
||||
<span v-if="testRes" class="muted">{{ testRes.app ? t('rules.testResult', { app: appName(testRes.app) }) : t('rules.testNone') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h">{{ t('rules.custom') }} <span class="faint">{{ rules.length }}</span></div>
|
||||
<div class="tbl-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>{{ t('rules.kind') }}</th><th>{{ t('rules.pattern') }}</th><th>{{ t('rules.target') }}</th><th /></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in rules" :key="r.id">
|
||||
<td><span class="tag">{{ t('rules.kinds.' + r.kind) }}</span></td>
|
||||
<td class="addr">{{ r.pattern }}</td>
|
||||
<td>{{ appLabel(r.app_id) }}</td>
|
||||
<td class="r"><button class="btn sm danger" @click="del(r)">{{ t('common.delete') }}</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!rules.length" class="empty">{{ t('common.empty') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel mt">
|
||||
<div class="panel-h">{{ t('rules.unknown') }}<span class="sp" /><RangeTabs :list="['today', 'week', 'month']" /></div>
|
||||
<p class="panel-b muted hint">{{ t('rules.unknownHint') }}</p>
|
||||
<div class="tbl-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>{{ t('hosts.domain') }}</th><th class="r">{{ t('rules.bytes') }}</th><th class="r">{{ t('common.hosts') }}</th><th /></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="u in unknown" :key="u.domain">
|
||||
<td>{{ u.domain }}</td>
|
||||
<td class="r num">{{ bytes(u.bytes) }}</td>
|
||||
<td class="r num">{{ u.hosts }}</td>
|
||||
<td class="r"><button class="btn sm" @click="classify(u.domain)">{{ t('rules.classify') }}</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!unknown.length" class="empty">{{ t('common.empty') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import http from '../api.js'
|
||||
import RangeTabs from '../components/RangeTabs.vue'
|
||||
import { appName, bytes, catName } from '../format.js'
|
||||
import { ui } from '../state.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
const meta = ref({ categories: [], apps: [] })
|
||||
const rules = ref([])
|
||||
const unknown = ref([])
|
||||
const newApp = ref(false)
|
||||
const f = reactive({ kind: 'domain', pattern: '', app_id: '', app_name: '', category: 'web' })
|
||||
const formErr = ref('')
|
||||
const testDomain = ref('')
|
||||
const testRes = ref(null)
|
||||
|
||||
const unknownRange = () => (['today', 'week', 'month'].includes(ui.range) ? ui.range : 'week')
|
||||
|
||||
async function load() {
|
||||
;[meta.value, rules.value] = await Promise.all([http.get('/meta'), http.get('/rules')])
|
||||
}
|
||||
async function loadUnknown() {
|
||||
unknown.value = await http.get('/rules/unknown-domains', { params: { range: unknownRange() } })
|
||||
}
|
||||
onMounted(() => { load(); loadUnknown() })
|
||||
watch(() => ui.range, loadUnknown)
|
||||
|
||||
function appLabel(id) {
|
||||
const a = meta.value.apps.find(x => x.id === id)
|
||||
return a ? appName(a) : id
|
||||
}
|
||||
|
||||
// 取注册域名(简单处理:最后两段;.com.cn 这类取三段)
|
||||
function baseDomain(d) {
|
||||
const p = d.split('.')
|
||||
const n = /^(com|net|org|gov|edu)\.[a-z]{2}$/.test(p.slice(-2).join('.')) ? 3 : 2
|
||||
return p.slice(-n).join('.')
|
||||
}
|
||||
|
||||
function classify(domain) {
|
||||
f.kind = 'domain'
|
||||
f.pattern = baseDomain(domain)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
async function add() {
|
||||
formErr.value = ''
|
||||
try {
|
||||
await http.post('/rules', { ...f, app_name: newApp.value ? f.app_name : null, category: newApp.value ? f.category : null })
|
||||
f.pattern = ''
|
||||
await load()
|
||||
await loadUnknown()
|
||||
} catch (e) { formErr.value = e.message }
|
||||
}
|
||||
|
||||
async function del(r) {
|
||||
if (!confirm(t('common.confirmDelete'))) return
|
||||
await http.delete(`/rules/${r.id}`)
|
||||
await load()
|
||||
}
|
||||
|
||||
async function test() {
|
||||
if (testDomain.value.trim()) testRes.value = await http.get('/rules/test', { params: { domain: testDomain.value.trim() } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.intro { margin: -6px 0 14px; }
|
||||
.mt { margin-top: 14px; }
|
||||
.form { display: flex; flex-direction: column; gap: 10px; }
|
||||
.test { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border-top: 1px solid var(--line); }
|
||||
.test .input { flex: 1; min-width: 160px; }
|
||||
.hint { margin: 0; padding-bottom: 4px; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-h"><h1>{{ t('settings.title') }}</h1><span class="sp" /><span class="faint" v-if="sys">{{ t('settings.version') }} {{ sys.version }}</span></div>
|
||||
|
||||
<div class="panel" v-if="sys">
|
||||
<div class="panel-h">{{ t('settings.status') }}<span class="sp" /><button class="btn sm" @click="loadSys">{{ t('common.refresh') }}</button></div>
|
||||
<div class="panel-b grid g3">
|
||||
<div class="st">
|
||||
<div class="st-h"><span class="dot" :class="sys.collector.flow_ok ? 'on' : 'warn'" />{{ t('settings.collector') }}
|
||||
<span class="faint">udp/{{ s?.ports.ipfix }}</span></div>
|
||||
<div class="kv"><span>{{ t('settings.flows') }}</span><b class="num">{{ sys.collector.flows.toLocaleString() }}</b></div>
|
||||
<div class="kv"><span>{{ t('settings.dropped') }}</span><span class="num">{{ sys.collector.flows_dropped.toLocaleString() }}</span></div>
|
||||
<div class="kv"><span>{{ t('settings.exporters') }}</span><span class="num">{{ Object.keys(sys.collector.exporters).join(', ') || '-' }}</span></div>
|
||||
<div class="kv"><span>{{ t('settings.last') }}</span><span>{{ ago(sys.collector.last_flow) }}</span></div>
|
||||
</div>
|
||||
<div class="st">
|
||||
<div class="st-h"><span class="dot" :class="sys.dns.ok ? 'on' : 'warn'" />{{ t('settings.dns') }}
|
||||
<span class="faint">udp/{{ s?.ports.tzsp }}</span></div>
|
||||
<div class="kv"><span>{{ t('settings.dnsAnswers') }}</span><b class="num">{{ sys.dns.answers.toLocaleString() }}</b></div>
|
||||
<div class="kv"><span>{{ t('settings.dnsEntries') }}</span><span class="num">{{ sys.dns.entries.toLocaleString() }}</span></div>
|
||||
<div class="kv"><span>{{ t('settings.last') }}</span><span>{{ ago(sys.dns.last) }}</span></div>
|
||||
</div>
|
||||
<div class="st">
|
||||
<div class="st-h"><span class="dot" :class="!sys.router.enabled ? '' : sys.router.ok ? 'on' : 'bad'" />{{ t('settings.ros') }}
|
||||
<button v-if="sys.router.enabled" class="btn sm" @click="testRos">{{ t('settings.test') }}</button></div>
|
||||
<template v-if="sys.router.enabled">
|
||||
<div class="kv"><span>URL</span><span class="num">{{ s?.ros_url }}</span></div>
|
||||
<div class="kv"><span>{{ t('login.username') }}</span><span>{{ s?.ros_user }}</span></div>
|
||||
<div class="kv" v-if="sys.router.identity"><span>Identity</span><span>{{ sys.router.identity }} · {{ sys.router.version }}</span></div>
|
||||
<div class="kv"><span>{{ t('settings.last') }}</span><span>{{ ago(sys.router.last_poll) }}</span></div>
|
||||
<div class="err" v-if="sys.router.error">{{ sys.router.error }}</div>
|
||||
</template>
|
||||
<div v-else class="muted small">{{ t('settings.rosHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid g2 mt" v-if="s">
|
||||
<div class="panel">
|
||||
<div class="panel-h">{{ t('settings.network') }}</div>
|
||||
<div class="panel-b form">
|
||||
<label>{{ t('settings.lan') }}</label>
|
||||
<textarea class="input" rows="5" v-model="lan" />
|
||||
<div class="faint small">{{ t('settings.lanHint') }}</div>
|
||||
<div v-if="s.lan_auto.length" class="small"><span class="muted">{{ t('settings.lanAuto') }}:</span>
|
||||
<span class="num faint">{{ s.lan_auto.join(', ') }}</span></div>
|
||||
<label>{{ t('settings.wan') }}</label>
|
||||
<select class="input" v-model="wan">
|
||||
<option value="">{{ t('settings.wanAuto') }}</option>
|
||||
<option v-for="i in s.interfaces" :key="i" :value="i">{{ i }}</option>
|
||||
<option v-if="wan && !s.interfaces.includes(wan)" :value="wan">{{ wan }}</option>
|
||||
</select>
|
||||
<div class="faint small">{{ t('settings.wanHint') }} <span v-if="s.wan_detected">{{ t('settings.wanDetected', { name: s.wan_detected }) }}</span></div>
|
||||
<div class="err" v-if="saveErr">{{ saveErr }}</div>
|
||||
<div><button class="btn primary" @click="save">{{ saved ? t('common.saved') : t('common.save') }}</button></div>
|
||||
<div class="faint small">{{ t('settings.retention') }}:{{ t('settings.retentionText', s.retention) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-h">{{ t('settings.password') }}</div>
|
||||
<form class="panel-b form" @submit.prevent="changePw">
|
||||
<input type="text" autocomplete="username" :value="auth.username" hidden />
|
||||
<input class="input" type="password" v-model="pw.old" :placeholder="t('settings.oldPw')" autocomplete="current-password" />
|
||||
<input class="input" type="password" v-model="pw.new" :placeholder="t('settings.newPw')" autocomplete="new-password" />
|
||||
<div class="err" v-if="pwErr">{{ pwErr }}</div>
|
||||
<div><button class="btn" :disabled="!pw.old || pw.new.length < 6">{{ pwOk ? t('common.saved') : t('common.save') }}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel mt" id="guide">
|
||||
<div class="panel-h">{{ t('settings.guide') }}<span class="sp" /><button class="btn sm" @click="copy">{{ copied ? t('settings.copied') : t('settings.copy') }}</button></div>
|
||||
<div class="panel-b">
|
||||
<p class="muted guide">{{ t('settings.guideIntro', { ip: '<HOST_IP>' }) }}</p>
|
||||
<div class="ipin"><span class="muted small">HOST_IP</span><input class="input num" v-model="hostIp" /></div>
|
||||
<pre class="code">{{ guide }}</pre>
|
||||
<p class="faint small">{{ t('settings.guideNote') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import http, { auth } from '../api.js'
|
||||
import { ago } from '../format.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
const s = ref(null)
|
||||
const sys = ref(null)
|
||||
const lan = ref('')
|
||||
const wan = ref('')
|
||||
const saved = ref(false)
|
||||
const saveErr = ref('')
|
||||
const pw = reactive({ old: '', new: '' })
|
||||
const pwErr = ref('')
|
||||
const pwOk = ref(false)
|
||||
const copied = ref(false)
|
||||
const hostIp = ref(location.hostname)
|
||||
|
||||
async function loadSys() { sys.value = await http.get('/system') }
|
||||
onMounted(async () => {
|
||||
s.value = await http.get('/settings')
|
||||
lan.value = s.value.lan_cidrs.split(',').join('\n')
|
||||
wan.value = s.value.wan_interface
|
||||
loadSys()
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saveErr.value = ''
|
||||
try {
|
||||
s.value = await http.put('/settings', { lan_cidrs: lan.value, wan_interface: wan.value })
|
||||
saved.value = true
|
||||
setTimeout(() => (saved.value = false), 1500)
|
||||
} catch (e) { saveErr.value = e.message }
|
||||
}
|
||||
|
||||
async function testRos() {
|
||||
try { sys.value.router = await http.post('/ros/test') } catch (e) { sys.value.router.error = e.message }
|
||||
}
|
||||
|
||||
async function changePw() {
|
||||
pwErr.value = ''
|
||||
try {
|
||||
await http.post('/auth/password', { old_password: pw.old, new_password: pw.new })
|
||||
pw.old = pw.new = ''
|
||||
pwOk.value = true
|
||||
setTimeout(() => (pwOk.value = false), 1500)
|
||||
} catch (e) { pwErr.value = e.message }
|
||||
}
|
||||
|
||||
const guide = computed(() => {
|
||||
const ip = hostIp.value || '<HOST_IP>'
|
||||
const p = s.value?.ports || { ipfix: 2055, tzsp: 37008 }
|
||||
return `# 1. 流量采集(IPFIX)
|
||||
/ip traffic-flow set enabled=yes interfaces=all active-flow-timeout=1m inactive-flow-timeout=15s
|
||||
/ip traffic-flow target add dst-address=${ip} port=${p.ipfix} version=ipfix
|
||||
|
||||
# 2. DNS 嗅探(应用识别):路由器自身 DNS 的应答 + 终端直连外部 DNS 的应答
|
||||
/ip firewall mangle add chain=output protocol=udp src-port=53 action=sniff-tzsp sniff-target=${ip} sniff-target-port=${p.tzsp} comment=ROSDPI-DNS
|
||||
/ip firewall mangle add chain=forward protocol=udp src-port=53 action=sniff-tzsp sniff-target=${ip} sniff-target-port=${p.tzsp} comment=ROSDPI-DNS
|
||||
|
||||
# 3. 只读 API 账号(可选,用于主机名、WAN 速率)
|
||||
/user group add name=rosdpi policy=read,rest-api,api,!write,!policy,!sensitive
|
||||
/user add name=rosdpi group=rosdpi password=<强密码> address=${ip}`
|
||||
})
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(guide.value)
|
||||
copied.value = true
|
||||
setTimeout(() => (copied.value = false), 1500)
|
||||
} catch (e) { /* 非 https 下剪贴板不可用 */ }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mt { margin-top: 14px; }
|
||||
.small { font-size: 12.5px; }
|
||||
.st { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
|
||||
.st-h { display: flex; align-items: center; gap: 8px; font-weight: 600; margin-bottom: 4px; }
|
||||
.kv { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); }
|
||||
.kv > :last-child { color: var(--text); text-align: right; overflow: hidden; text-overflow: ellipsis; }
|
||||
.form { display: flex; flex-direction: column; gap: 8px; }
|
||||
.form label { font-size: 13px; color: var(--muted); margin-top: 4px; }
|
||||
.guide { margin: 0 0 10px; }
|
||||
.ipin { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; max-width: 320px; }
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: { '/api': 'http://127.0.0.1:8080' },
|
||||
},
|
||||
build: { chunkSizeWarningLimit: 1200 },
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
"""向本机 ROS-DPI 发送模拟的 DNS 应答(TZSP)和 IPFIX 流量,用于开发调试和演示。
|
||||
|
||||
用法:python scripts/simulate.py [--host 127.0.0.1] [--minutes 5] [--hosts 14]
|
||||
需在 backend/.venv 环境下运行(借用 tests/packets.py 的构包函数)。
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend", "tests"))
|
||||
from packets import ipfix, tzsp_dns # noqa: E402
|
||||
|
||||
# (域名, 典型下行 MB/分钟, 上行比例)
|
||||
SITES = [
|
||||
("v26.douyinvod.com", 60, .03), ("upos-sz-mirrorcos.bilivideo.com", 80, .02), ("api.bilibili.com", 2, .2),
|
||||
("szshort.weixin.qq.com", 3, .4), ("mmbiz.qpic.cn", 6, .05), ("rr3---sn.googlevideo.com", 90, .02),
|
||||
("steamcdn-a.akamaihd.net.steamcontent.com", 120, .01), ("music.163.com", 4, .05),
|
||||
("chatgpt.com", 1.5, .3), ("claude.ai", 1.2, .35), ("github.com", 3, .1), ("swcdn.apple.com", 70, .01),
|
||||
("dl.delivery.mp.microsoft.com", 50, .01), ("pan.baidu.com", 30, .3), ("www.taobao.com", 5, .08),
|
||||
("img.alicdn.com", 8, .03), ("www.zhihu.com", 3, .08), ("xiaohongshu.com", 9, .05),
|
||||
("cdn.example-unknown.net", 6, .1), ("static.some-cdn.io", 4, .1), ("io.mi.com", .2, .5),
|
||||
("tuyacn.com", .1, .5), ("www.netflix.com", 2, .05), ("ipv4-c001.nflxvideo.net", 70, .01),
|
||||
]
|
||||
DEVICES = ["iPhone", "MacBook", "iPad", "小米电视", "Win 台式机", "Switch", "安卓手机", "NAS", "摄像头", "扫地机"]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", default="127.0.0.1")
|
||||
ap.add_argument("--ipfix-port", type=int, default=2055)
|
||||
ap.add_argument("--tzsp-port", type=int, default=37008)
|
||||
ap.add_argument("--minutes", type=float, default=3)
|
||||
ap.add_argument("--hosts", type=int, default=14)
|
||||
ap.add_argument("--fast", action="store_true", help="不等待,一次性发完")
|
||||
a = ap.parse_args()
|
||||
|
||||
rnd = random.Random(42)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
ips = {d: f"203.0.113.{i + 10}" for i, (d, *_x) in enumerate(SITES)}
|
||||
hosts = [(f"192.168.88.{100 + i}", f"AA:BB:CC:00:00:{i:02X}") for i in range(a.hosts)]
|
||||
# 每台终端偏好几个站点
|
||||
prefs = {ip: rnd.sample(SITES, k=rnd.randint(3, 8)) for ip, _ in hosts}
|
||||
|
||||
for d, ip in ips.items():
|
||||
sock.sendto(tzsp_dns(hosts[0][0], d, [ip]), (a.host, a.tzsp_port))
|
||||
print(f"已发送 {len(ips)} 条 DNS 应答")
|
||||
|
||||
end = time.time() + a.minutes * 60
|
||||
rounds = 0
|
||||
while True:
|
||||
flows = []
|
||||
for ip, mac in hosts:
|
||||
for d, mb, up_ratio in prefs[ip]:
|
||||
down = int(mb * 1024 * 1024 / 6 * rnd.uniform(.3, 1.6)) # 10 秒一轮
|
||||
up = int(down * up_ratio)
|
||||
port = 443
|
||||
flows.append(dict(src=ip, dst=ips[d], sport=rnd.randint(30000, 60000), dport=port, proto=6,
|
||||
nbytes=up, mac=mac))
|
||||
flows.append(dict(src=ips[d], dst=ip, sport=port, dport=rnd.randint(30000, 60000), proto=6,
|
||||
nbytes=down))
|
||||
for i in range(0, len(flows), 20):
|
||||
sock.sendto(ipfix(flows[i:i + 20], seq=rounds), (a.host, a.ipfix_port))
|
||||
rounds += 1
|
||||
if a.fast and rounds >= a.minutes * 6 or not a.fast and time.time() > end:
|
||||
break
|
||||
if not a.fast:
|
||||
time.sleep(10)
|
||||
print(f"已发送 {rounds} 轮流量,共 {len(hosts)} 台终端")
|
||||
print("终端建议备注名:", ", ".join(DEVICES))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user