fix: 发布 0.6.8,修复落库卡死(在线终端 0 根因)、本地自学习库、组播噪音过滤(debug15)
- 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 全落「其它」)
This commit is contained in:
1 parent
f2d4f4145a
commit
9add509a1e
12 files changed
+323
-35
No files matched your search
@@ -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 复查)
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.6.7"
|
||||
__version__ = "0.6.8"
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
+14
-5
@@ -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):
|
||||
|
||||
+10
-1
@@ -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:
|
||||
|
||||
+13
-1
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+29
-24
@@ -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)}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
@@ -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<MYSQL_PASSWORD> 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 的重算改动无关。
|
||||
Reference in new issue
Block a user