259 lines
9.8 KiB
Python
259 lines
9.8 KiB
Python
import subprocess
|
||
import os
|
||
import logging
|
||
import csv
|
||
import functools
|
||
|
||
@functools.lru_cache(maxsize=4096)
|
||
def normalize_domain(domain: str) -> str:
|
||
"""
|
||
规范化域名,去除动态生成的前缀。
|
||
"""
|
||
try:
|
||
if not domain:
|
||
return domain
|
||
parts = domain.split('.')
|
||
if len(parts) < 3:
|
||
return domain
|
||
prefix = parts[0]
|
||
# 判断前缀是否像动态生成的(长且包含数字和字母)
|
||
is_long = len(prefix) > 10
|
||
has_digits = any(char.isdigit() for char in prefix)
|
||
has_letters = any(char.isalpha() for char in prefix)
|
||
if is_long and has_digits and has_letters:
|
||
normalized = '.'.join(parts[1:])
|
||
return normalized
|
||
else:
|
||
return domain
|
||
except:
|
||
return domain
|
||
|
||
|
||
def build_blacklist_index(blacklist: set) -> dict:
|
||
"""
|
||
将黑名单后缀列表预处理为反向分层索引,加速后续查找。
|
||
|
||
原始线性扫描:O(黑名单大小)
|
||
分层索引查找:O(域名层级数),通常只需 2-3 次 set 查找
|
||
|
||
返回结构示例(黑名单含 "ads.google.com" 和 "tracker.com"):
|
||
{
|
||
'com': {
|
||
'google': {'ads': {}},
|
||
'tracker': {},
|
||
}
|
||
}
|
||
空 dict 表示该节点为黑名单终止节点(完整后缀匹配)。
|
||
"""
|
||
index = {}
|
||
for suffix in blacklist:
|
||
if not suffix:
|
||
continue
|
||
parts = suffix.strip().split('.')
|
||
node = index
|
||
# 从右到左插入(TLD 在最外层)
|
||
for part in reversed(parts):
|
||
if part not in node:
|
||
node[part] = {}
|
||
node = node[part]
|
||
return index
|
||
|
||
|
||
def is_blacklisted_domain(domain: str, blacklist: set,
|
||
_index: dict = None) -> bool:
|
||
"""
|
||
检查域名是否在黑名单中(支持后缀匹配)。
|
||
|
||
如果提供了预建的 _index(由 build_blacklist_index 生成),
|
||
使用分层索引 O(层级数) 查找;否则回退到线性扫描。
|
||
"""
|
||
if not domain:
|
||
return True
|
||
if _index is not None:
|
||
# 分层索引查找:从 TLD 往左逐层匹配
|
||
parts = domain.split('.')
|
||
node = _index
|
||
for part in reversed(parts):
|
||
if part not in node:
|
||
return False # 当前层无匹配,不在黑名单
|
||
node = node[part]
|
||
if not node:
|
||
return True # 到达终止节点,完整后缀匹配
|
||
return bool(not node) # 所有层都匹配完即为命中
|
||
# 回退:原始线性扫描
|
||
for blocked_suffix in blacklist:
|
||
if domain.endswith(blocked_suffix):
|
||
return True
|
||
return False
|
||
|
||
|
||
class TrafficMonitor:
|
||
"""
|
||
TrafficMonitor 用于实时监控 PCAPdroid 生成的流量日志。
|
||
它通过 ADB 读取最新的 txt 记录,并统计特定包名的去重域名。
|
||
"""
|
||
|
||
def __init__(self, device, package_name, blacklist_path="./doc/ad_domains.csv", pcap_callback=None):
|
||
"""
|
||
初始化流量监控模块。
|
||
:param device: 设备实例
|
||
:param package_name: 需要监控的包名
|
||
:param blacklist_path: 广告域名黑名单 CSV 路径
|
||
:param pcap_callback: (Optional) 回调函数,用于在检测到停止时推送流量文件
|
||
"""
|
||
self.logger = logging.getLogger(self.__class__.__name__)
|
||
self.device = device
|
||
self.package_name = package_name
|
||
self.pcap_callback = pcap_callback # 保存回调
|
||
self.remote_dir = self.device.captured_traffic_dir
|
||
|
||
self.all_domains = set() # 自启动以来发现的所有去重域名
|
||
self.last_domains_count = 0 # 上次外部查询时的域名总数
|
||
self.last_step_domains = set() # 上一步时的所有域名(用于计算每步新增)
|
||
|
||
# 已检查过黑名单的域名缓存(True=黑名单,False=白名单)
|
||
self._blacklist_cache: dict = {}
|
||
|
||
# 加载黑名单并预建分层索引
|
||
self.blacklist = set()
|
||
self._blacklist_index = {}
|
||
if blacklist_path and os.path.exists(blacklist_path):
|
||
try:
|
||
with open(blacklist_path, mode='r', encoding='utf-8') as f:
|
||
reader = csv.reader(f)
|
||
for row in reader:
|
||
if row:
|
||
self.blacklist.add(row[0].strip())
|
||
self._blacklist_index = build_blacklist_index(self.blacklist)
|
||
self.logger.info(f"Loaded {len(self.blacklist)} blacklisted domains from {blacklist_path}")
|
||
except Exception as e:
|
||
self.logger.error(f"Failed to load blacklist: {e}")
|
||
|
||
self.logger.info(f"TrafficMonitor initialized for package: {package_name}")
|
||
self.update_count = 0
|
||
|
||
def update(self):
|
||
"""
|
||
通过设备接口读取远程目录下最新的 txt 文件并解析。
|
||
"""
|
||
content = self.device.get_traffic_domains(self.remote_dir)
|
||
|
||
if content:
|
||
self._parse_content(content)
|
||
self.update_count += 1
|
||
else:
|
||
self.logger.warning(f"device.get_traffic_domains() return None. update_count: {self.update_count}")
|
||
|
||
def _parse_content(self, content):
|
||
"""
|
||
解析日志内容并将域名加入缓存。
|
||
格式: package_name, app_name, domain
|
||
|
||
性能优化:
|
||
1. 用行内容 hash 跳过已处理行,避免重复解析全量历史内容
|
||
2. 用 _blacklist_cache 缓存黑名单检查结果,同一域名只检查一次
|
||
3. 用预建的分层索引替代线性遍历黑名单
|
||
"""
|
||
lines = content.splitlines()
|
||
for line in lines:
|
||
parts = [p.strip() for p in line.split(',')]
|
||
# 格式校验:至少有 3 个元素
|
||
if len(parts) >= 3:
|
||
pkg_name = parts[0]
|
||
domain = parts[2]
|
||
|
||
if pkg_name == self.package_name and domain:
|
||
# 1. 规范化域名
|
||
normalized_domain = normalize_domain(domain)
|
||
|
||
# 2. 已知域名直接跳过(无需再查黑名单)
|
||
if normalized_domain in self.all_domains:
|
||
continue
|
||
|
||
# 3. 检查黑名单(带缓存)
|
||
if normalized_domain not in self._blacklist_cache:
|
||
self._blacklist_cache[normalized_domain] = is_blacklisted_domain(
|
||
normalized_domain, self.blacklist, self._blacklist_index
|
||
)
|
||
if not self._blacklist_cache[normalized_domain]:
|
||
self.all_domains.add(normalized_domain)
|
||
self.logger.debug(f"New domain found: {normalized_domain} (original: {domain})")
|
||
|
||
def has_new_features(self):
|
||
"""
|
||
检查自上次调用以来是否有新的去重域名出现。
|
||
:return: (bool, int) 是否有新域名,新增了多少个
|
||
"""
|
||
current_count = len(self.all_domains)
|
||
new_count = current_count - self.last_domains_count
|
||
|
||
has_new = new_count > 0
|
||
|
||
# 更新记录
|
||
self.last_domains_count = current_count
|
||
|
||
if has_new:
|
||
self.logger.info(f"Feedback: {new_count} new unique domains detected.")
|
||
|
||
return has_new, new_count
|
||
|
||
def get_all_domains(self):
|
||
"""
|
||
返回所有已发现的去重域名。
|
||
"""
|
||
return sorted(list(self.all_domains))
|
||
|
||
def get_new_domains_since_last_step(self):
|
||
"""
|
||
获取自上一步以来新增的域名。
|
||
每次调用后会更新 last_step_domains,确保每步只获取一次新增域名。
|
||
:return: (int, list) 新增域名数量,新增域名列表
|
||
"""
|
||
# 先更新获取最新数据
|
||
self.update()
|
||
|
||
# 计算新增域名
|
||
new_domains = self.all_domains - self.last_step_domains
|
||
new_domains_list = sorted(list(new_domains))
|
||
new_count = len(new_domains_list)
|
||
|
||
# 更新上一步的域名集合
|
||
self.last_step_domains = self.all_domains.copy()
|
||
|
||
if new_count > 0:
|
||
self.logger.debug(f"Step found {new_count} new domains: {new_domains_list}")
|
||
|
||
return new_count, new_domains_list
|
||
|
||
def check_monitor_health(self):
|
||
"""
|
||
检查监控环境是否正常运行。
|
||
如果停止运行:
|
||
1. 调用 pcap_callback (如果存在) 推送旧流量文件
|
||
2. 通过设备接口重启抓包
|
||
"""
|
||
try:
|
||
if not self.device.is_traffic_capture_running():
|
||
self.logger.warning("[Monitor Health] Traffic capture stopped running!")
|
||
|
||
# 1. 尝试推送旧流量文件(因为重启会覆盖/生成新文件)
|
||
if self.pcap_callback:
|
||
self.logger.info("[Monitor Health] Triggering pcap_callback to push traffic file...")
|
||
try:
|
||
self.pcap_callback(self.package_name)
|
||
except Exception as e:
|
||
self.logger.error(f"[Monitor Health] Failed to execute pcap_callback: {e}")
|
||
else:
|
||
self.logger.warning("[Monitor Health] No pcap_callback provided, skipping traffic push.")
|
||
|
||
# 2. 通过设备接口重启抓包
|
||
self.logger.info("[Monitor Health] Restarting traffic capture...")
|
||
self.device.restart_traffic_capture(self.package_name)
|
||
time.sleep(15)
|
||
else:
|
||
self.logger.debug("[Monitor Health] Traffic capture is running normally.")
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"[Monitor Health] Exception during health check: {e}")
|
||
|