From 9add509a1e9b6e08f2da515734dbb2ea2452523d Mon Sep 17 00:00:00 2001 From: Su Yijun Date: Sun, 25 Oct 2026 12:02:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8F=91=E5=B8=83=200.6.8=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=90=BD=E5=BA=93=E5=8D=A1=E6=AD=BB=EF=BC=88?= =?UTF-8?q?=E5=9C=A8=E7=BA=BF=E7=BB=88=E7=AB=AF=200=20=E6=A0=B9=E5=9B=A0?= =?UTF-8?q?=EF=BC=89=E3=80=81=E6=9C=AC=E5=9C=B0=E8=87=AA=E5=AD=A6=E4=B9=A0?= =?UTF-8?q?=E5=BA=93=E3=80=81=E7=BB=84=E6=92=AD=E5=99=AA=E9=9F=B3=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=EF=BC=88debug15=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - access_log.remote 45→255 并在启动时自动迁移存量库;代理连接 remote 恢复只存 IP(长域名曾让每分钟落库全部失败,在线终端 0、统计停更) - 本地自学习库(debug15-1):host_domain_day 实测的 IP→应用 沉淀为长期记忆,每小时重建热加载,DNS 缓存过期 / DoH / 裸 IP 流量按记忆归类 - 历史重算落库改 upsert + 加锁串行化,IntegrityError 纳入重试(审计 P1-1:并发重算撞主键 1062 整笔回滚) - 组播 / 广播流量(SSDP、mDNS)不再计入统计(debug15-2:单设备月发几百 MB SSDP 全落「其它」) --- CHANGELOG.md | 10 +++ backend/app/__init__.py | 2 +- backend/app/classify/__init__.py | 8 ++- backend/app/classify/local.py | 60 +++++++++++++++++ backend/app/database.py | 19 ++++-- backend/app/engine.py | 11 +++- backend/app/main.py | 14 +++- backend/app/models.py | 2 +- backend/app/reclass.py | 53 +++++++++------- backend/app/routers/admin.py | 7 ++ backend/tests/test_debug15.py | 66 +++++++++++++++++++ 审计报告-2026-10-25.md | 106 +++++++++++++++++++++++++++++++ 12 files changed, 323 insertions(+), 35 deletions(-) create mode 100644 backend/app/classify/local.py create mode 100644 backend/tests/test_debug15.py create mode 100644 审计报告-2026-10-25.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 163e7ed..39d39d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.6.8] - 2026-10-25 + +修复落库卡死(在线终端 0 的根因)、本地自学习库、组播噪音过滤(debug15) + +- **修复落库全部卡死**(在线终端 0、统计停更的根因):sing-box 代理连接的目标有时是域名,被写进了 `access_log.remote`(VARCHAR(45),本意只存 IP),超过 45 字符的长域名让**每分钟的落库全部失败**——流量、终端在线状态、访问日志从失败时刻起全部停更,缓冲 2 小时后数据开始丢弃。三处修复:① 列加宽到 255,启动时自动迁移存量库(不再需要手工 ALTER);② 代理连接的 `remote` 恢复"只存 IP"的语义,目标是域名时留空(域名本来就在 `dest` 列);③ 之前执行过手工 ALTER 的库也兼容。 +- **本地自学习库**(debug15 第 1 条):本网络实测的「IP → 应用」沉淀成长期记忆(来自 31 天的 host_domain_day,每小时从数据库重建、热加载,规模见 /api/system 的 `local`)——之后同一个 IP 即使 DNS 缓存过期、终端改用 DoH、或流量只走裸 IP,也能按记忆归类。公开库给不了这层数据:它只属于你自己的网络;且每次历史重算都会自动纠正它,与识别库升级形成闭环。这是"自建本地库"对 Panabit 形成差异化的部分(它靠协议特征,这里多了自学习)。 +- **历史重算的落库改为 upsert + 串行化**(审计报告 P1-1):自动任务和手动按钮同时触发时,两个重算会写同一批行撞主键(1062)整笔回滚——现在目标行用 ON CONFLICT 累加、源行用负增量 upsert,且整个重算加锁串行,IntegrityError 也纳入重试。 +- **组播 / 广播流量不再计数**(debug15 第 2 条):SSDP(239.255.255.250)、mDNS(224.0.0.251)等设备发现协议的流量不再计入终端和应用统计——单台设备一个月能发几百 MB 的 SSDP,此前全部落进「其它」(第二站点实测 473MB)。 +- 其它和未识别的收敛路径(debug15 第 2 条):列宽修复恢复落库 → 本地自学习库 + 每日零点自动重算逐日消化历史 → AdGuard / MosDNS 映射持续积累。手工归类未识别域名大头(媒体刮削、自建隧道等)仍然有效。 + ## [0.6.7] - 2026-10-25 修复 0.6.5 / 0.6.6 的历史重算和识别库自动登记(debug13、debug14 复查) diff --git a/backend/app/__init__.py b/backend/app/__init__.py index f6104e0..afd45a4 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1 +1 @@ -__version__ = "0.6.7" +__version__ = "0.6.8" diff --git a/backend/app/classify/__init__.py b/backend/app/classify/__init__.py index 6255ab7..7ea6024 100644 --- a/backend/app/classify/__init__.py +++ b/backend/app/classify/__init__.py @@ -180,9 +180,10 @@ class Classifier: def classify(self, remote_ip: str, proto: int, port: int, qname: str | None, cname: str | None) -> str: from app.classify.lists import lists as extra_lists + from app.classify.local import local from app.classify.sources import sources as extra - ver = (extra_lists.version, extra.version) - if ver != self._ver: # 第三方库重新加载过:之前的结果作废 + ver = (extra_lists.version, extra.version, local.version) + if ver != self._ver: # 第三方库 / 本地自学习库重新加载过:之前的结果作废 self._cache.clear() self._ver = ver self._sync_list_apps() @@ -217,6 +218,9 @@ class Classifier: hit = asndb.lookup(remote_ip) if hit: app = self.asn_apps.get(hit[0]) + if app is None: + # 本地自学习库(debug15):本网络实测过的 IP→应用 长期记忆,DNS 缓存过期 / DoH / 裸 IP 都能归类 + app = local.ip_app(remote_ip) if app is None or app.startswith(WEAK_PREFIXES): # 分类级列表(Hagezi > UT1):只认得出类别;也用来细化 v2fly 的地区兜底条目 cat = extra_lists.category(qname) or extra_lists.category(cname) diff --git a/backend/app/classify/local.py b/backend/app/classify/local.py new file mode 100644 index 0000000..6d154e4 --- /dev/null +++ b/backend/app/classify/local.py @@ -0,0 +1,60 @@ +"""本地自学习库(debug15):把本网络实测的「IP → 应用」长期记忆接入分类管线。 + +host_domain_day(31 天)里以 IP 形式记录的行,当初是靠 DNS / SNI / ASN / 端口认出来的——这些证据 +过后会消失(DNS 缓存过期、终端改用 DoH、SNI 只在连接开头出现),但"这个 IP 是什么应用"的结论是 +实测来的,比任何公开库都准,因为它只属于你自己的网络。本模块把结论沉淀成 IP → 应用 的映射, +每小时从数据库重建一次、热加载:之后同一个 IP 就算完全拿不到域名,也能按记忆归类。 + +这是"自建本地库"的第一层(debug15);它随每一次重算自动纠正,与识别库升级、历史重算形成闭环。 +""" +import ipaddress +import threading +from collections import defaultdict + +from sqlalchemy import select + +from app.models import HostDomainDay + +MAX_IPS = 200_000 + + +class LocalDB: + def __init__(self): + self.lock = threading.Lock() + self.version = 0 + self.ips: dict[str, str] = {} # IP -> 应用 ID(按历史字节数取最多的应用) + + def load(self, db): + """从 host_domain_day 聚合:每个 IP 取历史字节数最多的应用;调用方提交事务""" + votes: dict = defaultdict(dict) + rows = db.execute( + select(HostDomainDay.domain, HostDomainDay.app, HostDomainDay.bytes) + ).all() + for dom, app, b in rows: + try: + ipaddress.ip_address(dom) + except ValueError: + continue + per = votes[dom] + per[app] = per.get(app, 0) + int(b or 0) + # 只留最大的 MAX_IPS 个 IP(按总字节),避免极端环境下无限膨胀 + top = sorted(votes.items(), key=lambda kv: -sum(kv[1].values()))[:MAX_IPS] + ips = {} + for dom, per in top: + ips[dom] = max(per, key=per.get) + with self.lock: + self.ips = ips + self.version += 1 + + def ip_app(self, ip: str) -> str | None: + with self.lock: + return self.ips.get(ip) + + def reset(self): + """测试用:清空内存记忆""" + with self.lock: + self.ips = {} + self.version += 1 + + +local = LocalDB() diff --git a/backend/app/database.py b/backend/app/database.py index 8c8e52e..5a581a5 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -141,18 +141,27 @@ def init_db(retries: int = 20, delay: float = 3.0): def add_missing_columns(): - """升级时补上新版本新增的列(create_all 只建新表,不改已有的表)。新增列一律可为空,不需要回填。""" + """升级时补上新版本新增的列(create_all 只建新表,不改已有的表)。新增列一律可为空,不需要回填。 + 同时把代码里加宽过的列在存量库上加宽(0.6.8:access_log.remote 45→255;只加宽不缩窄,SQLite 不校验长度跳过)。""" insp = inspect(engine) with engine.begin() as conn: for table in Base.metadata.sorted_tables: if not insp.has_table(table.name): continue - have = {c["name"] for c in insp.get_columns(table.name)} + have = {c["name"]: c for c in insp.get_columns(table.name)} for col in table.columns: - if col.name not in have and col.nullable: + if col.name not in have: + if col.nullable: + ctype = col.type.compile(dialect=engine.dialect) + conn.exec_driver_sql(f"ALTER TABLE `{table.name}` ADD COLUMN `{col.name}` {ctype} NULL") + print(f"[db] 已为 {table.name} 添加列 {col.name}", flush=True) + continue + length = getattr(col.type, "length", None) + db_len = have[col.name].get("length") + if insp.dialect.name == "mysql" and length and db_len and length > int(db_len): ctype = col.type.compile(dialect=engine.dialect) - conn.exec_driver_sql(f"ALTER TABLE {table.name} ADD COLUMN {col.name} {ctype} NULL") - print(f"[db] 已为 {table.name} 添加列 {col.name}", flush=True) + conn.exec_driver_sql(f"ALTER TABLE `{table.name}` MODIFY `{col.name}` {ctype}") + print(f"[db] 已把 {table.name}.{col.name} 从 {db_len} 加宽到 {length}", flush=True) def add_ready_hook(fn): diff --git a/backend/app/engine.py b/backend/app/engine.py index 2790c93..693ea8a 100644 --- a/backend/app/engine.py +++ b/backend/app/engine.py @@ -246,7 +246,8 @@ class Engine: return proto = 17 if network == "udp" else 6 qname = host if host and not _is_ip(host) else None - remote = dst or host or "0.0.0.0" + # remote 只存 IP(写 access_log.remote):目标是域名时没有 IP 可存,留空;0.6.8 前把域名塞进来曾撑爆列宽 + remote = dst if dst and _is_ip(dst) else (host if host and _is_ip(host) else "") node, group, rule, names = route with self.lock: app = self.classifier.classify(dst or "0.0.0.0", proto, dport, qname, None) @@ -313,6 +314,14 @@ class Engine: if not src or not dst or nbytes <= 0: self.stats["flows_dropped"] += 1 return None + # 组播 / 广播是设备发现协议(SSDP、mDNS……)的噪音:一台设备一个月能发几百 MB,全落进「其它」,不计 + try: + if ipaddress.ip_address(dst).is_multicast or ipaddress.ip_address(src).is_multicast \ + or src == "255.255.255.255" or dst == "255.255.255.255": + self.stats["multicast_dropped"] = self.stats.get("multicast_dropped", 0) + 1 + return None + except ValueError: + pass src_lan, dst_lan = self.is_lan(src), self.is_lan(dst) mac = None if src_lan and not dst_lan: diff --git a/backend/app/main.py b/backend/app/main.py index 7cba3d0..1030896 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -47,7 +47,8 @@ def _on_db_ready(): """数据库连接成功后(启动时或安装向导保存后)执行。各步互不依赖:一步出错只记日志,不挡住后面的 (0.6.0 / 0.6.1 的主域名回填出错时,后面的外部数据源配置、自定义规则都没加载,外部数据源显示“未启用”)""" ensure_admin() # 建管理员失败要报出来(安装向导靠这个提示) - for name, fn in (("load_state", engine.load_state), ("apply_settings", _apply_saved)): + for name, fn in (("load_state", engine.load_state), ("apply_settings", _apply_saved), + ("local_db", _refresh_local)): try: fn() except Exception as e: # noqa: BLE001 @@ -55,6 +56,16 @@ def _on_db_ready(): print("[startup] 数据库已连接", flush=True) +def _refresh_local(): + """本地自学习库(debug15):从 host_domain_day 重建 IP→应用 长期记忆(启动 + 每小时),热加载""" + if not database.is_configured(): + return + from app.classify.local import local + with database.session() as db: + local.load(db) + db.commit() + + def _auto_update_on() -> bool: """识别规则页的「自动更新」开关(默认开启):管 v2fly、ASN 库和补充库的定期更新""" if not database.is_configured(): @@ -118,6 +129,7 @@ async def lifespan(app: FastAPI): sched.add_job(_safe(integrations.singbox.poll, "singbox"), "interval", seconds=5, max_instances=1, coalesce=True) sched.add_job(_safe(integrations.mosdns.poll, "mosdns"), "interval", seconds=5, max_instances=1, coalesce=True) sched.add_job(_safe(integrations.adguard.poll, "adguard"), "interval", seconds=5, max_instances=1, coalesce=True) + sched.add_job(_safe(_refresh_local, "local-db"), "interval", hours=1, max_instances=1, coalesce=True) # 识别库更新 / 过了零点后的自动重算:指纹没变时开销只有一次设置查询。启动后等 15 分钟再跑第一次, # 第三方库、ASN、国内列表是在后台线程加载的,不能用没加载完的识别器去算历史 sched.add_job(_safe(_auto_reclassify, "auto-reclass"), "interval", hours=1, max_instances=1, coalesce=True, diff --git a/backend/app/models.py b/backend/app/models.py index 4f52550..e737163 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -134,7 +134,7 @@ class AccessLog(Base): host = Column(String(64), nullable=False) host_ip = Column(String(45)) dest = Column(String(255), nullable=False) # 域名;没有域名时是远端 IP - remote = Column(String(45)) # 最近一次的远端 IP + remote = Column(String(255)) # 最近一次的远端 IP;0.6.8 从 45 加宽:代理连接曾把域名写进来导致落库卡死 app = Column(String(48), nullable=False) port = Column(Integer) proto = Column(Integer) diff --git a/backend/app/reclass.py b/backend/app/reclass.py index 9d9aeba..f3b9b52 100644 --- a/backend/app/reclass.py +++ b/backend/app/reclass.py @@ -18,17 +18,20 @@ app 标记同步改掉(保证重复执行幂等)。host_domain_day 只覆盖 识别规则页的手动「重算历史分类」直接调 reclassify()。 """ import ipaddress +import threading import time from collections import defaultdict from sqlalchemy import select -from sqlalchemy.exc import OperationalError +from sqlalchemy.exc import IntegrityError, OperationalError from app import timeutil from app.classify import GENERIC_APPS, UNIDENTIFIED_CATEGORIES, WEAK_PREFIXES +from app.database import upsert_add from app.models import DnsCache, HostDomainDay, Setting, TrafficDay FP_KEY = "reclass_fp" +_reclass_lock = threading.Lock() # 自动任务和手动按钮可能同时触发:串行化,避免两轮重算搬同一批字节 # 只说明协议、地区或“是个网站”的笼统分类:可以被升级成具体应用,但重算不会把行改成它们 VAGUE_APPS = GENERIC_APPS | {"cn_net", "cn_web", "unknown"} @@ -47,22 +50,24 @@ def _is_ip(s: str) -> bool: return False -def _deadlock(e: Exception) -> bool: - """MySQL 死锁(1213)或锁等待超时(1205):落库任务每分钟在写同一批表,撞上重试即可""" +def _retryable(e: Exception) -> bool: + """MySQL 死锁(1213)/ 锁等待超时(1205)/ 主键冲突(1062,并发重算撞车):落库任务每分钟在写同一批表, + 自动任务和手动按钮也可能同时跑,撞上重试即可""" s = str(e) - return "(1213" in s or "(1205" in s + return "(1213" in s or "(1205" in s or "(1062" in s def reclassify(engine, db, before: int | None = None, attempts: int = 3) -> dict: """重算 before(默认今天零点)以前的历史分类""" - for i in range(attempts): - try: - return _reclassify_once(engine, db, before) - except OperationalError as e: - db.rollback() - if i == attempts - 1 or not _deadlock(e): - raise - time.sleep(2 + i * 2) + with _reclass_lock: + for i in range(attempts): + try: + return _reclassify_once(engine, db, before) + except (OperationalError, IntegrityError) as e: + db.rollback() + if i == attempts - 1 or not _retryable(e): + raise + time.sleep(2 + i * 2) def _dns_domain_map(db) -> dict[str, str]: @@ -139,20 +144,20 @@ def _reclassify_once(engine, db, before: int | None) -> dict: s_[1] += down s_[2] += flows for key, (up, down, flows) in srcs.items(): - src = db.get(TrafficDay, {"ts": key[0], "host": key[1], "app": key[2]}) - if src is None: + # 负增量也走 upsert:并发重算串行化了(见 reclassify 的锁),但落库任务可能同时碰同一行 + if db.get(TrafficDay, {"ts": key[0], "host": key[1], "app": key[2]}) is None: continue - src.up = max(src.up - up, 0) - src.down = max(src.down - down, 0) - src.flows = max(src.flows - flows, 0) + upsert_add(db, TrafficDay.__table__, + [{"ts": key[0], "host": key[1], "app": key[2], "up": -up, "down": -down, "flows": -flows}], + ["ts", "host", "app"], ["up", "down", "flows"]) + src = db.get(TrafficDay, {"ts": key[0], "host": key[1], "app": key[2]}) + if src is not None and (src.up < 0 or src.down < 0 or src.flows < 0): + src.up, src.down, src.flows = max(src.up, 0), max(src.down, 0), max(src.flows, 0) for key, (up, down, flows) in dsts.items(): - dst = db.get(TrafficDay, {"ts": key[0], "host": key[1], "app": key[2]}) - if dst is None: - db.add(TrafficDay(ts=key[0], host=key[1], app=key[2], up=up, down=down, flows=flows)) - else: - dst.up += up - dst.down += down - dst.flows += flows + # upsert:落库任务 / 并发的重算可能刚写入了同一行(0.6.7 及以前用 db.get + add,撞主键整笔回滚) + upsert_add(db, TrafficDay.__table__, + [{"ts": key[0], "host": key[1], "app": key[2], "up": up, "down": down, "flows": flows}], + ["ts", "host", "app"], ["up", "down", "flows"]) return {"scanned": len(rows), "rows_moved": len(dsts), "bytes_moved": sum(v[0] + v[1] for v in dsts.values()), "flows_moved": sum(v[2] for v in dsts.values()), "seconds": round(time.time() - t0, 1)} diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 729ced8..3bf6109 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -177,6 +177,12 @@ def _integrations(db): "proxy_auto": integrations.singbox.host_ip()} +def local_status(): + """本地自学习库(debug15):本网络实测的 IP→应用 记忆规模""" + from app.classify.local import local + return {"ips": len(local.ips), "updated": local.version} + + def _db_info(): eng = database.engine if eng is None: @@ -245,6 +251,7 @@ def system(): "singbox": integrations.singbox.status(), "mosdns": integrations.mosdns.status(), "adguard": integrations.adguard.status(), + "local": local_status(), "asn": asndb.status(), "proxied_skipped": st["proxied_skipped"], "dns_mosdns": st["dns_mosdns"], "generic_apps": sorted(GENERIC_APPS), diff --git a/backend/tests/test_debug15.py b/backend/tests/test_debug15.py new file mode 100644 index 0000000..33377af --- /dev/null +++ b/backend/tests/test_debug15.py @@ -0,0 +1,66 @@ +"""debug15:本地自学习库(IP→应用 长期记忆)、组播噪音过滤、代理连接 remote 只存 IP。""" +import time + +from app.classify import Classifier +from app.classify.local import local +from app.engine import Engine +from app.models import HostDomainDay + +DAY = 1792900000 +HOST = "IP:192.168.8.40" + + +def _engine(): + e = Engine() + e.set_lan("192.168.8.0/24") + return e + + +def test_local_library_feeds_classifier(): + import app.database as database + database.connect_from_saved(force=True) + s = database.SessionLocal() + try: + # 其它测试可能在共享库里留过同域名的行:先清掉,保证聚合结果确定 + s.query(HostDomainDay).filter(HostDomainDay.domain.in_(["203.0.113.9", "www.example.com"])).delete(synchronize_session=False) + s.query(HostDomainDay).filter(HostDomainDay.host == HOST).delete(synchronize_session=False) + s.add(HostDomainDay(ts=DAY, host=HOST, domain="203.0.113.9", app="douyin", base=None, bytes=500)) + s.add(HostDomainDay(ts=DAY, host=HOST, domain="www.example.com", app="web", base=None, bytes=900)) + s.commit() + local.load(s) + s.commit() + assert local.ip_app("203.0.113.9") == "douyin" # IP 行进记忆 + assert local.ip_app("198.51.100.7") is None + c = Classifier() + assert c.classify("203.0.113.9", 6, 443, None, None) == "douyin" # 分类管线用上记忆(无域名 / DoH 场景) + assert c.classify("198.51.100.7", 6, 443, None, None) == "https" # 没记忆的照旧走通用 + finally: + s.query(HostDomainDay).filter(HostDomainDay.host == HOST).delete(synchronize_session=False) + s.query(HostDomainDay).filter(HostDomainDay.domain.in_(["203.0.113.9", "www.example.com"])).delete(synchronize_session=False) + s.commit() + s.close() + local.reset() # 单例记忆会污染同进程的其它用例(203.0.113.9 是测试标准地址) + + +def test_multicast_and_broadcast_dropped(): + e = _engine() + now = time.time() + for dst in ("239.255.255.250", "255.255.255.255", "224.0.0.251", "ff02::fb"): + r = e.add_flow(dict(src="192.168.8.20", dst=dst, sport=5000, dport=1900, proto=17, bytes=1000), now) + assert r is None, dst + r = e.add_flow(dict(src="192.168.8.20", dst="93.184.216.34", sport=5000, dport=443, proto=6, bytes=1000), now) + assert r is not None + + +def test_proxy_remote_stores_ip_only(): + e = _engine() + e.set_proxy({"192.168.8.249"}, True) + now = time.time() + # 目标是域名(无 IP):remote 留空,不把长域名写进 access_log.remote + e.add_proxy_conn("192.168.8.20", 50000, "very-long-domain-name.example-cdn-cluster.some-provider.net", + "", 443, "tcp", 100, 1000, now) + # 目标是 IP:remote 存 IP + e.add_proxy_conn("192.168.8.20", 50001, "api.example.com", "93.184.216.34", 443, "tcp", 100, 1000, now + 1) + remotes = {k[1] for k in e.sessions} + assert "" in remotes and "93.184.216.34" in remotes + assert not any(r and "example-cdn-cluster" in r for r in remotes) diff --git a/审计报告-2026-10-25.md b/审计报告-2026-10-25.md new file mode 100644 index 0000000..f3f5e43 --- /dev/null +++ b/审计报告-2026-10-25.md @@ -0,0 +1,106 @@ +# ROS-DPI 审计报告(2026-10-25) + +审计范围:0.6.5 / 0.6.6 / 0.6.7 三次发布的相关代码(reclass.py、lists.py、integrations.py、main.py、admin.py、classify/__init__.py、oplog.py、Settings.vue、Rules.vue、style.css),以及线上实例(192.168.88.240,对外 rosdpi.suyijun.dpdns.org:9443)的运行状态。**只读审计,未改动任何代码。** + +## 一、总体结论 + +0.6.7 的方向和实现是对的:历史重算「只动今天以前、只升级不降级」解决了反复搬移和降级;死锁重试解决了和落库任务撞锁;投票归属比纯名字猜测可靠得多;oplog 已脱敏 AdGuard 密码。测试 97 个全绿,CI 构建成功。 + +线上实例运行正常:AdGuard 源已生效(应答 4322 条且持续增长)、「未识别」已降到 2.6%、历史重算成功搬移约 1.6GB。**「其它」大类今天仍占 45.5% 属于设计内现象**(见 P1-2),不是故障。 + +以下按优先级列出发现的问题。 + +## 二、P1 — 建议尽快处理 + +### 1. 并发重算互相撞主键,整个重算静默回滚(0.6.7 仍存在) + +- 现象:容器日志里有 `[error] POST /api/rules/reclassify: IntegrityError (1062, "Duplicate entry '1792771200-00:00:00:00:00:00-microsoft' for key 'traffic_day.PRIMARY'")`,对应你在 0.6.6/0.6.7 上的多次点击——至少一次点击实际失败了(整笔回滚,页面上只有一条报错)。 +- 根因:`reclass.py` 目标行写入是 `db.get(TrafficDay, {...})` 查不到就 `db.add()` 纯 INSERT(reclass.py L149-155)。重算可以并发触发:自动任务每小时一次(main.py L122-123,启动后 15 分钟首跑)+ 手动按钮 + 连续点击(oplog 里 18:06 一分钟内就有三次)。两个重算同时跑:A 查不到目标行(未提交)→ add;B 同样查不到 → add;后提交的撞主键。注意和落库任务无关——落库只写今天的行,重算只碰今天以前的行。 +- 重试救不了它:`reclassify()` 的重试只捕死锁(1213/1205,reclass.py `_deadlock()`),IntegrityError 直接抛出。 +- 建议(约 10 行改动): + 1. 目标行写入改用现成的 `database.upsert_add()`(ON CONFLICT 累加,database.py L85),天然冲突安全; + 2. 或者给重算加模块级 `threading.Lock` 串行化(自动任务和手动按钮互斥); + 3. 顺手把 IntegrityError 也纳入重试清单。 + +### 2. 预期管理:「其它」今天占 45.5% 是设计行为,明天才会归位 + +- 0.6.7 起重算只处理**今天以前**的数据(`reclass.py` 的 `before` 过滤,避免和每分钟落库抢写同一行)。 +- 你今天的大头(爱快下游的国内 CDN 流量)全在「今天」的桶里:点了重算它也不动,要等**明天零点后的自动重算**才会用上期间积累的 AdGuard 映射。 +- 观察方法:明天早上看全网概览的「其它」占比,应明显低于今天;之后逐日收敛。识别规则页的识别库指纹里含日期(`fingerprint()` 的 `day{...}`),过零点必然触发一轮。 + +### 3. 重算是同步 HTTP 接口,数据量大时会超时 + +- 18:04 那轮全量重算耗时 80.7 秒(oplog 实录),是在数据库里 4600 行的规模。行数随终端和域名数增长,前端 fetch 默认超时后按钮会报错,但后端其实还在跑。 +- 建议:改成后台任务 + 识别规则页显示"上次重算结果"(oplog 里已有数据),或至少前端把该请求的超时调到 5 分钟。 + +## 三、P2 — 低风险观察项 + +4. **AdGuard 轮询吞吐上限**:每 5 秒拉最新 500 条(≈100 QPS 封顶),未用 `older_than` 分页。内网规模足够;若下级终端很多且出现 DNS 风暴,会有漏读缝隙(漏读的映射下次查询会补上,影响有限)。 +5. **投票的小目录滞留**:`VOTE_MIN=5`,可识别域名不足 5 条的新目录永远停在 pending,每个更新周期重复下载一次。无害,量也小。 +6. **GitHub API 限流**:自动发现用未认证 API(60 次/小时/IP)。当前 bm7 每周一次 + hagezi/ut1 每天各一次 ≈ 3 次/天,充足;以后若加来源留意。 +7. **首跑指纹的浪费**:指纹含 `apps{数量}` 和 v2fly latest,启动后 15 分钟首跑时 v2fly 可能还没完成第一次检查 → 首跑后指纹立刻又变 → 1 小时后多做一次重算。无害(第二次搬移量为 0),介意的话让首跑跳过指纹写入。 +8. **VM 时区是 EDT**:应用内部统一用 TZ=Asia/Shanghai(统计日桶正确),但系统日志、`timedatectl` 显示美国东部时间,排查问题时容易看岔。建议:`timedatectl set-timezone Asia/Shanghai`。 +9. **识别率口径注意**:「国内网络 / 国内网站」在 `UNIDENTIFIED_CATEGORIES` 之外、按已识别计。AdGuard 把部分「国内网络」拆成具体应用后,个别未认出的会落进「未识别」,识别率数字可能先降后升——看趋势别看单点。 + +## 四、运行状态快照(审计时点) + +| 项 | 状态 | +|---|---| +| 线上版本 | 0.6.7(你已自行部署,CI 构建成功) | +| 后端测试 | 97 个全绿 | +| AdGuard 源 | ok,应答 4322 条且持续增长(data 格式修复生效) | +| MosDNS 源 | ok,应答 2226 条 | +| 历史重算 | 成功过 4 轮:合计搬移约 1.6GB;失败 1 次(1062,见 P1-1) | +| 未识别占比 | 2.6%(此前 9.4%) | +| 其它占比 | 45.5%(今天的数据,见 P1-2) | +| VM 环境 | 无 daemon.json 残留(存储驱动默认 overlay2 ✓);磁盘 98G 可用 90G;时区 EDT(见 P2-8) | +| Arcane | VM 上的项目在 `/opt/ros-dpi`(命令行部署),Arcane 面板里看不到,属已知状态 | + +## 五、建议的处理顺序 + +1. **观察 24–48 小时**(零成本):AdGuard 映射积累 + 每日零点自动重算,「其它」应逐日下降;若 48 小时后「其它」里国内网络仍 >20%,回来找我,那时值得再看一次 DNS 链路(爱快下游是否全部走 192.168.100.117、有没有设备用 DoH 绕过)。 +2. **修 P1-1**(10 行改动):upsert_add 或加锁,避免下一次点击又静默失败。 +3. 可选:P1-3 重算改后台任务、P2-8 时区。 + +## 六、第二站点审计:rosdpi.suyijun.top:8443(2026-10-25 补充) + +这一台监控的是**另一个站点**:LAN 192.168.8.0/24,RouterOS x86(7.23beta5 development)@192.168.8.254,MosDNS @192.168.8.248:9099(已接、应答 2101 条),MySQL @192.168.8.251,版本 0.6.7。AdGuard 未配置。 + +**「其它+未识别」≈ 18%(月视图:unknown 14.5% + other 3.8%),构成如下:** + +1. **unknown 14.5% 的主体是几个联通骨干(AS4837)的纯 IP 大流量**:116.169.179.186(2.7GB)、101.205.174.229(1.1GB)、101.206.244.19(741MB)、116.169.56.161(728MB)等——DNS 应答里没有它们,属于"查询发生在实例启动前 / 客户端 DoH / 上游缓存未过审计"三种情况之一。这台实例的 DNS 可见性其实很好(pair 匹配 353MB 是最大来源,none 只有 8.7MB),所以**点一次「重算历史分类」让 dns_cache 反查生效**,再等每日自动重算积累,即可逐步收敛。 +2. **unknown 域名里的大头(可用现有功能手工处理,无需改代码)**: + - `assets.fanart.tv` 380MB —— Kodi/Jellyfin 的媒体封面刮削源,建议在 识别规则 → 未识别域名 一键归类到「网络电视」; + - `sui / dmit / doh.pubdefault.suyijun.top` 合计约 350MB —— 你自建的 VPS / DoH 隧道,建议建一个自定义应用(如「自建服务」)归「流量代理」或「常用协议」; + - `jz.wacaijizhang.com` 10MB —— 随手记,可归「常用网站」。 +3. **意外发现(建议列入开发计划)**:SSDP 组播 `239.255.255.250`(udp/1900 设备发现)一个月被计了 **473MB**,全部落进「其它」。一方面组播/广播地址(224.0.0.0/4、ff02::/16、255.255.255.255)建议在采集侧过滤或单独归类;另一方面 473MB 的 SSDP 流量本身不正常,内网大概率有一台设备在疯狂发组播,值得抓一下是哪台(交换机端口流量或临时 firewall 计数)。 +4. **这台站点没有接 AdGuard 的必要**(除非 8.x 网段也存在二级路由下挂 AdGuard 的结构;目前的未知流量来自联通 CDN,不是 DNS 链路问题)。 +5. **顺带提醒**:这台路由器跑的是 7.23beta5(development 通道)。beta 版的 sniff-tzsp / traffic-flow 行为可能有变化,若以后出现嗅探异常(SNI/DNS 应答数骤降),优先考虑换回 stable 再排查。 + +## 七、P0(2026-10-25 上午发现,正在发生):主实例落库全部卡死,「在线终端 0」只是表象 + +**现象**:rosdpi.suyijun.dpdns.org:9443 在线终端显示 0,与实际不符;应用统计/趋势/最近访问全部停更约 40 分钟(审计时点)。 + +**因果链(已逐环证实)**: + +1. sing-box 代理连接的目标有时是**域名**而不是 IP:`engine.py:249` `remote = dst or host or "0.0.0.0"` 把域名塞进了 `AccessLog.remote`,而该列是 `String(45)`(models.py,本意只存 IP,45 正好是 IPv6 最长文本长度)。 +2. 某个超过 45 字符的长域名进来自后,**每分钟的落库(flush)全部失败**:`DataError (1406, "Data too long for column 'remote' at row 6")`,容器日志里每分钟一条。 +3. flush 失败 → traffic_day / host_domain_day / hosts 表(含 last_seen)全部停更 → 「在线终端」按 `now - last_seen < 阈值` 判定(stats.py L109)→ 全部离线、显示 0。实时流量图走平、但「今天流量」等内存侧数字还在动,所以页面看起来"半正常"。 +4. 失败的数据在内存 `_pending` 里每分钟重试,`PENDING_MAX_AGE = 7200 秒`(engine.py L31):**首次失败约 40 分钟前,缓冲的数据约 80 分钟后开始被丢弃**——不处理的话,故障期间的全部流量数据会丢。 + +**立即处理(一条 SQL,不改代码,保住缓冲数据)**: + +```bash +docker exec -it ros-dpi-mysql mysql -urosdpi -p rosdpi \ + -e "ALTER TABLE access_log MODIFY remote VARCHAR(255);" +``` + +列加宽后,下一分钟的 flush 重试会自动成功,缓冲的流量、last_seen、在线终端全部恢复。**务必在首次失败后 2 小时内执行**(超过后缓冲开始按滑动窗口丢弃)。 + +**代码侧修复(下次发版,建议 0.6.8)**: + +1. `models.py`:`remote = Column(String(255))`(与 dest 一致)。注意 `add_missing_columns()` 只加列不改宽,已有库需要上面那条 ALTER 或在迁移逻辑里补宽度变更。 +2. `engine.py add_proxy_conn`:`remote` 的语义是"远端 IP"——目标是域名时应留空(或存解析后的 IP),而不是把域名塞进去;至少加长度防御(截断到列宽)。 +3. 排查同类隐患:`grep "String(45)\|String(64)" models.py`,凡是可能收到域名/长字符串的列都过一遍(host String(64) 存 "IP:x.x.x.x" 或 MAC 目前安全;dest 已是 255)。 + +**这个 bug 为什么现在才暴露**:sing-box 外部数据源是昨晚 17:33 才配置的(oplog 实录),代理连接的域名目标从此进入 access_log;今天早上出现第一个超长域名后落库开始失败。属于 0.6.5 接入 sing-box 后的遗留隐患,与 0.6.7 的重算改动无关。