Files

126 lines
5.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""本地自学习库(debug15 / debug17):把本网络实测的「IP → 应用」与「域名 → 应用」长期记忆接入分类管线。
host_domain_day(31 天)里的行,当初是靠 DNS / SNI / ASN / 端口认出来的——这些证据过后会消失
(DNS 缓存过期、终端改用 DoH、SNI 只在连接开头出现),但结论是实测来的,比任何公开库都准,
因为它只属于你自己的网络。本模块把结论沉淀成两类映射,每小时从数据库重建一次、热加载:
- IP → 应用(debug15):IPv4 精确、IPv6 按 /64 前缀聚合(中国环境以 IPv6 公网为主,精确地址不稳定);
- 域名 → 应用(debug17):精确域名匹配,配合社区共享(其它部署实例的实测结论,本地没有结论时兜底)。
它随每一次历史重算自动纠正,与识别库升级、社区共享形成闭环。
"""
import ipaddress
import threading
import time
from collections import defaultdict
from sqlalchemy import func, select
from app.models import HostDomainDay
MAX_IPS = 200_000
MAX_DOMAINS = 200_000
# IPv6 精确地址在不同网络里几乎不重复(中国环境以 IPv6 公网为主):按 /64 前缀聚合记忆与共享
def _is_ip(s: str) -> bool:
try:
ipaddress.ip_address(s)
return True
except ValueError:
return False
def _v6key(ip: str) -> str | None:
try:
a = ipaddress.ip_address(ip)
except ValueError:
return None
if a.version != 6:
return None
return str(ipaddress.ip_network(f"{a}/64", strict=False).network_address) + "/64"
class LocalDB:
def __init__(self):
self.lock = threading.Lock()
self.version = 0
self.updated = 0 # 上次重建的时间(识别库面板显示用)
self.ips: dict[str, str] = {} # 本地实测:IPv4 精确 / IPv6 /64 前缀 -> 应用 ID(按历史字节数取最多的应用)
self.domains: dict[str, str] = {} # 本地实测(debug17):精确域名 -> 应用 ID
self.community: dict[str, str] = {} # 社区共享(debug16):其它实例的 IP 结论,只补本地空白
self.community_domains: dict[str, str] = {} # 社区共享(debug17):其它实例的域名结论,只补本地空白
def load(self, db, classifier=None):
"""从 host_domain_day 聚合:IP(IPv6 聚到 /64)与域名分别取历史字节数最多的应用;调用方提交事务。
聚合在数据库里做(GROUP BY),不把几百万行逐行读进 Python——那会抢住 GIL,UDP 收包跟着丢。
笼统结论(HTTPS / QUIC / 国内网络 / 未识别……)不进记忆:它们会挡住后面更准的识别,还会自我强化"""
from app.reclass import _vague
ip_votes: dict = defaultdict(dict)
dom_votes: dict = defaultdict(dict)
rows = db.execute(
select(HostDomainDay.domain, HostDomainDay.app, func.sum(HostDomainDay.bytes))
.group_by(HostDomainDay.domain, HostDomainDay.app)
).all()
for dom, app, b in rows:
if classifier is not None and _vague(classifier, app):
continue
if _is_ip(dom):
key = _v6key(dom) or dom
votes = ip_votes
else:
key, votes = dom, dom_votes
per = votes[key]
per[app] = per.get(app, 0) + int(b or 0)
# 只留最大的 MAX_IPS / MAX_DOMAINS 个键(按总字节),避免极端环境下无限膨胀
ips = {k: max(p, key=p.get) for k, p in
sorted(ip_votes.items(), key=lambda kv: -sum(kv[1].values()))[:MAX_IPS]}
domains = {k: max(p, key=p.get) for k, p in
sorted(dom_votes.items(), key=lambda kv: -sum(kv[1].values()))[:MAX_DOMAINS]}
with self.lock:
self.ips = ips
self.domains = domains
self.updated = int(time.time())
self.version += 1
def ip_app(self, ip: str) -> str | None:
with self.lock:
# 本地实测优先;本地没有结论的 IP 用社区共享层(debug16)
hit = self.ips.get(ip)
if hit:
return hit
p = _v6key(ip)
if p:
hit = self.ips.get(p)
if hit:
return hit
return self.community.get(p)
return self.community.get(ip)
def domain_app(self, qname: str | None) -> str | None:
"""域名学习(debug17):精确匹配,本地实测优先、社区共享兜底;没有 qname 或没学过返回 None"""
if not qname:
return None
name = qname.lower().rstrip(".")
with self.lock:
return self.domains.get(name) or self.community_domains.get(name)
def load_community(self, community: dict[str, str], community_domains: dict[str, str] | None = None):
"""社区共享库(debug16/17):其它部署实例的实测结论,只补本地没有的条目。调用方提交事务"""
with self.lock:
self.community = dict(community)
self.community_domains = dict(community_domains or {})
self.version += 1
def reset(self):
"""测试用:清空内存记忆"""
with self.lock:
self.ips = {}
self.domains = {}
self.community = {}
self.community_domains = {}
self.version += 1
local = LocalDB()