WechatOnCloud/bridge/woc_bridge/db/coordinator.py

542 lines
22 KiB
Python
Raw Normal View History

"""DB 状态协调DB 状态解析 / 密钥自动提取 / DB 可读性检查 / 重试装饰器。
server.py 抽出的 DB 协调逻辑 routes/ app.py 调用
所有函数通过 _statefrom config.py访问全局状态
"""
from __future__ import annotations
import asyncio
import functools
import inspect
import logging
import os
import time
from dataclasses import dataclass
from typing import Optional, get_type_hints
from woc_bridge.config import _state, _require_db_reader, _require_xdotool
from woc_bridge.db.decryptor import Decryptor, _resolve_page1_key_material
from woc_bridge.db.key_extractor import KeyExtractor, _get_wechat_pids
from woc_bridge.models import BridgeError
logger = logging.getLogger("woc-bridge")
# ---------------------------------------------------------------------------
# with_db_retry 装饰器
# ---------------------------------------------------------------------------
def with_db_retry(func):
"""路由装饰器:遇到 DB_ENCRYPTED 时强制重新提取 key 并重试一次。
DB 加密异常可能发生在
- _check_db_readable()contact.db 探针 key 失效
- DB 读取操作message_0.db DB key 失效探针未覆盖
捕获 DB_ENCRYPTED 阻塞调用 _auto_extract_with_lock(force=True) 重新提取 key
成功后重试一次路由提取失败则原样抛出只重试一次避免死循环
发送类接口send_text 同样安全DB_ENCRYPTED 只会在发送前的
DB 读取阶段_resolve_display_name抛出尚未执行实际 UI 发送
"""
@functools.wraps(func)
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except BridgeError as e:
if e.code != "DB_ENCRYPTED":
raise
logger.warning(
"%s 遇到 DB_ENCRYPTED强制重新提取 key 后重试一次: %s",
func.__name__, e.message,
)
extracted = await _auto_extract_with_lock(force=True)
if not extracted:
raise
logger.info("%s key 重新提取成功,重试请求", func.__name__)
return await func(*args, **kwargs)
# functools.wraps 复制的 __annotations__ 是字符串化注解(因 from __future__
# import annotationsFastAPI 解析 wrapper 签名时拿到字符串注解,无法识别
# Pydantic body 参数,会把它当 query 参数处理导致 422。用 get_type_hints
# 解析为真实类型,并据此重建 __signature__让 FastAPI 能正确识别参数类型。
try:
hints = get_type_hints(func, include_extras=True)
wrapper.__annotations__ = hints
# 基于原始函数签名重建 wrapper 的 __signature__替换 (*args, **kwargs)
orig_sig = inspect.signature(func)
new_params = []
for name, param in orig_sig.parameters.items():
annotation = hints.get(name, param.annotation)
new_params.append(param.replace(annotation=annotation))
wrapper.__signature__ = orig_sig.replace(
parameters=new_params,
return_annotation=hints.get("return", orig_sig.return_annotation),
)
except Exception:
# 解析失败时保持 functools.wraps 的默认行为
pass
return wrapper
# ---------------------------------------------------------------------------
# _resolve_db_state_tuple供 MessageStreamer 使用的适配器)
# ---------------------------------------------------------------------------
async def _resolve_db_state_tuple() -> tuple[bool, Optional[str]]:
"""供 MessageStreamer 使用的 DB 状态解析适配器。
_resolve_db_state() DbState 降维为 (db_accessible, db_error_code)
MessageStreamer 解耦 server.py 内部数据结构
"""
state = await _resolve_db_state()
return (state.db_accessible, state.db_error_code)
# ---------------------------------------------------------------------------
# DbState 与 DB 状态缓存
# ---------------------------------------------------------------------------
@dataclass
class DbState:
"""DB 状态解析结果(由 _resolve_db_state 返回)。
统一三处调用方get_status / _check_db_readable / diagnostic_run
DB 状态判断逻辑避免重复代码与行为不一致
"""
status: str # ok / not_found / need_init / key_invalid / unreadable
db_accessible: bool # 是否可读ok 或 有有效 key
db_error_code: Optional[str] # 不可读时的错误码
salt_hex: Optional[str] # 当前 DB 的 salt加密 DB 才有)
key_hex: Optional[str] # 当前 DB 对应的有效 key无则 None
need_extract: bool # 无有效 key 且需要自动提取
# DB 状态短时缓存:避免高频 /api/status 重复读 salt/验证 key
_db_state_cache: Optional[tuple[float, DbState]] = None
_DB_STATE_CACHE_TTL = 1.0 # 秒
def _invalidate_db_state_cache() -> None:
"""失效 DB 状态缓存key 变化、提取完成时调用)。"""
global _db_state_cache
_db_state_cache = None
# ---------------------------------------------------------------------------
# _resolve_db_state
# ---------------------------------------------------------------------------
async def _resolve_db_state() -> DbState:
"""统一解析 DB 状态、salt、有效 key、pid 失效检测、env/api key 验证。
本函数只做"状态解析"不触发自动提取提取由调用方按 need_extract 决定
单次请求内 salt/pid 只读一次结果携带在 DbState 中供调用方复用
1 秒短时缓存减少高频轮询时的重复 I/O
Returns:
DbState status / db_accessible / db_error_code / salt_hex
/ key_hex / need_extract
"""
global _db_state_cache
# 检查缓存
if _db_state_cache is not None:
cached_at, cached_state = _db_state_cache
if time.monotonic() - cached_at < _DB_STATE_CACHE_TTL:
return cached_state
db_reader = _require_db_reader()
status = await asyncio.to_thread(db_reader.check_db_status)
# 明文 DB 或不可达:直接返回
if status == "ok":
result = DbState(
status=status, db_accessible=True, db_error_code=None,
salt_hex=None, key_hex=None, need_extract=False,
)
_db_state_cache = (time.monotonic(), result)
return result
if status in ("not_found", "unreadable"):
result = DbState(
status=status, db_accessible=False, db_error_code=status,
salt_hex=None, key_hex=None, need_extract=False,
)
_db_state_cache = (time.monotonic(), result)
return result
# status in ("need_init", "key_invalid"):加密 DB
salt_hex = await asyncio.to_thread(_read_db_salt, db_reader)
key = db_reader._get_effective_key(salt_hex) if salt_hex else db_reader._get_effective_key()
# auto_extract 的 key 在进程重启后失效
if key and _state.key_cache.cached and _state.key_cache.source == "auto_extract":
wechat_pid_info = await _get_wechat_pid()
current_pid = wechat_pid_info[0] if wechat_pid_info else None
current_start_time = wechat_pid_info[1] if wechat_pid_info else None
if not _state.key_cache.is_valid_for_pid(current_pid, current_start_time):
_state.key_cache.clear()
db_reader.invalidate_decrypted_cache()
key = None
# env/api 注入的 key 若未验证,快速验证 page14KB避免无效 key 反复失败
if key and _state.key_cache.cached and _state.key_cache.source in ("env", "api") and not _state.key_cache.verified:
verified, _key_mode = await asyncio.to_thread(_verify_db_key, db_reader, key)
if verified:
_state.key_cache.set_meta(verified=True)
else:
# env/api key 验证失败:仅清默认 key保留持久化 salt_to_key
db_reader.clear_default_key()
key = None
# 有有效 keyDB 可读
if key:
result = DbState(
status=status, db_accessible=True, db_error_code="encrypted_key_ok",
salt_hex=salt_hex, key_hex=key, need_extract=False,
)
_db_state_cache = (time.monotonic(), result)
return result
# 无有效 key
init_in_progress = _state.init_state.state == "running"
if init_in_progress:
db_error_code = "init_in_progress"
elif _state.config.auto_extract_enabled:
db_error_code = "need_init"
else:
db_error_code = "encrypted_no_key"
result = DbState(
status=status,
db_accessible=False,
db_error_code=db_error_code,
salt_hex=salt_hex,
key_hex=None,
need_extract=_state.config.auto_extract_enabled and not init_in_progress,
)
_db_state_cache = (time.monotonic(), result)
return result
# ---------------------------------------------------------------------------
# _auto_extract_with_lock
# ---------------------------------------------------------------------------
async def _auto_extract_with_lock(force: bool = False) -> Optional[dict[str, str]]:
"""带锁的自动密钥提取,结果同步到 init_state。
diagnostic_autofix 直接调用阻塞等待结果
_check_db_readable 通过 asyncio.create_task 在后台调用非阻塞
with_db_retry 装饰器在 DB_ENCRYPTED 时强制调用force=True
串行化通过 extract_lock 确保同一时间只有一个提取任务
Args:
force: True 时跳过 _resolve_db_state 探针检查强制重新扫描内存
用于 contact.db 探针通过但 message_0.db key 失效的场景
探针误判 DB 可读常规路径不会触发重新提取
Returns:
salt_hex -> enc_key_hex 映射失败返回 None
"""
async with _state.extract_lock():
# 双重检查持锁后可能已被其他请求提取成功force 模式跳过此检查)
if not force:
state = await _resolve_db_state()
if state.db_accessible:
return _state.db_reader.get_keys() if _state.db_reader else None
if _state.init_state.state == "running":
# 已在运行不应该发生lock 串行化),返回 None
return None
# 标记运行中,阻止后续并发请求重复扫描
_state.init_state.state = "running"
_state.init_state.progress_pct = 0.0
_state.init_state.message = "正在扫描内存提取密钥"
try:
extracted = await _try_auto_extract_key()
if extracted:
_state.init_state.set_success(
len(extracted),
f"成功提取 {len(extracted)} 个密钥",
)
else:
_state.init_state.set_failed("自动提取密钥失败")
return extracted
except Exception as e:
_state.init_state.set_failed(str(e))
return None
finally:
# 兜底:异常路径未设置 success/failed 时回退 idle
if _state.init_state.state == "running":
_state.init_state.state = "idle"
_state.init_state.progress_pct = None
_state.init_state.message = None
# 失效 DB 状态缓存,让下次查询看到新 key
_invalidate_db_state_cache()
# ---------------------------------------------------------------------------
# _check_db_readable
# ---------------------------------------------------------------------------
async def _check_db_readable() -> None:
"""检查 DB 是否可读,不可读时抛出对应错误码。
非阻塞设计1.4.0+
- not_found DB_NOT_FOUND
- unreadable DB_NOT_FOUND
- ok 或有有效 key 不抛异常
- 无有效 key 且提取进行中 DB_ENCRYPTED提示稍后重试
- 无有效 key 且需提取 后台触发提取不等待立即抛 DB_ENCRYPTED
- 无有效 key 且提取已失败 DB_ENCRYPTED提示注入 key
- 自动提取未启用 DB_ENCRYPTED提示注入 key
Raises:
BridgeError(DB_NOT_FOUND / DB_ENCRYPTED)
"""
db_reader = _require_db_reader()
state = await _resolve_db_state()
if state.status == "not_found":
raise BridgeError(code="DB_NOT_FOUND", message="未在 /config 下找到微信消息 DB")
if state.status == "unreadable":
raise BridgeError(code="DB_NOT_FOUND", message="DB 文件存在但不可读(权限问题)")
if state.db_accessible:
# ok 或有有效 key不抛异常
return
# 无有效 key
init_state = _state.init_state.state
if init_state == "running":
pct = _state.init_state.progress_pct or 0
raise BridgeError(
code="DB_ENCRYPTED",
message=f"DB 已加密,密钥提取进行中({pct:.0f}%),请稍后重试",
)
if not state.need_extract:
# 自动提取未启用,或已提取过但当前 salt 无匹配
if init_state == "failed":
raise BridgeError(
code="DB_ENCRYPTED",
message="DB 已加密SQLCipher自动提取密钥失败需通过 POST /api/db/decrypt 注入密钥或设置 WOC_DB_KEY",
)
raise BridgeError(
code="DB_ENCRYPTED",
message="DB 已加密SQLCipher需通过 POST /api/db/decrypt 注入密钥或设置 WOC_DB_KEY",
)
# need_extract=True后台触发提取非阻塞立即返回 503
asyncio.create_task(_auto_extract_with_lock())
raise BridgeError(
code="DB_ENCRYPTED",
message="DB 已加密SQLCipher已触发后台密钥提取请稍后重试",
)
# ---------------------------------------------------------------------------
# _verify_db_key
# ---------------------------------------------------------------------------
def _verify_db_key(
db_reader: DbReader,
key_hex: str,
salt_hex: str | None = None,
) -> tuple[bool, Optional[str]]:
"""验证 key 是否匹配当前 DB 文件,并返回 key_mode。
db_reader._find_db_path 找到 DB 文件 Decryptor.verify_key 验证
验证通过时一并返回 key_modeenc_key / key_material
避免调用方再读一次 page1
Args:
db_reader: DB 读取器
key_hex: 64 位十六进制密钥
salt_hex: 未使用保留参数兼容旧调用自动读取当前 DB salt
Returns:
(verified, key_mode): verified 为是否验证通过
key_mode 为密钥形态验证失败时为 None
"""
db_path = db_reader._find_db_path()
if db_path is None:
return (False, None)
try:
decryptor = Decryptor(key_hex)
verified = decryptor.verify_key(db_path)
if not verified:
return (False, None)
# 验证通过,获取 key_mode复用已读的 page1 逻辑)
key_mode = _get_key_mode(key_hex, db_path)
return (True, key_mode)
except Exception:
return (False, None)
# ---------------------------------------------------------------------------
# _read_db_salt
# ---------------------------------------------------------------------------
def _read_db_salt(db_reader: DbReader) -> str | None:
"""读取当前 DB 文件的 salt同步阻塞"""
db_path = db_reader._find_db_path()
if db_path is None:
return None
return db_reader._read_db_salt(db_path)
# ---------------------------------------------------------------------------
# _get_wechat_pid
# ---------------------------------------------------------------------------
async def _get_wechat_pid() -> tuple[int, float] | None:
"""获取微信主进程 PID 与启动时间。
使用与 key_extractor 一致的进程识别逻辑检查 /proc/<pid>/comm exe
避免 `pgrep -f xwechat` 漏掉主进程主进程命令行不含 xwechat
Returns:
(pid, start_time) None进程未运行
"""
try:
pids = await asyncio.to_thread(_get_wechat_pids)
if not pids:
return None
# 取内存占用最大的进程作为主进程
pid, _rss_kb = pids[0]
# 读 /proc/<pid>/stat 的第 22 字段start_time单位 jiffies
try:
with open(f"/proc/{pid}/stat", "r") as f:
stat_fields = f.read().split()
start_time = float(stat_fields[21]) if len(stat_fields) > 21 else 0.0
except (OSError, IndexError, ValueError):
start_time = 0.0
return (pid, start_time)
except Exception:
return None
# ---------------------------------------------------------------------------
# _try_auto_extract_key
# ---------------------------------------------------------------------------
async def _try_auto_extract_key() -> dict[str, str] | None:
"""尝试从微信进程内存自动提取 SQLCipher 密钥。
流程
1. 检查 auto_extract_enabled 开关
2. 检查微信进程是否运行pgrep
3. 获取全部微信 PID 候选不只取第一个
4. 找到加密 DB 目录作为探针
5. 对每个 PID KeyExtractor.extract_all_keys() 提取 salt->enc_key 映射
6. 成功则缓存到 KeyCache pid / start_time并注入 DbReader
Returns:
salt_hex -> enc_key_hex 映射字典失败返回 None
"""
if not _state.config.auto_extract_enabled:
return None
_require_db_reader()
_require_xdotool()
# 检查微信进程是否运行
wechat_running = await _state.xdotool.is_wechat_running()
if not wechat_running:
return None
# 找到加密 DB 目录作为探针
db_path = await asyncio.to_thread(_state.db_reader._find_db_path)
if db_path is None:
logger.warning("自动提取密钥失败:未找到 DB 路径")
return None
# 优先扫描整个 /config/xwechat_files让 KeyExtractor 自己探测所有微信 PID
# 原 pgrep -f xwechat 会漏掉主进程(主进程命令行里没有 xwechat 字样)
db_dir = "/config/xwechat_files"
if not os.path.isdir(db_dir):
db_dir = os.path.dirname(db_path)
logger.info("自动提取密钥开始,探针目录: %s", db_dir)
try:
# pid=0 让 KeyExtractor 用 /proc 自探测,比 pgrep 更可靠
extractor = KeyExtractor(0, db_path)
key_map = await asyncio.to_thread(extractor.extract_all_keys, db_dir)
if key_map:
# 记录成功时关联的 PID取 key_map 中第一个地址反推太麻烦,
# 这里用 _get_wechat_pid 拿到主进程 PID 做缓存校验)
wechat_pid_info = await _get_wechat_pid()
wechat_pid = wechat_pid_info[0] if wechat_pid_info else 0
start_time = wechat_pid_info[1] if wechat_pid_info else 0.0
# KeyExtractor 内部已通过 page 1 HMAC 验证,无需再次全库验证
# 统一通过 db_reader.set_keys 转发到 KeyCache.set_key_map
# 避免 key_cache 与 db_reader 两份重复同步
_state.db_reader.set_keys(
key_map,
source="auto_extract",
pid=wechat_pid,
start_time=start_time,
)
_invalidate_db_state_cache()
logger.info(
"自动提取 DB 密钥成功(主 PID=%s%d salts来源微信进程内存扫描",
wechat_pid,
len(key_map),
)
return key_map
except Exception as e:
logger.exception("自动提取密钥异常: %s", e)
logger.warning("自动提取 DB 密钥失败:扫描全部微信进程后未找到有效密钥")
return None
# ---------------------------------------------------------------------------
# _get_key_mode
# ---------------------------------------------------------------------------
def _get_key_mode(key_hex: str, db_path: str) -> str | None:
"""获取 key 形态enc_key / key_material
通过读取 DB 1 + _resolve_page1_key_material 验证后返回 mode
"""
try:
with open(db_path, "rb") as f:
page1 = f.read(4096)
key_bytes = bytes.fromhex(key_hex)
result = _resolve_page1_key_material(key_bytes, page1)
if result:
return result[2]
except Exception:
pass
return None
# ---------------------------------------------------------------------------
# run_auto_extract_sync供后台线程同步调用的密钥提取入口
# ---------------------------------------------------------------------------
def run_auto_extract_sync(force: bool = True) -> Optional[dict[str, str]]:
"""供后台线程同步调用的密钥提取入口。
通过 asyncio.run_coroutine_threadsafe 在主事件循环中执行
_auto_extract_with_lock确保与 asyncio 路径共享同一把 extract_lock
避免并发 set_keys() 竞态
routes/db.py _init_task 调用
"""
try:
loop = asyncio.get_event_loop()
except RuntimeError:
# 无事件循环(纯线程环境),创建临时 loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(_auto_extract_with_lock(force=force))
loop.close()
return result
if loop.is_running():
# 主事件循环运行中,用 run_coroutine_threadsafe 投递
future = asyncio.run_coroutine_threadsafe(
_auto_extract_with_lock(force=force), loop
)
return future.result(timeout=120) # 提取最长 120s
else:
return loop.run_until_complete(_auto_extract_with_lock(force=force))