Files
ros-dpi/backend/app/routers/admin.py
T
Su YijunandClaude Opus 5.5 28135807bd feat: debug3 应用三级分类、图表重做、终端按 MAC/IP 命名(v0.1.4)
- 应用分类参照 Panabit 改为 应用大类 → 小类 → 应用:10 个大类、41 个小类、277 个应用;
  CNAME 指向 CDN 时归到 CDN 厂商;ICMP / GRE / ESP 按协议识别;已发布的应用和分类 ID 全部保留
- 图表重做:流量构成双层环图 + 排行表、按应用大类堆叠的趋势(可切换上下行)、
  带名次和占比的 Top 排行、桑基图中间列改为应用大类;配色按大类固定并通过色盲校验
- 终端命名:按 MAC 或按 IP,新增命名管理,可提前给未上线设备命名,旧备注名自动迁移
- DNS 嗅探卡片说明作用并显示应答来源;补充内网自建 DNS(AdGuard/Pi-hole)的说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:44:17 +08:00

203 lines
7.4 KiB
Python

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 import database
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 {
"groups": [{"id": k, **v} for k, v in c.groups.items()],
"categories": [{"id": k, **v} for k, v in c.categories.items()],
"apps": sorted(({"id": a.id, "name": a.name, "name_en": a.name_en, "category": a.category,
"custom": a.custom} for a in c.apps.values()), key=lambda a: a["id"]),
}
# ---------- 自定义识别规则 ----------
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,
"database": _db_info(),
}
def _db_info():
eng = database.engine
if eng is None:
return None
u = eng.url
return {"source": "env" if get_settings().database_url else "wizard", "dialect": u.get_backend_name(),
"host": u.host, "port": u.port, "database": u.database, "user": u.username}
@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),
"servers": sorted(({"ip": ip, "answers": n, "last": ts, "lan": engine.is_lan(ip)}
for ip, (n, ts) in list(engine.dns_servers.items())),
key=lambda x: x["answers"], reverse=True)[:8]},
"router": poller.status(),
"generic_apps": sorted(GENERIC_APPS),
}
@router.post("/ros/test")
def ros_test():
if not poller.enabled:
raise HTTPException(400, "未配置 ROS_URL,请在环境变量中设置 RouterOS REST 地址和只读账号")
poller.poll()
return poller.status()