feat: 新增woc-bridge服务及配套能力
新增完整的woc-bridge业务服务,实现微信API代理、密钥自动提取、消息队列限流等功能: 1. 新增bridge核心代码与s6服务配置 2. 新增ptrace权限初始化脚本与docker配置 3. 新增M2M鉴权API代理与环境变量配置 4. 新增SQLCipher解密、密钥缓存、二维码截图等工具模块 5. 完善docker构建与实例部署配置
This commit is contained in:
parent
0036d5911b
commit
a2201d8be3
29
.env.example
29
.env.example
@ -117,3 +117,32 @@ WOC_INSTANCE_MEM_SOFT_MB=1500
|
||||
WOC_INSTANCE_MEM_HARD_MB=2500
|
||||
WOC_WATCHDOG_INTERVAL_SEC=300
|
||||
# WOC_WATCHDOG_HEALTH_FAILS=0
|
||||
|
||||
# ── bridge 业务 API 的 M2M 鉴权 ─────────────────────────────
|
||||
# 外部系统(如 ForcePilot 后端)以 M2M 方式调用 /api/bridge/:id/* 时所需的 Bearer token。
|
||||
# 配置后,这些请求只需带 Authorization: Bearer <token> 即可,无需管理员会话 cookie。
|
||||
# 不配置(留空)时退化为仅允许管理员会话鉴权(兼容旧部署,浏览器访问不受影响)。
|
||||
#
|
||||
# 生成建议: openssl rand -hex 32 (64 位十六进制字符)
|
||||
#
|
||||
# 权限范围: 持有该 token 即可访问全部实例的全部 bridge 业务 API
|
||||
# (发消息/读消息/查联系人/退出登录/重启微信等),无 per-instance / per-action 粒度。
|
||||
# 请妥善保管;轮换时修改 .env 后 `docker compose up -d` 重建面板容器即生效。
|
||||
#
|
||||
# 调用示例(ForcePilot 侧):
|
||||
# GET http://<nas_host>:36080/api/bridge/<instance_id>/api/messages/since
|
||||
# Authorization: Bearer <WOC_BRIDGE_API_TOKEN>
|
||||
WOC_BRIDGE_API_TOKEN=
|
||||
|
||||
# ── woc-bridge 业务 API ──────────────────────────────────────
|
||||
# bridge 服务在实例容器内监听 8088 端口,由面板 /api/bridge/:id/* 反代访问。
|
||||
# 端口 8088 与 Dockerfile EXPOSE / 面板反代 target 三处耦合,不可配置;
|
||||
# 以下配置作用于 bridge 服务行为(由面板 envList 透传给实例容器):
|
||||
# WOC_BRIDGE_SEND_DELAY_MS 发送消息串行化间隔(毫秒),默认 800
|
||||
# WOC_BRIDGE_MAX_BATCH_SIZE 单次消息拉取建议上限,默认 50(/api/status 返回)
|
||||
# WOC_BRIDGE_POLL_INTERVAL_MS 轮询建议间隔(毫秒),默认 2000(/api/status 返回)
|
||||
# WOC_BRIDGE_MAX_CALLS_PER_SEC 单实例每秒调用上限,默认 10
|
||||
WOC_BRIDGE_SEND_DELAY_MS=800
|
||||
WOC_BRIDGE_MAX_BATCH_SIZE=50
|
||||
WOC_BRIDGE_POLL_INTERVAL_MS=2000
|
||||
WOC_BRIDGE_MAX_CALLS_PER_SEC=10
|
||||
|
||||
1045
bridge/db_reader.py
Normal file
1045
bridge/db_reader.py
Normal file
File diff suppressed because it is too large
Load Diff
345
bridge/decryptor.py
Normal file
345
bridge/decryptor.py
Normal file
@ -0,0 +1,345 @@
|
||||
"""纯 Python 微信 4.x Linux SQLCipher/WCDB 解密器。
|
||||
|
||||
参考 wechat-cli-main 项目实现,针对微信 4.x Linux 加密数据库:
|
||||
- 页面大小 4096 字节
|
||||
- 第 1 页前 16 字节为 salt
|
||||
- 保留区 80 字节:IV(16) + HMAC-SHA512(64)
|
||||
- AES-256-CBC 解密
|
||||
- HMAC-SHA512 页校验
|
||||
- mac_salt = salt XOR 0x3A,PBKDF2-HMAC-SHA512 2 轮派生 mac_key
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
|
||||
# SQLite 文件头
|
||||
SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
PAGE_SIZE = 4096
|
||||
KEY_SIZE = 32
|
||||
SALT_SIZE = 16
|
||||
IV_SIZE = 16
|
||||
HMAC_SIZE = 64 # SHA512 HMAC
|
||||
RESERVE_SIZE = 80 # IV(16) + HMAC(64)
|
||||
ROUND_COUNT = 256000 # PBKDF2 迭代轮数(微信 4.x Linux 使用 SHA512)
|
||||
MAC_SALT_XOR = 0x3A # mac_salt = salt ^ 0x3A
|
||||
|
||||
# WAL 文件常量
|
||||
WAL_HEADER_SIZE = 32
|
||||
WAL_FRAME_HEADER_SIZE = 24
|
||||
|
||||
|
||||
def _derive_enc_key(key_material: bytes, salt: bytes) -> bytes:
|
||||
"""从 32 字节 key material 派生 AES 加密密钥(PBKDF2-SHA512 256000 轮)。"""
|
||||
return hashlib.pbkdf2_hmac("sha512", key_material, salt, ROUND_COUNT, dklen=KEY_SIZE)
|
||||
|
||||
|
||||
def _derive_mac_key(enc_key: bytes, salt: bytes) -> bytes:
|
||||
"""派生微信 4.x 页 HMAC 密钥(mac_salt = salt XOR 0x3A,PBKDF2-SHA512 2 轮)。"""
|
||||
mac_salt = bytes(b ^ MAC_SALT_XOR for b in salt)
|
||||
return hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SIZE)
|
||||
|
||||
|
||||
def _compute_page_hmac(mac_key: bytes, page: bytes, page_num: int) -> bytes:
|
||||
"""计算页面 HMAC-SHA512。
|
||||
|
||||
微信 4.x Linux 页 HMAC 计算范围(含 IV):
|
||||
- 第 1 页:salt 之后到 HMAC 之前的数据(即 offset=16 到 data_end=4096-80+16=4032)
|
||||
- 其他页:从头开始到 HMAC 之前的数据(offset=0 到 data_end=4032)
|
||||
最后追加 4 字节小端页号。
|
||||
"""
|
||||
offset = SALT_SIZE if page_num == 1 else 0
|
||||
data_end = PAGE_SIZE - RESERVE_SIZE + IV_SIZE
|
||||
mac = hmac.new(mac_key, digestmod=hashlib.sha512)
|
||||
mac.update(page[offset:data_end])
|
||||
mac.update(page_num.to_bytes(4, "little"))
|
||||
return mac.digest()
|
||||
|
||||
|
||||
def _resolve_page1_key_material(
|
||||
key_material: bytes, page1: bytes
|
||||
) -> tuple[bytes, bytes, str] | None:
|
||||
"""检测输入密钥是否匹配第 1 页 HMAC。
|
||||
|
||||
先尝试把输入当作已派生的 enc_key 直接验证;失败则当作 raw key_material
|
||||
派生 enc_key 后再验证。返回 (enc_key, mac_key, mode) 或 None。
|
||||
"""
|
||||
if len(page1) < PAGE_SIZE:
|
||||
return None
|
||||
|
||||
salt = page1[:SALT_SIZE]
|
||||
stored_page1_hmac = page1[PAGE_SIZE - HMAC_SIZE: PAGE_SIZE]
|
||||
|
||||
# 尝试 1:输入就是 enc_key
|
||||
mac_key = _derive_mac_key(key_material, salt)
|
||||
if hmac.compare_digest(stored_page1_hmac, _compute_page_hmac(mac_key, page1, 1)):
|
||||
return key_material, mac_key, "enc_key"
|
||||
|
||||
# 尝试 2:输入是 raw key_material,需派生 enc_key
|
||||
enc_key = _derive_enc_key(key_material, salt)
|
||||
mac_key = _derive_mac_key(enc_key, salt)
|
||||
if hmac.compare_digest(stored_page1_hmac, _compute_page_hmac(mac_key, page1, 1)):
|
||||
return enc_key, mac_key, "key_material"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _decrypt_page(enc_key: bytes, page: bytes, page_num: int) -> bytes:
|
||||
"""使用 AES-256-CBC 解密单页。
|
||||
|
||||
明文 SQLite 页不携带加密保留区,通过零填充保留页大小不变。
|
||||
"""
|
||||
iv = page[PAGE_SIZE - RESERVE_SIZE: PAGE_SIZE - RESERVE_SIZE + IV_SIZE]
|
||||
offset = SALT_SIZE if page_num == 1 else 0
|
||||
encrypted_page = page[offset: PAGE_SIZE - RESERVE_SIZE]
|
||||
|
||||
cipher = Cipher(
|
||||
algorithms.AES(enc_key),
|
||||
modes.CBC(iv),
|
||||
backend=default_backend(),
|
||||
)
|
||||
decryptor = cipher.decryptor()
|
||||
decrypted_page = decryptor.update(encrypted_page) + decryptor.finalize()
|
||||
|
||||
if page_num == 1:
|
||||
return SQLITE_HEADER + decrypted_page + (b"\x00" * RESERVE_SIZE)
|
||||
return decrypted_page + (b"\x00" * RESERVE_SIZE)
|
||||
|
||||
|
||||
def decrypt_wal(enc_key: bytes, wal_path: str, output_db_path: str) -> int:
|
||||
"""把加密的 WAL 文件合并到已解密的明文 DB 中。
|
||||
|
||||
微信 4.x Linux 使用 SQLCipher WAL 格式,每帧结构为:
|
||||
- frame header (24 bytes): pgno(4) + commit_size(4) + salt1(4) + salt2(4) + checksum(8)
|
||||
- frame page (4096 bytes): 加密页数据
|
||||
|
||||
只合并与 WAL header 中 salt 匹配的帧,避免应用不完整数据。
|
||||
|
||||
Args:
|
||||
enc_key: 32 字节 AES 加密密钥
|
||||
wal_path: 加密 WAL 文件路径
|
||||
output_db_path: 已解密的明文 DB 路径(会被原地 patch)
|
||||
|
||||
Returns:
|
||||
int: 成功合并的帧数
|
||||
"""
|
||||
if not os.path.exists(wal_path):
|
||||
return 0
|
||||
|
||||
wal_size = os.path.getsize(wal_path)
|
||||
if wal_size <= WAL_HEADER_SIZE:
|
||||
return 0
|
||||
|
||||
patched = 0
|
||||
with open(wal_path, "rb") as wf, open(output_db_path, "r+b") as df:
|
||||
wal_header = wf.read(WAL_HEADER_SIZE)
|
||||
if len(wal_header) < WAL_HEADER_SIZE:
|
||||
return 0
|
||||
|
||||
wal_salt1 = struct.unpack(">I", wal_header[16:20])[0]
|
||||
wal_salt2 = struct.unpack(">I", wal_header[20:24])[0]
|
||||
frame_size = WAL_FRAME_HEADER_SIZE + PAGE_SIZE
|
||||
|
||||
while wf.tell() + frame_size <= wal_size:
|
||||
frame_header = wf.read(WAL_FRAME_HEADER_SIZE)
|
||||
if len(frame_header) < WAL_FRAME_HEADER_SIZE:
|
||||
break
|
||||
|
||||
pgno = struct.unpack(">I", frame_header[0:4])[0]
|
||||
frame_salt1 = struct.unpack(">I", frame_header[8:12])[0]
|
||||
frame_salt2 = struct.unpack(">I", frame_header[12:16])[0]
|
||||
encrypted_page = wf.read(PAGE_SIZE)
|
||||
if len(encrypted_page) < PAGE_SIZE:
|
||||
break
|
||||
|
||||
# 过滤异常帧
|
||||
if pgno == 0 or pgno > 10000000:
|
||||
continue
|
||||
# salt 不匹配说明 WAL 已轮换或数据不完整,跳过
|
||||
if frame_salt1 != wal_salt1 or frame_salt2 != wal_salt2:
|
||||
continue
|
||||
|
||||
decrypted_page = _decrypt_page(enc_key, encrypted_page, pgno)
|
||||
df.seek((pgno - 1) * PAGE_SIZE)
|
||||
df.write(decrypted_page)
|
||||
patched += 1
|
||||
|
||||
return patched
|
||||
|
||||
|
||||
class Decryptor:
|
||||
"""微信 4.x Linux SQLCipher/WCDB 数据库解密器。"""
|
||||
|
||||
def __init__(self, key_hex: str):
|
||||
"""初始化解密器,key_hex 为 64 位十六进制密钥。
|
||||
|
||||
Args:
|
||||
key_hex: 64 位十六进制字符串(32 字节 enc_key 或 raw key_material)
|
||||
|
||||
Raises:
|
||||
ValueError: 密钥长度不为 64 或非有效十六进制
|
||||
"""
|
||||
if len(key_hex) != 64:
|
||||
raise ValueError("密钥必须是 64 位十六进制字符串")
|
||||
|
||||
try:
|
||||
self.key_bytes = bytes.fromhex(key_hex)
|
||||
except ValueError:
|
||||
raise ValueError("密钥必须是有效的十六进制字符串")
|
||||
|
||||
self._key_hex = key_hex
|
||||
self._enc_key: bytes | None = None
|
||||
self._mac_key: bytes | None = None
|
||||
self._key_mode: str = ""
|
||||
|
||||
def _resolve(self, page1: bytes) -> bool:
|
||||
"""用第 1 页解析出 enc_key / mac_key / mode,成功返回 True。"""
|
||||
resolved = _resolve_page1_key_material(self.key_bytes, page1)
|
||||
if resolved is None:
|
||||
return False
|
||||
self._enc_key, self._mac_key, self._key_mode = resolved
|
||||
return True
|
||||
|
||||
def decrypt_db(self, enc_db_path: str, output_path: str) -> dict:
|
||||
"""解密微信 4.x 加密数据库到明文 SQLite 文件。
|
||||
|
||||
采用流式分页读写:
|
||||
- 仅把当前页保留在内存,不一次性读入整库。
|
||||
- 明文 SQLite 直接流式复制。
|
||||
|
||||
Args:
|
||||
enc_db_path: 加密 DB 文件路径
|
||||
output_path: 解密后明文 SQLite 文件路径
|
||||
|
||||
Returns:
|
||||
dict: 解密结果统计信息
|
||||
"""
|
||||
result: dict = {
|
||||
"success": False,
|
||||
"key_mode": "",
|
||||
"total_pages": 0,
|
||||
"successful_pages": 0,
|
||||
"failed_pages": 0,
|
||||
"copied_as_sqlite": False,
|
||||
"error": "",
|
||||
}
|
||||
|
||||
try:
|
||||
file_size = os.path.getsize(enc_db_path)
|
||||
except Exception as e:
|
||||
result["error"] = f"stat_error: {e}"
|
||||
return result
|
||||
|
||||
if file_size < PAGE_SIZE:
|
||||
result["error"] = "file_too_small"
|
||||
return result
|
||||
|
||||
try:
|
||||
with open(enc_db_path, "rb") as f:
|
||||
page1 = f.read(PAGE_SIZE)
|
||||
except Exception as e:
|
||||
result["error"] = f"read_error: {e}"
|
||||
return result
|
||||
|
||||
if page1.startswith(SQLITE_HEADER):
|
||||
try:
|
||||
with open(enc_db_path, "rb") as src, open(output_path, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
except Exception as e:
|
||||
result["error"] = f"write_error: {e}"
|
||||
return result
|
||||
result["success"] = True
|
||||
result["copied_as_sqlite"] = True
|
||||
return result
|
||||
|
||||
if not self._resolve(page1):
|
||||
result["error"] = "key_mismatch"
|
||||
return result
|
||||
|
||||
result["key_mode"] = self._key_mode
|
||||
|
||||
total_pages = (file_size + PAGE_SIZE - 1) // PAGE_SIZE
|
||||
result["total_pages"] = total_pages
|
||||
successful_pages = 0
|
||||
failed_pages = 0
|
||||
|
||||
try:
|
||||
with open(enc_db_path, "rb") as src, open(output_path, "wb") as dst:
|
||||
for page_num in range(1, total_pages + 1):
|
||||
page = src.read(PAGE_SIZE)
|
||||
if not page:
|
||||
break
|
||||
if len(page) < PAGE_SIZE:
|
||||
page = page + (b"\x00" * (PAGE_SIZE - len(page)))
|
||||
|
||||
# 先验 HMAC:不匹配说明密钥错误或该页损坏
|
||||
stored_hmac = page[PAGE_SIZE - HMAC_SIZE: PAGE_SIZE]
|
||||
expected_hmac = _compute_page_hmac(self._mac_key, page, page_num)
|
||||
if not hmac.compare_digest(stored_hmac, expected_hmac):
|
||||
# 第 1 页 HMAC 失败说明密钥错误,直接返回失败
|
||||
if page_num == 1:
|
||||
result["error"] = "page1_hmac_mismatch"
|
||||
return result
|
||||
# 其他页失败:写零页跳过,避免污染下游 SQLite
|
||||
failed_pages += 1
|
||||
dst.write(b"\x00" * PAGE_SIZE)
|
||||
continue
|
||||
|
||||
try:
|
||||
dst.write(_decrypt_page(self._enc_key, page, page_num))
|
||||
successful_pages += 1
|
||||
except Exception:
|
||||
failed_pages += 1
|
||||
dst.write(b"\x00" * PAGE_SIZE)
|
||||
except Exception as e:
|
||||
result["error"] = f"write_error: {e}"
|
||||
return result
|
||||
|
||||
result["successful_pages"] = successful_pages
|
||||
result["failed_pages"] = failed_pages
|
||||
# 阈值判断:失败率 > 10% 或全部失败,认为整体失败
|
||||
# 避免极端情况下整库解密全失败仍认为成功,缓存被复用
|
||||
if total_pages > 0:
|
||||
failure_ratio = failed_pages / total_pages
|
||||
if failed_pages == total_pages or failure_ratio > 0.1:
|
||||
result["success"] = False
|
||||
result["error"] = (
|
||||
f"too_many_failed_pages: {failed_pages}/{total_pages} "
|
||||
f"(ratio={failure_ratio:.2%})"
|
||||
)
|
||||
return result
|
||||
result["success"] = True
|
||||
return result
|
||||
|
||||
def verify_key(self, enc_db_path: str) -> bool:
|
||||
"""便捷方法:验证 key 是否匹配指定 DB 文件。"""
|
||||
try:
|
||||
with open(enc_db_path, "rb") as f:
|
||||
data = f.read(PAGE_SIZE)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if len(data) < PAGE_SIZE:
|
||||
return False
|
||||
|
||||
if data.startswith(SQLITE_HEADER):
|
||||
return True
|
||||
|
||||
return self._resolve(data)
|
||||
|
||||
def decrypt_wal(self, wal_path: str, output_db_path: str) -> int:
|
||||
"""使用已解析的 enc_key 合并 WAL 到明文 DB。
|
||||
|
||||
必须先调用 decrypt_db 或 verify_key 解析出 enc_key。
|
||||
"""
|
||||
if self._enc_key is None:
|
||||
raise RuntimeError("enc_key 尚未解析,请先调用 decrypt_db 或 verify_key")
|
||||
return decrypt_wal(self._enc_key, wal_path, output_db_path)
|
||||
341
bridge/key_cache.py
Normal file
341
bridge/key_cache.py
Normal file
@ -0,0 +1,341 @@
|
||||
"""SQLCipher 密钥统一存储与持久化(single source of truth)。
|
||||
|
||||
设计目标:
|
||||
- 统一密钥来源:salt -> enc_key 映射、默认 key、已拒绝 salt、来源/验证/pid 元信息
|
||||
全部在此类管理,DbReader 与 server.py 都通过此类读写,避免双份同步问题
|
||||
- 持久化:自动加载/保存 woc-keys.json,容器重启后复用
|
||||
- 线程安全:内置 threading.Lock
|
||||
- 进程失效检测:pid / start_time 变化时 is_valid_for_pid 返回 False
|
||||
|
||||
使用方式:
|
||||
key_cache = KeyCache(keys_file="/config/woc-keys.json")
|
||||
|
||||
# 注入(server.py / db_reader.py 都可调用)
|
||||
key_cache.set_default_key(env_key, source="env", verified=False)
|
||||
key_cache.set_key(key_hex, salt_hex=salt, source="api", verified=True)
|
||||
key_cache.set_key_map(key_map, source="auto_extract", verified=True, pid=pid)
|
||||
|
||||
# 查询(DbReader 用)
|
||||
key = key_cache.get_key(salt_hex) # 严格按 salt,回退到未拒绝的默认 key
|
||||
has = key_cache.has_keys()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class KeyCache:
|
||||
"""DB 解密密钥统一存储。
|
||||
|
||||
所有密钥状态(salt->key 映射、默认 key、已拒绝 salt、来源/验证/pid)
|
||||
全部在此类管理,DbReader 与 server.py 都通过此类读写,避免双份同步。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
keys_file: str = "/config/woc-keys.json",
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
keys_file: 持久化密钥文件路径(/config/woc-keys.json)
|
||||
"""
|
||||
self.keys_file = keys_file
|
||||
|
||||
# 持久化的 salt -> enc_key 映射
|
||||
self._salt_to_key: dict[str, str] = {}
|
||||
# 默认 key(env 注入或单 key 兼容),不持久化(未按 salt 验证)
|
||||
self._default_key: Optional[str] = None
|
||||
# 已验证默认 key 不匹配的 salt 集合,避免反复触发整库解密失败
|
||||
self._rejected_default_salts: set[str] = set()
|
||||
# 持久化文件中记录的 wxid
|
||||
self._wxid: Optional[str] = None
|
||||
|
||||
# 元信息(不持久化,进程级状态)
|
||||
self.source: Optional[str] = None # env / api / auto_extract
|
||||
self.verified: bool = False
|
||||
self.pid: Optional[int] = None
|
||||
self.start_time: Optional[float] = None
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._load_from_file()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 文件持久化
|
||||
# ------------------------------------------------------------------
|
||||
def _load_from_file(self) -> None:
|
||||
"""从 keys_file 加载密钥到内存。"""
|
||||
if not os.path.isfile(self.keys_file):
|
||||
return
|
||||
try:
|
||||
with open(self.keys_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
self._wxid = data.get("_wxid")
|
||||
self._salt_to_key = {
|
||||
k: v for k, v in data.items()
|
||||
if not k.startswith("_") and isinstance(v, str)
|
||||
}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
def _save_to_file(self) -> None:
|
||||
"""持久化 salt -> key 映射到 keys_file(调用方需持锁)。"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.keys_file), exist_ok=True)
|
||||
data: dict[str, Any] = {
|
||||
"_wxid": self._wxid,
|
||||
"_updated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
}
|
||||
data.update(self._salt_to_key)
|
||||
with open(self.keys_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def set_wxid(self, wxid: Optional[str]) -> None:
|
||||
"""更新持久化文件中的 wxid 字段。"""
|
||||
with self._lock:
|
||||
self._wxid = wxid
|
||||
self._save_to_file()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 查询
|
||||
# ------------------------------------------------------------------
|
||||
def get_key(self, salt_hex: Optional[str] = None) -> Optional[str]:
|
||||
"""返回指定 salt 的有效密钥。
|
||||
|
||||
严格按 salt 查找;未命中时仅对未拒绝过的 salt 回退到默认 key,
|
||||
避免反复用错误默认 key 触发整库解密失败。
|
||||
"""
|
||||
with self._lock:
|
||||
if salt_hex:
|
||||
if salt_hex in self._salt_to_key:
|
||||
return self._salt_to_key[salt_hex]
|
||||
if self._default_key and salt_hex not in self._rejected_default_salts:
|
||||
return self._default_key
|
||||
return None
|
||||
return self._default_key
|
||||
|
||||
def get_default_key(self) -> Optional[str]:
|
||||
"""返回默认密钥(向后兼容旧接口)。"""
|
||||
with self._lock:
|
||||
return self._default_key
|
||||
|
||||
def get_keys(self) -> dict[str, str]:
|
||||
"""返回当前持久化的 salt -> key 映射副本。"""
|
||||
with self._lock:
|
||||
return dict(self._salt_to_key)
|
||||
|
||||
def has_keys(self) -> bool:
|
||||
"""是否有可用的密钥(持久化或默认 key)。"""
|
||||
with self._lock:
|
||||
return bool(self._salt_to_key or self._default_key)
|
||||
|
||||
def has_persisted_keys(self) -> bool:
|
||||
"""是否有持久化到文件的 salt -> key 映射。"""
|
||||
with self._lock:
|
||||
return bool(self._salt_to_key)
|
||||
|
||||
def key_prefix(self) -> Optional[str]:
|
||||
"""返回缓存密钥的摘要(不泄露完整 key)。
|
||||
|
||||
多 salt 时返回数量 + 第一个 key 的前 4/后 4 位。
|
||||
"""
|
||||
with self._lock:
|
||||
if self._default_key:
|
||||
k = self._default_key
|
||||
return f"{k[:4]}...{k[-4:]}"
|
||||
if not self._salt_to_key:
|
||||
return None
|
||||
first_key = next(iter(self._salt_to_key.values()))
|
||||
prefix = f"{first_key[:4]}...{first_key[-4:]}"
|
||||
if len(self._salt_to_key) > 1:
|
||||
return f"{prefix} (+{len(self._salt_to_key) - 1} salts)"
|
||||
return prefix
|
||||
|
||||
@property
|
||||
def cached(self) -> bool:
|
||||
"""是否有缓存的 key(持久化或默认)。"""
|
||||
with self._lock:
|
||||
return self._default_key is not None or bool(self._salt_to_key)
|
||||
|
||||
def is_valid_for_pid(
|
||||
self,
|
||||
current_pid: Optional[int],
|
||||
current_start_time: Optional[float] = None,
|
||||
) -> bool:
|
||||
"""检查缓存的 key 是否对当前进程仍有效。
|
||||
|
||||
pid 变化或 start_time 变化时返回 False(需重新提取)。
|
||||
"""
|
||||
if not self.cached:
|
||||
return False
|
||||
with self._lock:
|
||||
if current_pid is not None and self.pid is not None and current_pid != self.pid:
|
||||
return False
|
||||
if current_start_time is not None and self.start_time is not None and current_start_time != self.start_time:
|
||||
return False
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 注入
|
||||
# ------------------------------------------------------------------
|
||||
def set_key(
|
||||
self,
|
||||
key_hex: Optional[str],
|
||||
salt_hex: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
verified: bool = True,
|
||||
pid: Optional[int] = None,
|
||||
start_time: Optional[float] = None,
|
||||
) -> None:
|
||||
"""设置单个密钥。
|
||||
|
||||
Args:
|
||||
key_hex: 64 位十六进制密钥;为 None 时清空所有密钥与元信息
|
||||
salt_hex: 为 None 时设默认 key,否则设 salt -> key 映射
|
||||
source: env / api / auto_extract
|
||||
verified: 是否已通过 HMAC 验证
|
||||
pid / start_time: 自动提取时关联的进程信息
|
||||
"""
|
||||
with self._lock:
|
||||
if key_hex is None:
|
||||
# 清空所有
|
||||
self._salt_to_key.clear()
|
||||
self._default_key = None
|
||||
self._rejected_default_salts.clear()
|
||||
self.source = None
|
||||
self.verified = False
|
||||
self.pid = None
|
||||
self.start_time = None
|
||||
self._save_to_file()
|
||||
return
|
||||
|
||||
if salt_hex is None:
|
||||
self._default_key = key_hex
|
||||
else:
|
||||
self._salt_to_key[salt_hex] = key_hex
|
||||
# 新 key 注入时清除该 salt 的拒绝标记
|
||||
self._rejected_default_salts.discard(salt_hex)
|
||||
self._save_to_file()
|
||||
|
||||
if source is not None:
|
||||
self.source = source
|
||||
self.verified = verified
|
||||
if pid is not None:
|
||||
self.pid = pid
|
||||
if start_time is not None:
|
||||
self.start_time = start_time
|
||||
|
||||
def set_default_key(
|
||||
self,
|
||||
key_hex: Optional[str],
|
||||
source: Optional[str] = None,
|
||||
verified: bool = False,
|
||||
) -> None:
|
||||
"""设置默认密钥(env 注入专用),不影响 salt_to_key 映射。
|
||||
|
||||
与 set_key(salt_hex=None) 的区别:
|
||||
- set_key(salt_hex=None) 会清空 salt_to_key 并覆盖 woc-keys.json
|
||||
- set_default_key 仅设 _default_key,保留已持久化的多 salt 映射
|
||||
|
||||
用于 WOC_DB_KEY 环境变量加载:容器重启时先从 woc-keys.json 恢复多 salt
|
||||
映射,再用 env key 作为默认兜底,二者共存而非互斥。
|
||||
"""
|
||||
with self._lock:
|
||||
self._default_key = key_hex
|
||||
if source is not None:
|
||||
self.source = source
|
||||
self.verified = verified
|
||||
# 默认 key 不持久化到文件(未按 salt 验证,避免污染 woc-keys.json)
|
||||
|
||||
def set_key_map(
|
||||
self,
|
||||
key_map: dict[str, str],
|
||||
source: Optional[str] = None,
|
||||
verified: bool = True,
|
||||
pid: Optional[int] = None,
|
||||
start_time: Optional[float] = None,
|
||||
persist: bool = True,
|
||||
) -> None:
|
||||
"""批量设置 salt -> enc_key 映射。
|
||||
|
||||
Args:
|
||||
key_map: {salt_hex: enc_key_hex}
|
||||
source: env / api / auto_extract
|
||||
verified: 是否已通过 HMAC 验证
|
||||
pid / start_time: 自动提取时关联的进程信息
|
||||
persist: 是否持久化到文件(默认 True)
|
||||
"""
|
||||
with self._lock:
|
||||
self._salt_to_key = dict(key_map)
|
||||
# 新 keymap 注入时清空拒绝标记(旧 salt 可能已重新匹配)
|
||||
self._rejected_default_salts.clear()
|
||||
if source is not None:
|
||||
self.source = source
|
||||
self.verified = verified
|
||||
if pid is not None:
|
||||
self.pid = pid
|
||||
if start_time is not None:
|
||||
self.start_time = start_time
|
||||
if persist:
|
||||
self._save_to_file()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""清空所有密钥与元信息(含持久化文件)。"""
|
||||
with self._lock:
|
||||
self._salt_to_key.clear()
|
||||
self._default_key = None
|
||||
self._rejected_default_salts.clear()
|
||||
self.source = None
|
||||
self.verified = False
|
||||
self.pid = None
|
||||
self.start_time = None
|
||||
self._save_to_file()
|
||||
|
||||
def clear_default_key(self) -> None:
|
||||
"""仅清空默认 key(保留 salt_to_key 持久化映射)。
|
||||
|
||||
用于 env key 验证失败后清除默认 key,不影响已持久化的多 salt 映射。
|
||||
"""
|
||||
with self._lock:
|
||||
self._default_key = None
|
||||
self._rejected_default_salts.clear()
|
||||
|
||||
def mark_default_key_mismatch(self, salt_hex: str) -> None:
|
||||
"""标记默认 key 不匹配该 salt。
|
||||
|
||||
由 DbReader._ensure_decrypted 在解密失败且使用的是默认 key 时调用,
|
||||
避免后续相同 salt 的查询反复触发整库解密。
|
||||
"""
|
||||
with self._lock:
|
||||
if self._default_key:
|
||||
self._rejected_default_salts.add(salt_hex)
|
||||
|
||||
def set_meta(
|
||||
self,
|
||||
source: Optional[str] = None,
|
||||
verified: Optional[bool] = None,
|
||||
pid: Optional[int] = None,
|
||||
start_time: Optional[float] = None,
|
||||
) -> None:
|
||||
"""更新元信息(不影响密钥存储)。
|
||||
|
||||
用于外部已通过其他途径设置 key 后补充元信息(如 source="api")。
|
||||
所有参数为 None 时不更新对应字段。
|
||||
"""
|
||||
with self._lock:
|
||||
if source is not None:
|
||||
self.source = source
|
||||
if verified is not None:
|
||||
self.verified = verified
|
||||
if pid is not None:
|
||||
self.pid = pid
|
||||
if start_time is not None:
|
||||
self.start_time = start_time
|
||||
532
bridge/key_extractor.py
Normal file
532
bridge/key_extractor.py
Normal file
@ -0,0 +1,532 @@
|
||||
"""Linux 微信进程内存密钥提取器。
|
||||
|
||||
参考 wechat-cli-main 实现:
|
||||
- 通过 /proc/<pid>/maps 枚举微信进程可读内存段
|
||||
- 通过 /proc/<pid>/mem 读取内存
|
||||
- 匹配 x'<hex>' 模式(64 位 enc_key、96 位 enc_key+salt、更长 hex)
|
||||
- 用 HMAC-SHA512 校验确认 enc_key
|
||||
- 返回 salt_hex -> enc_key_hex 映射
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac as hmac_mod
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
PAGE_SZ = 4096
|
||||
KEY_SZ = 32
|
||||
SALT_SZ = 16
|
||||
IV_SIZE = 16
|
||||
HMAC_SIZE = 64
|
||||
RESERVE_SIZE = 80
|
||||
MAC_SALT_XOR = 0x3A
|
||||
|
||||
# 单次内存读取上限(避免超大映射导致内存爆炸)
|
||||
_MAX_REGION_SIZE = 500 * 1024 * 1024
|
||||
|
||||
_KNOWN_COMMS = {"wechat", "wechatappex", "weixin"}
|
||||
# crashpad / 渲染辅助子进程:内存中不含主进程的 DB 密钥,扫描会浪费时间
|
||||
_EXCLUDE_COMM_KEYWORDS = ("crashpad", "crashpad_handler", "renderer", "gpu-process")
|
||||
_INTERPRETER_PREFIXES = ("python", "bash", "sh", "zsh", "node", "perl", "ruby")
|
||||
_SKIP_MAPPINGS = {"[vdso]", "[vsyscall]", "[vvar]"}
|
||||
_SKIP_PATH_PREFIXES = ("/usr/lib/", "/lib/", "/usr/share/")
|
||||
|
||||
|
||||
def _safe_readlink(path: str) -> str:
|
||||
try:
|
||||
return os.path.realpath(os.readlink(path))
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _is_wechat_process(pid: int) -> bool:
|
||||
"""检查 pid 是否为微信主进程(排除 crashpad 等辅助子进程)。
|
||||
|
||||
排除逻辑:
|
||||
- crashpad_handler / crashpad 子进程:内存中无 DB 密钥,扫描浪费时间
|
||||
- 渲染/GPU 辅助进程:同上
|
||||
- 解释器进程(python/bash 等):避免误中
|
||||
"""
|
||||
if pid == os.getpid():
|
||||
return False
|
||||
try:
|
||||
with open(f"/proc/{pid}/comm", encoding="utf-8", errors="replace") as f:
|
||||
comm = f.read().strip()
|
||||
comm_lower = comm.lower()
|
||||
# 排除 crashpad 等辅助子进程
|
||||
if any(kw in comm_lower for kw in _EXCLUDE_COMM_KEYWORDS):
|
||||
return False
|
||||
if comm_lower in _KNOWN_COMMS:
|
||||
return True
|
||||
exe_path = _safe_readlink(f"/proc/{pid}/exe")
|
||||
exe_name = os.path.basename(exe_path)
|
||||
if any(exe_name.lower().startswith(p) for p in _INTERPRETER_PREFIXES):
|
||||
return False
|
||||
return "wechat" in exe_name.lower() or "weixin" in exe_name.lower()
|
||||
except (PermissionError, FileNotFoundError, ProcessLookupError):
|
||||
return False
|
||||
|
||||
|
||||
def _get_wechat_pids() -> list[tuple[int, int]]:
|
||||
"""返回疑似微信进程的 (pid, rss_kb) 列表,按内存占用降序。"""
|
||||
pids: list[tuple[int, int]] = []
|
||||
for pid_str in os.listdir("/proc"):
|
||||
if not pid_str.isdigit():
|
||||
continue
|
||||
pid = int(pid_str)
|
||||
try:
|
||||
if not _is_wechat_process(pid):
|
||||
continue
|
||||
with open(f"/proc/{pid}/statm", encoding="utf-8") as f:
|
||||
rss_pages = int(f.read().split()[1])
|
||||
rss_kb = rss_pages * 4
|
||||
pids.append((pid, rss_kb))
|
||||
except (PermissionError, FileNotFoundError, ProcessLookupError, OSError, ValueError):
|
||||
continue
|
||||
pids.sort(key=lambda x: x[1], reverse=True)
|
||||
return pids
|
||||
|
||||
|
||||
def _get_readable_regions(pid: int, full_scan: bool = False) -> list[tuple[int, int, str]]:
|
||||
"""解析 /proc/<pid>/maps,返回可读内存区域 (base, size, name) 列表。
|
||||
|
||||
Args:
|
||||
pid: 目标进程 PID
|
||||
full_scan: 为 True 时不跳过系统库路径(更慢但更全);默认按已知路径过滤
|
||||
"""
|
||||
regions: list[tuple[int, int, str]] = []
|
||||
maps_path = f"/proc/{pid}/maps"
|
||||
with open(maps_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
perms = parts[1]
|
||||
if "r" not in perms:
|
||||
continue
|
||||
mapping_name = parts[5] if len(parts) >= 6 else ""
|
||||
if mapping_name in _SKIP_MAPPINGS:
|
||||
continue
|
||||
if not full_scan:
|
||||
mapping_lower = mapping_name.lower()
|
||||
if (
|
||||
any(mapping_name.startswith(p) for p in _SKIP_PATH_PREFIXES)
|
||||
and "wcdb" not in mapping_lower
|
||||
and "wechat" not in mapping_lower
|
||||
and "weixin" not in mapping_lower
|
||||
):
|
||||
continue
|
||||
start_s, end_s = parts[0].split("-")
|
||||
start = int(start_s, 16)
|
||||
size = int(end_s, 16) - start
|
||||
if 0 < size < _MAX_REGION_SIZE:
|
||||
regions.append((start, size, mapping_name))
|
||||
return regions
|
||||
|
||||
|
||||
def _collect_db_files(db_dir: str) -> tuple[list[tuple[str, str, int, str, bytes]], dict[str, list[str]]]:
|
||||
"""遍历 db_dir 收集所有 .db 文件及其 salt。
|
||||
|
||||
Returns:
|
||||
db_files: [(rel_path, abs_path, size, salt_hex, page1_bytes), ...]
|
||||
salt_to_dbs: {salt_hex: [rel_path, ...]}
|
||||
"""
|
||||
db_files: list[tuple[str, str, int, str, bytes]] = []
|
||||
salt_to_dbs: dict[str, list[str]] = {}
|
||||
if not os.path.isdir(db_dir):
|
||||
return db_files, salt_to_dbs
|
||||
for root, _, files in os.walk(db_dir):
|
||||
for name in files:
|
||||
if not name.endswith(".db") or name.endswith("-wal") or name.endswith("-shm"):
|
||||
continue
|
||||
path = os.path.join(root, name)
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
continue
|
||||
if size < PAGE_SZ:
|
||||
continue
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
page1 = f.read(PAGE_SZ)
|
||||
except OSError:
|
||||
continue
|
||||
if page1.startswith(b"SQLite format 3\x00"):
|
||||
continue
|
||||
rel = os.path.relpath(path, db_dir)
|
||||
salt = page1[:SALT_SZ].hex()
|
||||
db_files.append((rel, path, size, salt, page1))
|
||||
salt_to_dbs.setdefault(salt, []).append(rel)
|
||||
return db_files, salt_to_dbs
|
||||
|
||||
|
||||
def _verify_enc_key(enc_key: bytes, page1: bytes) -> bool:
|
||||
"""通过 HMAC-SHA512 校验 page 1 验证 enc_key 是否正确。"""
|
||||
salt = page1[:SALT_SZ]
|
||||
mac_salt = bytes(b ^ MAC_SALT_XOR for b in salt)
|
||||
mac_key = hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ)
|
||||
hmac_data = page1[SALT_SZ: PAGE_SZ - RESERVE_SIZE + IV_SIZE]
|
||||
stored_hmac = page1[PAGE_SZ - HMAC_SIZE: PAGE_SZ]
|
||||
hm = hmac_mod.new(mac_key, hmac_data, hashlib.sha512)
|
||||
hm.update(struct.pack("<I", 1))
|
||||
return hm.digest() == stored_hmac
|
||||
|
||||
|
||||
class KeyExtractor:
|
||||
"""Linux 微信进程内存密钥提取器。"""
|
||||
|
||||
def __init__(self, wechat_pid: int, probe_db_path: str):
|
||||
"""
|
||||
Args:
|
||||
wechat_pid: 微信进程 PID(为 0 时自动检测)
|
||||
probe_db_path: 用于验证 key 的加密 DB 文件路径,或包含 DB 的目录
|
||||
"""
|
||||
self.wechat_pid = wechat_pid
|
||||
self.probe_db_path = probe_db_path
|
||||
self._hex_re = re.compile(rb"x'([0-9a-fA-F]{64,192})'")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 旧接口兼容:返回 probe DB 对应 salt 的 enc_key
|
||||
# ------------------------------------------------------------------
|
||||
def extract_key(self) -> str | None:
|
||||
"""扫描内存提取与 probe_db_path 匹配的 SQLCipher 密钥。
|
||||
|
||||
Returns:
|
||||
64 位十六进制 enc_key 字符串,未找到返回 None
|
||||
"""
|
||||
db_dir = self.probe_db_path
|
||||
if os.path.isfile(db_dir):
|
||||
db_dir = os.path.dirname(db_dir)
|
||||
key_map = self.extract_all_keys(db_dir)
|
||||
|
||||
# 优先匹配 probe_db_path 自己的 salt
|
||||
probe_salt = None
|
||||
if os.path.isfile(self.probe_db_path):
|
||||
try:
|
||||
with open(self.probe_db_path, "rb") as f:
|
||||
page1 = f.read(PAGE_SZ)
|
||||
if not page1.startswith(b"SQLite format 3\x00"):
|
||||
probe_salt = page1[:SALT_SZ].hex()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if probe_salt and probe_salt in key_map:
|
||||
return key_map[probe_salt]
|
||||
|
||||
# 否则返回任意一个找到的 key
|
||||
if key_map:
|
||||
return next(iter(key_map.values()))
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 新接口:返回 salt_hex -> enc_key_hex 映射
|
||||
# ------------------------------------------------------------------
|
||||
def extract_all_keys(
|
||||
self,
|
||||
db_dir: str | None = None,
|
||||
full_scan: bool = False,
|
||||
) -> dict[str, str]:
|
||||
"""扫描内存,为 db_dir 下每个加密 DB salt 提取 enc_key。
|
||||
|
||||
Args:
|
||||
db_dir: 微信数据库目录。默认为 probe_db_path 所在目录。
|
||||
full_scan: 是否完整扫描所有可读内存区域(默认优先扫描 [heap],未找全再扫其它)。
|
||||
|
||||
Returns:
|
||||
dict: {salt_hex: enc_key_hex}
|
||||
"""
|
||||
if db_dir is None:
|
||||
db_dir = self.probe_db_path
|
||||
if os.path.isfile(db_dir):
|
||||
db_dir = os.path.dirname(db_dir)
|
||||
|
||||
db_files, salt_to_dbs = _collect_db_files(db_dir)
|
||||
if not db_files:
|
||||
_logger.warning("在 %s 下未找到加密 DB 文件", db_dir)
|
||||
return {}
|
||||
|
||||
_logger.info(
|
||||
"找到 %d 个加密 DB,%d 个不同 salt",
|
||||
len(db_files),
|
||||
len(salt_to_dbs),
|
||||
)
|
||||
|
||||
if self.wechat_pid:
|
||||
pids = [(self.wechat_pid, 0)]
|
||||
else:
|
||||
pids = _get_wechat_pids()
|
||||
if not pids:
|
||||
_logger.warning("未检测到微信进程")
|
||||
return {}
|
||||
_logger.info(
|
||||
"检测到 %d 个微信进程候选: %s",
|
||||
len(pids),
|
||||
[p for p, _ in pids],
|
||||
)
|
||||
|
||||
key_map: dict[str, str] = {}
|
||||
remaining = set(salt_to_dbs.keys())
|
||||
|
||||
for pid, _rss_kb in pids:
|
||||
if not remaining:
|
||||
break
|
||||
try:
|
||||
regions = _get_readable_regions(pid, full_scan=full_scan)
|
||||
except (PermissionError, FileNotFoundError, ProcessLookupError) as e:
|
||||
_logger.warning("无法读取 /proc/%s/maps: %s", pid, e)
|
||||
continue
|
||||
|
||||
if not full_scan:
|
||||
# heap 优先,其次是 stack,再按区域大小降序
|
||||
def _heap_first_key(item: tuple[int, int, str]) -> tuple[int, int, str]:
|
||||
name = item[2].lower()
|
||||
if name == "[heap]":
|
||||
return (0, 0, name)
|
||||
if name == "[stack]":
|
||||
return (1, 0, name)
|
||||
return (2, -item[1], name)
|
||||
|
||||
regions.sort(key=_heap_first_key)
|
||||
|
||||
total_size = sum(size for _, size, _ in regions)
|
||||
total_mib = total_size / (1024 * 1024)
|
||||
_logger.info(
|
||||
"PID %s 开始扫描 %d 个区域,合计 %.1f MiB(full_scan=%s)",
|
||||
pid,
|
||||
len(regions),
|
||||
total_mib,
|
||||
full_scan,
|
||||
)
|
||||
|
||||
try:
|
||||
mem = open(f"/proc/{pid}/mem", "rb")
|
||||
except (PermissionError, FileNotFoundError, ProcessLookupError) as e:
|
||||
_logger.warning("无法打开 /proc/%s/mem: %s", pid, e)
|
||||
continue
|
||||
|
||||
try:
|
||||
for idx, (base, size, name) in enumerate(regions):
|
||||
if not remaining:
|
||||
break
|
||||
_logger.info(
|
||||
"区域 %d/%d PID=%s [%-16s] 0x%016x size=%.1fMiB 剩余salt=%d",
|
||||
idx + 1,
|
||||
len(regions),
|
||||
pid,
|
||||
name or "?",
|
||||
base,
|
||||
size / (1024 * 1024),
|
||||
len(remaining),
|
||||
)
|
||||
try:
|
||||
self._scan_region(
|
||||
mem, base, size, name,
|
||||
db_files, key_map, remaining, pid,
|
||||
)
|
||||
except (OSError, ValueError) as e:
|
||||
_logger.debug("读取区域失败 PID=%s addr=0x%016x: %s", pid, base, e)
|
||||
continue
|
||||
_logger.info(
|
||||
"完成区域 %d/%d,已找到 %d/%d 个 enc_key",
|
||||
idx + 1,
|
||||
len(regions),
|
||||
len(key_map),
|
||||
len(salt_to_dbs),
|
||||
)
|
||||
finally:
|
||||
mem.close()
|
||||
|
||||
# 交叉验证:用已找到的 key 验证未匹配的 salt(多 DB 可能共用 enc_key)
|
||||
self._cross_verify(db_files, key_map, remaining)
|
||||
|
||||
if key_map:
|
||||
_logger.info(
|
||||
"成功提取 %d/%d 个 salt 的 enc_key",
|
||||
len(key_map),
|
||||
len(salt_to_dbs),
|
||||
)
|
||||
else:
|
||||
_logger.warning("扫描完成,未找到任何有效 enc_key")
|
||||
return key_map
|
||||
|
||||
def _scan_buffer(
|
||||
self,
|
||||
data: bytes,
|
||||
db_files: list[tuple[str, str, int, str, bytes]],
|
||||
key_map: dict[str, str],
|
||||
remaining: set[str],
|
||||
base_addr: int,
|
||||
pid: int,
|
||||
) -> None:
|
||||
"""扫描一段内存数据,匹配 x'hex' 模式并验证。
|
||||
|
||||
hex 长度分支覆盖:
|
||||
- 64:纯 enc_key,尝试匹配所有剩余 salt
|
||||
- 96:enc_key(64) + salt(32),按指定 salt 验证
|
||||
- >96 且偶数:取前 64 为 enc_key,末 32 为 salt
|
||||
- 66~94 偶数:无明确 salt,按纯 enc_key 尝试所有 salt(覆盖 64~192 全范围)
|
||||
"""
|
||||
for m in self._hex_re.finditer(data):
|
||||
hex_str = m.group(1).decode()
|
||||
addr = base_addr + m.start()
|
||||
hex_len = len(hex_str)
|
||||
|
||||
if hex_len == 64:
|
||||
enc_key_hex = hex_str
|
||||
self._try_key_all_salts(enc_key_hex, db_files, key_map, remaining, addr, pid)
|
||||
elif hex_len == 96:
|
||||
enc_key_hex = hex_str[:64]
|
||||
salt_hex = hex_str[64:]
|
||||
self._try_key(enc_key_hex, salt_hex, db_files, key_map, remaining, addr, pid)
|
||||
elif hex_len > 96 and hex_len % 2 == 0:
|
||||
enc_key_hex = hex_str[:64]
|
||||
salt_hex = hex_str[-32:]
|
||||
self._try_key(enc_key_hex, salt_hex, db_files, key_map, remaining, addr, pid)
|
||||
elif hex_len > 64 and hex_len < 96 and hex_len % 2 == 0:
|
||||
# 66~94 长度:无明确 salt 区,按纯 enc_key 尝试所有 salt
|
||||
enc_key_hex = hex_str[:64]
|
||||
self._try_key_all_salts(enc_key_hex, db_files, key_map, remaining, addr, pid)
|
||||
|
||||
def _scan_region(
|
||||
self,
|
||||
mem,
|
||||
base: int,
|
||||
size: int,
|
||||
name: str,
|
||||
db_files: list[tuple[str, str, int, str, bytes]],
|
||||
key_map: dict[str, str],
|
||||
remaining: set[str],
|
||||
pid: int,
|
||||
) -> None:
|
||||
"""流式读取并扫描一个内存区域,按 MiB 输出进度。
|
||||
|
||||
采用 8 MiB 分块 + 256 字节重叠,避免超大区域一次性读入内存,同时保证
|
||||
x'hex' 模式跨块边界时也能被匹配。
|
||||
"""
|
||||
chunk_size = 8 * 1024 * 1024
|
||||
overlap = 256
|
||||
tail = b""
|
||||
position = 0
|
||||
last_log_mib = -1
|
||||
region_mib = size / (1024 * 1024)
|
||||
|
||||
while position < size:
|
||||
to_read = min(chunk_size, size - position)
|
||||
try:
|
||||
mem.seek(base + position)
|
||||
chunk = mem.read(to_read)
|
||||
except (OSError, ValueError):
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
data = tail + chunk
|
||||
buffer_base = base + position - len(tail)
|
||||
self._scan_buffer(data, db_files, key_map, remaining, buffer_base, pid)
|
||||
|
||||
position += len(chunk)
|
||||
scanned_mib = position / (1024 * 1024)
|
||||
current_mib = int(scanned_mib)
|
||||
if current_mib > last_log_mib:
|
||||
last_log_mib = current_mib
|
||||
_logger.info(
|
||||
"PID %s [%-16s] 已扫描 %.1f/%.1f MiB,已找到 %d 个 enc_key",
|
||||
pid,
|
||||
name or "?",
|
||||
scanned_mib,
|
||||
region_mib,
|
||||
len(key_map),
|
||||
)
|
||||
|
||||
tail = data[-overlap:] if len(data) >= overlap else data
|
||||
|
||||
def _try_key(
|
||||
self,
|
||||
enc_key_hex: str,
|
||||
salt_hex: str,
|
||||
db_files: list[tuple[str, str, int, str, bytes]],
|
||||
key_map: dict[str, str],
|
||||
remaining: set[str],
|
||||
addr: int,
|
||||
pid: int,
|
||||
) -> None:
|
||||
"""尝试用一个 enc_key 匹配指定 salt。"""
|
||||
if salt_hex not in remaining:
|
||||
return
|
||||
try:
|
||||
enc_key = bytes.fromhex(enc_key_hex)
|
||||
except ValueError:
|
||||
return
|
||||
for _rel, _path, _sz, s, page1 in db_files:
|
||||
if s != salt_hex:
|
||||
continue
|
||||
if _verify_enc_key(enc_key, page1):
|
||||
key_map[salt_hex] = enc_key_hex
|
||||
remaining.discard(salt_hex)
|
||||
_logger.info(
|
||||
"找到 enc_key: salt=%s PID=%s addr=0x%016x",
|
||||
salt_hex,
|
||||
pid,
|
||||
addr,
|
||||
)
|
||||
break
|
||||
|
||||
def _try_key_all_salts(
|
||||
self,
|
||||
enc_key_hex: str,
|
||||
db_files: list[tuple[str, str, int, str, bytes]],
|
||||
key_map: dict[str, str],
|
||||
remaining: set[str],
|
||||
addr: int,
|
||||
pid: int,
|
||||
) -> None:
|
||||
"""尝试用一个 64-hex enc_key 匹配所有剩余 salt。"""
|
||||
if not remaining:
|
||||
return
|
||||
try:
|
||||
enc_key = bytes.fromhex(enc_key_hex)
|
||||
except ValueError:
|
||||
return
|
||||
for _rel, _path, _sz, salt_hex, page1 in db_files:
|
||||
if salt_hex not in remaining:
|
||||
continue
|
||||
if _verify_enc_key(enc_key, page1):
|
||||
key_map[salt_hex] = enc_key_hex
|
||||
remaining.discard(salt_hex)
|
||||
_logger.info(
|
||||
"找到 enc_key: salt=%s PID=%s addr=0x%016x",
|
||||
salt_hex,
|
||||
pid,
|
||||
addr,
|
||||
)
|
||||
|
||||
def _cross_verify(
|
||||
self,
|
||||
db_files: list[tuple[str, str, int, str, bytes]],
|
||||
key_map: dict[str, str],
|
||||
remaining: set[str],
|
||||
) -> None:
|
||||
"""用已找到的 key 交叉验证剩余 salt。"""
|
||||
if not remaining or not key_map:
|
||||
return
|
||||
for _rel, _path, _sz, salt_hex, page1 in db_files:
|
||||
if salt_hex not in remaining:
|
||||
continue
|
||||
for known_salt, known_key_hex in key_map.items():
|
||||
enc_key = bytes.fromhex(known_key_hex)
|
||||
if _verify_enc_key(enc_key, page1):
|
||||
key_map[salt_hex] = known_key_hex
|
||||
remaining.discard(salt_hex)
|
||||
_logger.info(
|
||||
"交叉验证成功: salt=%s 复用 salt=%s 的 enc_key",
|
||||
salt_hex,
|
||||
known_salt,
|
||||
)
|
||||
break
|
||||
408
bridge/models.py
Normal file
408
bridge/models.py
Normal file
@ -0,0 +1,408 @@
|
||||
"""woc-bridge 数据模型与错误码定义。
|
||||
|
||||
本模块定义规格中所有接口的请求/响应 Pydantic v2 schema、预定义错误码常量,
|
||||
以及统一的 BridgeError 异常类。路由层直接 raise BridgeError,由 server.py 中的
|
||||
全局异常处理器统一捕获并转换为 ErrorResponse。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 登录态枚举
|
||||
# ---------------------------------------------------------------------------
|
||||
class LoginState(str, Enum):
|
||||
"""微信登录状态枚举。"""
|
||||
|
||||
NOT_RUNNING = "not_running"
|
||||
NOT_LOGGED_IN = "not_logged_in"
|
||||
LOGGING_IN = "logging_in"
|
||||
LOGGED_IN = "logged_in"
|
||||
LOGGED_OUT = "logged_out"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 错误码定义(与 spec 一致)
|
||||
# ---------------------------------------------------------------------------
|
||||
# 每个错误码对应一个 HTTP 状态码
|
||||
ERROR_CODES: dict[str, int] = {
|
||||
"WECHAT_NOT_RUNNING": 503,
|
||||
"WECHAT_NOT_LOGGED_IN": 401,
|
||||
"WINDOW_NOT_FOUND": 503,
|
||||
"CONTACT_NOT_FOUND": 404,
|
||||
"SEND_FAILED": 500,
|
||||
"DB_LOCKED": 503,
|
||||
"DB_NOT_FOUND": 500,
|
||||
"INVALID_PARAMS": 400,
|
||||
"BRIDGE_INTERNAL_ERROR": 500,
|
||||
"LOGIN_TIMEOUT": 408,
|
||||
"MEDIA_NOT_FOUND": 404,
|
||||
"RATE_LIMITED": 429,
|
||||
"DB_ENCRYPTED": 503,
|
||||
"DB_NEED_INIT": 503,
|
||||
"DB_INIT_IN_PROGRESS": 503,
|
||||
"DB_KEY_INVALID": 503,
|
||||
"LOGOUT_FAILED": 500,
|
||||
"RESTART_TIMEOUT": 408,
|
||||
}
|
||||
|
||||
|
||||
class BridgeError(Exception):
|
||||
"""bridge 统一业务异常。
|
||||
|
||||
路由层直接 raise BridgeError(code="...", message="...", details=...),
|
||||
由全局异常处理器捕获后转换为统一 ErrorResponse 并设置对应 HTTP 状态码。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str = "BRIDGE_INTERNAL_ERROR",
|
||||
message: str = "bridge internal error",
|
||||
http_status: Optional[int] = None,
|
||||
details: Optional[Any] = None,
|
||||
) -> None:
|
||||
self.code = code
|
||||
self.message = message
|
||||
# 若未显式指定 http_status,则查表取默认值;查不到则回落到 500
|
||||
self.http_status = http_status if http_status is not None else ERROR_CODES.get(code, 500)
|
||||
self.details = details
|
||||
super().__init__(f"[{code}] {message}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 通用错误响应
|
||||
# ---------------------------------------------------------------------------
|
||||
class ErrorResponse(BaseModel):
|
||||
"""统一错误响应结构。"""
|
||||
|
||||
success: bool = Field(default=False, description="固定为 false")
|
||||
error: dict[str, Any] = Field(
|
||||
description="错误详情,含 code/message/details 三个字段"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 状态接口(GET /api/status)
|
||||
# ---------------------------------------------------------------------------
|
||||
class StatusResponse(BaseModel):
|
||||
"""bridge 状态响应。
|
||||
|
||||
新增字段(1.1.0):
|
||||
- db_error_code:DB 不可达时的具体原因(encrypted / not_found / unreadable),
|
||||
db_accessible=true 时为 None
|
||||
- send_queue_pending:发送队列当前积压任务数,客户端据此决定是否退避
|
||||
- media_supported:图片/文件发送是否已实现真实传输(与 BRIDGE_CAPABILITIES
|
||||
中 image_send/file_send 同步)
|
||||
- bridge_capabilities:当前 bridge 支持的能力集合,客户端据此协商
|
||||
"""
|
||||
|
||||
bridge_version: str = Field(description="bridge 版本号")
|
||||
wechat_running: bool = Field(description="微信进程是否运行")
|
||||
wechat_window_found: bool = Field(description="是否找到微信窗口")
|
||||
login_state: LoginState = Field(description="登录态")
|
||||
db_accessible: bool = Field(description="微信本地 DB 是否可读")
|
||||
db_error_code: Optional[str] = Field(
|
||||
default=None,
|
||||
description="DB 不可达原因:not_found / unreadable / encrypted_no_key / encrypted_key_ok / key_extract_failed / need_init / init_in_progress / key_invalid;db_accessible=true 时为 None",
|
||||
)
|
||||
init_in_progress: bool = Field(default=False, description="是否正在进行后台 DB 初始化(密钥提取/预解密)")
|
||||
init_progress_pct: Optional[float] = Field(default=None, description="初始化进度百分比 0-100")
|
||||
init_message: Optional[str] = Field(default=None, description="初始化阶段描述")
|
||||
current_wxid: str = Field(default="", description="当前登录 wxid,未登录时为空")
|
||||
current_nickname: str = Field(default="", description="当前登录昵称,未登录时为空")
|
||||
uptime_seconds: float = Field(description="bridge 已运行秒数")
|
||||
display: str = Field(description="当前 DISPLAY 环境变量值")
|
||||
max_batch_size: int = Field(default=50, description="客户端单次拉取建议上限")
|
||||
poll_interval_ms: int = Field(default=2000, description="客户端轮询建议间隔(毫秒)")
|
||||
send_queue_pending: int = Field(default=0, description="发送队列积压任务数")
|
||||
media_supported: bool = Field(default=False, description="图片/文件真实传输是否已实现")
|
||||
bridge_capabilities: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="bridge 支持的能力集合,如 ['text_send','image_send','long_poll']",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 消息接口(GET /api/messages/since)
|
||||
# ---------------------------------------------------------------------------
|
||||
class Message(BaseModel):
|
||||
"""单条微信消息。"""
|
||||
|
||||
msg_id: str = Field(description="消息 ID")
|
||||
talker: str = Field(description="会话对方 wxid(群消息为 chatroom id)")
|
||||
sender: str = Field(default="", description="实际发送者 wxid(群消息中为成员 wxid)")
|
||||
is_sender: bool = Field(default=False, description="是否为本机发送")
|
||||
type: int = Field(description="微信原始消息类型")
|
||||
render_type: str = Field(description="渲染类型:text/image/voice/video/file/system")
|
||||
content: str = Field(default="", description="消息内容文本")
|
||||
create_time: int = Field(description="消息时间戳(Unix 秒)")
|
||||
session_type: str = Field(description="会话类型:p2p / group")
|
||||
|
||||
|
||||
class MessagesResponse(BaseModel):
|
||||
"""消息拉取响应。"""
|
||||
|
||||
messages: list[Message] = Field(default_factory=list)
|
||||
next_cursor: int = Field(description="下次拉取应使用的 cursor")
|
||||
has_more: bool = Field(default=False, description="是否可能还有更多消息")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 发送接口
|
||||
# ---------------------------------------------------------------------------
|
||||
class SendTextRequest(BaseModel):
|
||||
"""发送文本消息请求。"""
|
||||
|
||||
to_wxid: str = Field(description="目标 wxid")
|
||||
content: str = Field(description="文本内容")
|
||||
display_name: Optional[str] = Field(
|
||||
default=None,
|
||||
description="可选:用于微信搜索框定位会话的显示名。不传时 bridge 自动按备注->昵称->wxid 查找",
|
||||
)
|
||||
|
||||
|
||||
class SendFileRequest(BaseModel):
|
||||
"""发送图片/文件请求。"""
|
||||
|
||||
to_wxid: str = Field(description="目标 wxid")
|
||||
file_path: str = Field(description="容器内文件绝对路径")
|
||||
display_name: Optional[str] = Field(
|
||||
default=None,
|
||||
description="可选:用于微信搜索框定位会话的显示名。不传时 bridge 自动按备注->昵称->wxid 查找",
|
||||
)
|
||||
|
||||
|
||||
class SendResponse(BaseModel):
|
||||
"""发送消息响应。
|
||||
|
||||
字段说明:
|
||||
- local_send_id:bridge 本地生成的发送 ID,格式 local_<unix秒>_<随机>。
|
||||
仅用于客户端幂等去重,**不对应微信原生 msg_id**,禁止用于
|
||||
/api/media/{msg_id} 查媒体。
|
||||
- placeholder:是否为占位实现(true 表示真实文件/图片内容未传输,
|
||||
仅发了提示文本;客户端应走降级处理)。
|
||||
"""
|
||||
|
||||
success: bool = Field(default=False)
|
||||
local_send_id: str = Field(default="", description="本地生成的发送 ID,非微信原生 msg_id")
|
||||
placeholder: bool = Field(default=False, description="是否为占位实现(真实内容未传输)")
|
||||
error: Optional[str] = Field(default=None, description="失败时的错误描述")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 联系人接口
|
||||
# ---------------------------------------------------------------------------
|
||||
class Contact(BaseModel):
|
||||
"""联系人。"""
|
||||
|
||||
wxid: str
|
||||
nickname: str = ""
|
||||
remark: str = ""
|
||||
avatar_url: str = ""
|
||||
type: str = Field(default="person", description="person / group / official")
|
||||
|
||||
# 扩展字段:从 contact.db 的 contact 表读取
|
||||
alias: Optional[str] = Field(default=None, description="微信号/别名")
|
||||
encrypt_username: Optional[str] = Field(default=None, description="加密用户名")
|
||||
quan_pin: Optional[str] = Field(default=None, description="全拼")
|
||||
pin_yin_initial: Optional[str] = Field(default=None, description="拼音首字母")
|
||||
big_head_url: Optional[str] = Field(default=None, description="高清头像 URL")
|
||||
small_head_url: Optional[str] = Field(default=None, description="缩略头像 URL")
|
||||
description: Optional[str] = Field(default=None, description="个性签名/描述")
|
||||
local_type: Optional[int] = Field(default=None, description="联系人类型标记")
|
||||
verify_flag: Optional[int] = Field(default=None, description="认证标记")
|
||||
delete_flag: Optional[int] = Field(default=None, description="删除标记")
|
||||
chat_room_type: Optional[int] = Field(default=None, description="群类型标记")
|
||||
|
||||
|
||||
class ContactsResponse(BaseModel):
|
||||
"""联系人列表响应。"""
|
||||
|
||||
contacts: list[Contact] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
|
||||
class GroupsResponse(BaseModel):
|
||||
"""群聊列表响应。"""
|
||||
|
||||
groups: list[Contact] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 群成员接口
|
||||
# ---------------------------------------------------------------------------
|
||||
class GroupMember(BaseModel):
|
||||
"""群成员。"""
|
||||
|
||||
wxid: str
|
||||
nickname: str = ""
|
||||
display_name: str = ""
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class GroupMembersResponse(BaseModel):
|
||||
"""群成员列表响应。"""
|
||||
|
||||
group_wxid: str
|
||||
members: list[GroupMember] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 登录二维码接口
|
||||
# ---------------------------------------------------------------------------
|
||||
class QrLoginStartResult(BaseModel):
|
||||
"""启动扫码登录结果。"""
|
||||
|
||||
qr_data_url: str = Field(default="", description="data:image/png;base64,... 形式的二维码图片")
|
||||
message: str = ""
|
||||
connected: bool = False
|
||||
|
||||
|
||||
class QrLoginWaitResult(BaseModel):
|
||||
"""扫码等待结果。"""
|
||||
|
||||
connected: bool = False
|
||||
message: str = ""
|
||||
qr_data_url: str = ""
|
||||
credentials: Optional[dict[str, str]] = Field(
|
||||
default=None,
|
||||
description="登录成功时含 wxid/nickname",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 退出登录接口(POST /api/login/logout)
|
||||
# ---------------------------------------------------------------------------
|
||||
class LogoutResponse(BaseModel):
|
||||
"""退出登录响应。"""
|
||||
|
||||
success: bool = Field(description="是否成功")
|
||||
message: str = Field(description="结果描述")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 重启微信接口(POST /api/wechat/restart)
|
||||
# ---------------------------------------------------------------------------
|
||||
class RestartResponse(BaseModel):
|
||||
"""重启微信响应。"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
pid: Optional[int] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 诊断接口
|
||||
# ---------------------------------------------------------------------------
|
||||
class ConnectivityResponse(BaseModel):
|
||||
"""连通性检查响应。"""
|
||||
|
||||
reachable: bool = False
|
||||
latency_ms: int = 0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class DiagnosticItem(BaseModel):
|
||||
"""诊断项定义。"""
|
||||
|
||||
check_id: str
|
||||
name: str
|
||||
severity: str = Field(default="info", description="info / warning / critical")
|
||||
description: str = ""
|
||||
auto_repairable: bool = False
|
||||
|
||||
|
||||
class DiagnosticRunResult(BaseModel):
|
||||
"""诊断执行结果。"""
|
||||
|
||||
check_id: str
|
||||
passed: bool = False
|
||||
severity: str = "info"
|
||||
message: str = ""
|
||||
auto_repairable: bool = False
|
||||
repair_plan: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB 解密接口(POST /api/db/decrypt, GET /api/db/key/status)
|
||||
# ---------------------------------------------------------------------------
|
||||
class DbDecryptRequest(BaseModel):
|
||||
"""手动 key 注入请求。
|
||||
|
||||
外部系统(如 ForcePilot)或管理员通过 POST /api/db/decrypt 传入 64 位
|
||||
十六进制密钥,bridge 验证并缓存,后续所有 DB 查询接口立即恢复可用。
|
||||
"""
|
||||
|
||||
key: str = Field(description="64 位十六进制 SQLCipher 密钥")
|
||||
salt: Optional[str] = Field(
|
||||
default=None,
|
||||
description="可选:该 key 对应的 DB salt(32 位十六进制)。未指定时 bridge 自动读取当前 DB 的 salt",
|
||||
)
|
||||
|
||||
|
||||
class DbDecryptResponse(BaseModel):
|
||||
"""key 注入结果。
|
||||
|
||||
- verified=true 时 key 已缓存,后续 DB 查询接口立即可用
|
||||
- verified=false 时 key 不匹配,未缓存
|
||||
"""
|
||||
|
||||
success: bool = Field(description="请求是否处理成功(不代表 key 正确)")
|
||||
verified: bool = Field(default=False, description="key 是否通过验证并缓存")
|
||||
key_mode: Optional[str] = Field(
|
||||
default=None,
|
||||
description="key 形态:raw_enc_key / sqlcipher_passphrase;验证失败时为 None",
|
||||
)
|
||||
error: Optional[str] = Field(default=None, description="验证失败或特殊状态:key_mismatch / file_too_small / db_not_encrypted")
|
||||
|
||||
|
||||
class DbKeyStatusResponse(BaseModel):
|
||||
"""key 缓存状态查询响应。
|
||||
|
||||
供调用方判断是否需要注入 key 或等待自动提取。
|
||||
"""
|
||||
|
||||
cached: bool = Field(default=False, description="是否有缓存的 key")
|
||||
source: Optional[str] = Field(
|
||||
default=None,
|
||||
description="key 来源:env(环境变量)/ api(POST 注入)/ auto_extract(内存扫描)/ file(持久化文件)",
|
||||
)
|
||||
verified: bool = Field(default=False, description="缓存 key 是否已通过验证")
|
||||
key_prefix: Optional[str] = Field(
|
||||
default=None,
|
||||
description="key 前 4 + ... + 后 4 字符,用于确认是哪个 key(不泄露完整 key)",
|
||||
)
|
||||
|
||||
|
||||
class DbInitRequest(BaseModel):
|
||||
"""DB 初始化请求(显式触发密钥提取)。"""
|
||||
|
||||
pid: Optional[int] = Field(default=None, description="可选:指定微信进程 PID")
|
||||
db_dir: Optional[str] = Field(default=None, description="可选:指定微信数据目录,默认自动检测")
|
||||
force: bool = Field(default=False, description="是否强制重新提取,忽略已有 keys 文件")
|
||||
|
||||
|
||||
class DbInitResponse(BaseModel):
|
||||
"""DB 初始化响应。"""
|
||||
|
||||
success: bool = Field(description="请求是否受理")
|
||||
state: str = Field(description="状态:started / already_done / in_progress / failed")
|
||||
message: str = Field(description="阶段描述")
|
||||
key_count: Optional[int] = Field(default=None, description="已提取到的 salt→key 映射数量")
|
||||
|
||||
|
||||
class DbInitStatusResponse(BaseModel):
|
||||
"""DB 初始化后台任务状态。"""
|
||||
|
||||
state: str = Field(description="状态:idle / running / success / failed")
|
||||
progress_pct: Optional[float] = Field(default=None, description="进度百分比 0-100")
|
||||
message: Optional[str] = Field(default=None, description="当前阶段描述")
|
||||
key_count: Optional[int] = Field(default=None, description="成功提取的密钥数")
|
||||
error: Optional[str] = Field(default=None, description="失败原因")
|
||||
149
bridge/qr_capture.py
Normal file
149
bridge/qr_capture.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""微信窗口二维码截图能力。
|
||||
|
||||
封装屏幕截图(scrot)与二维码区域裁剪(Pillow),供
|
||||
/api/login/qr/start 与 /api/screenshot 使用。
|
||||
|
||||
所有方法为 async,内部用 asyncio 子进程执行截图命令,避免阻塞
|
||||
event loop。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from models import BridgeError
|
||||
|
||||
|
||||
class QrCapture:
|
||||
"""屏幕截图与二维码裁剪器。
|
||||
|
||||
通过 scrot 截取整个 X11 屏幕,再用 Pillow 按比例裁剪出二维码
|
||||
所在区域(中央偏上)。
|
||||
"""
|
||||
|
||||
# 二维码区域裁剪比例(spec 12.3 节,MVP 阶段固定为中央偏上区域)
|
||||
_QR_LEFT = 0.25
|
||||
_QR_TOP = 0.2
|
||||
_QR_RIGHT = 0.75
|
||||
_QR_BOTTOM = 0.7
|
||||
|
||||
def __init__(self, display: str = ":1") -> None:
|
||||
"""保存 DISPLAY 环境变量值。
|
||||
|
||||
Args:
|
||||
display: X server display 地址,如 ":1"
|
||||
"""
|
||||
self.display = display
|
||||
|
||||
def _env(self) -> dict:
|
||||
"""返回带 DISPLAY 的环境副本。"""
|
||||
env = dict(os.environ)
|
||||
env["DISPLAY"] = self.display
|
||||
return env
|
||||
|
||||
async def _run(self, args: list[str]) -> tuple[int, bytes, bytes]:
|
||||
"""执行一条命令并返回 (returncode, stdout, stderr)。"""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=self._env(),
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
return proc.returncode, stdout, stderr
|
||||
|
||||
async def _capture_full_png(self, dest_path: str) -> None:
|
||||
"""用 `scrot <dest>` 截取整个屏幕到 PNG 文件。
|
||||
|
||||
Args:
|
||||
dest_path: 目标 PNG 文件路径
|
||||
"""
|
||||
returncode, _, stderr = await self._run(
|
||||
["scrot", dest_path]
|
||||
)
|
||||
if returncode != 0 or not os.path.exists(dest_path):
|
||||
msg = stderr.decode(errors="ignore").strip() or "unknown error"
|
||||
raise BridgeError(
|
||||
code="BRIDGE_INTERNAL_ERROR",
|
||||
message=f"截图失败: {msg}",
|
||||
)
|
||||
|
||||
async def capture_qr_code(self) -> str:
|
||||
"""截取并裁剪二维码区域,返回 data URL。
|
||||
|
||||
流程:
|
||||
1. 截图整个屏幕到 /tmp 临时 PNG
|
||||
2. 用 Pillow 打开并按比例裁剪二维码区域
|
||||
3. 编码为 base64 data URL `data:image/png;base64,...`
|
||||
4. 删除临时文件
|
||||
5. 返回 data URL
|
||||
"""
|
||||
# 临时文件
|
||||
fd, tmp_path = tempfile.mkstemp(prefix="woc_qr_", suffix=".png")
|
||||
os.close(fd)
|
||||
try:
|
||||
await self._capture_full_png(tmp_path)
|
||||
# 用 Pillow 裁剪
|
||||
data_url = await asyncio.to_thread(self._crop_qr_to_data_url, tmp_path)
|
||||
return data_url
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _crop_qr_to_data_url(self, png_path: str) -> str:
|
||||
"""同步:打开 PNG,按比例裁剪二维码区域,返回 data URL。"""
|
||||
try:
|
||||
from PIL import Image # 局部导入,避免无 Pillow 时影响模块加载
|
||||
except ImportError as e:
|
||||
raise BridgeError(
|
||||
code="BRIDGE_INTERNAL_ERROR",
|
||||
message=f"Pillow 未安装: {e}",
|
||||
)
|
||||
|
||||
try:
|
||||
with Image.open(png_path) as img:
|
||||
w, h = img.size
|
||||
left = int(w * self._QR_LEFT)
|
||||
top = int(h * self._QR_TOP)
|
||||
right = int(w * self._QR_RIGHT)
|
||||
bottom = int(h * self._QR_BOTTOM)
|
||||
cropped = img.crop((left, top, right, bottom))
|
||||
buf = io.BytesIO()
|
||||
cropped.save(buf, format="PNG")
|
||||
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
return f"data:image/png;base64,{b64}"
|
||||
except BridgeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BridgeError(
|
||||
code="BRIDGE_INTERNAL_ERROR",
|
||||
message=f"裁剪二维码失败: {e}",
|
||||
)
|
||||
|
||||
async def capture_full_screenshot(self) -> bytes:
|
||||
"""截取完整屏幕,返回 PNG bytes(供 /api/screenshot 用)。"""
|
||||
fd, tmp_path = tempfile.mkstemp(prefix="woc_shot_", suffix=".png")
|
||||
os.close(fd)
|
||||
try:
|
||||
await self._capture_full_png(tmp_path)
|
||||
try:
|
||||
with open(tmp_path, "rb") as f:
|
||||
return f.read()
|
||||
except OSError as e:
|
||||
raise BridgeError(
|
||||
code="BRIDGE_INTERNAL_ERROR",
|
||||
message=f"读取截图文件失败: {e}",
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
27
bridge/s6/woc-bridge/run
Normal file
27
bridge/s6/woc-bridge/run
Normal file
@ -0,0 +1,27 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
# 等待微信窗口出现,最多等 5 分钟;超时也启动 bridge(/api/status 会返回 not_running)
|
||||
WAITED=0
|
||||
MAX_WAIT=300
|
||||
while ! xdotool search --name "微信" >/dev/null 2>&1; do
|
||||
sleep 2
|
||||
WAITED=$((WAITED + 2))
|
||||
if [ "$WAITED" -ge "$MAX_WAIT" ]; then
|
||||
echo "woc-bridge: 等待微信窗口超时(${WAITED}s),仍然启动 bridge" >&2
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# 以 abc 用户身份运行(与微信同 X 会话,才能操作微信窗口)
|
||||
# s6-setuidgid 不接受 VAR=value 参数,须先用 s6-env 设环境变量再切用户
|
||||
|
||||
# 校验 ptrace 权限(key_extractor 读微信进程内存需要)
|
||||
if [ -w /proc/sys/kernel/yama/ptrace_scope ] 2>/dev/null; then
|
||||
echo 0 > /proc/sys/kernel/yama/ptrace_scope 2>/dev/null || true
|
||||
fi
|
||||
|
||||
exec s6-env DISPLAY=${DISPLAY:-:1} XAUTHORITY=/config/.Xauthority \
|
||||
s6-setuidgid abc \
|
||||
python3 /opt/woc-bridge/server.py \
|
||||
--listen 0.0.0.0:8088 \
|
||||
--wechat-db /config \
|
||||
--display ${DISPLAY:-:1}
|
||||
1
bridge/s6/woc-bridge/type
Normal file
1
bridge/s6/woc-bridge/type
Normal file
@ -0,0 +1 @@
|
||||
longrun
|
||||
135
bridge/send_queue.py
Normal file
135
bridge/send_queue.py
Normal file
@ -0,0 +1,135 @@
|
||||
"""发送串行化队列。
|
||||
|
||||
所有 xdotool 操作经此队列串行执行,避免并发 UI 操作冲突。
|
||||
内部维护最近 1 秒调用时间戳用于限流。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from models import BridgeError
|
||||
|
||||
|
||||
# 工厂类型:返回一个待执行的 coroutine
|
||||
CoroFactory = Callable[[], Awaitable[Any]]
|
||||
|
||||
|
||||
class SendQueue:
|
||||
"""串行化发送队列 + 限流。
|
||||
|
||||
通过 asyncio.Queue 串行执行所有发送任务;执行间隔可配置
|
||||
(默认 800ms),单实例每秒调用上限可配置(默认 10)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
send_delay_ms: int = 800,
|
||||
max_calls_per_sec: int = 10,
|
||||
) -> None:
|
||||
"""初始化队列配置。
|
||||
|
||||
Args:
|
||||
send_delay_ms: 两次发送之间的最小间隔毫秒
|
||||
max_calls_per_sec: 每秒最大调用次数
|
||||
"""
|
||||
self.send_delay_ms = send_delay_ms
|
||||
self.max_calls_per_sec = max_calls_per_sec
|
||||
self._queue: asyncio.Queue[tuple[CoroFactory, asyncio.Future]] = asyncio.Queue()
|
||||
self._worker: asyncio.Task | None = None
|
||||
# 最近 1 秒内的调用时间戳
|
||||
self._recent_call_times: list[float] = []
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动 worker task。"""
|
||||
if self._worker is None or self._worker.done():
|
||||
self._worker = asyncio.create_task(self._run())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""取消 worker。"""
|
||||
if self._worker is not None and not self._worker.done():
|
||||
self._worker.cancel()
|
||||
try:
|
||||
await self._worker
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._worker = None
|
||||
|
||||
async def enqueue(self, coro_factory: CoroFactory) -> Any:
|
||||
"""将一个返回 coroutine 的工厂入队,等待执行结果。
|
||||
|
||||
Args:
|
||||
coro_factory: 调用后返回 coroutine 的工厂函数
|
||||
|
||||
Returns:
|
||||
coroutine 的执行结果
|
||||
|
||||
Raises:
|
||||
BridgeError: 限流命中时立即抛 RATE_LIMITED;
|
||||
任务执行抛出的异常会透传给调用方
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
await self._queue.put((coro_factory, future))
|
||||
return await future
|
||||
|
||||
def pending_count(self) -> int:
|
||||
"""返回当前队列中待执行任务数(供 /api/status 暴露给客户端做退避决策)。"""
|
||||
return self._queue.qsize()
|
||||
|
||||
def _check_rate_limit(self) -> None:
|
||||
"""检查限流。
|
||||
|
||||
清理 1 秒前的时间戳,若当前已满 max_calls_per_sec 则抛
|
||||
BridgeError(RATE_LIMITED),并在 details 中携带 retry_after 秒数
|
||||
供上层设置 Retry-After 响应头。
|
||||
"""
|
||||
now = time.monotonic()
|
||||
# 清理 1 秒前的时间戳
|
||||
self._recent_call_times = [t for t in self._recent_call_times if now - t < 1.0]
|
||||
if len(self._recent_call_times) >= self.max_calls_per_sec:
|
||||
# 计算建议等待秒数:最早一次调用距窗口边界还差多久
|
||||
oldest = self._recent_call_times[0]
|
||||
retry_after = max(1, int(1.0 - (now - oldest)) + 1)
|
||||
raise BridgeError(
|
||||
code="RATE_LIMITED",
|
||||
message=f"发送限流:每秒最多 {self.max_calls_per_sec} 次",
|
||||
details={"retry_after": retry_after},
|
||||
)
|
||||
|
||||
async def _run(self) -> None:
|
||||
"""worker 主循环。
|
||||
|
||||
循环取出任务执行;执行前检查限流(超限则失败该任务);
|
||||
执行前记录开始时间戳(避免长任务导致 1 秒窗口内超限);
|
||||
执行后 sleep send_delay_ms/1000。
|
||||
"""
|
||||
while True:
|
||||
coro_factory, future = await self._queue.get()
|
||||
# 标记任务是否真正开始执行(用于决定 finally 是否延时)
|
||||
executed = False
|
||||
try:
|
||||
# 执行前检查限流
|
||||
self._check_rate_limit()
|
||||
# 记录开始时间戳(限流窗口基于开始时刻,避免长任务后窗口偏移)
|
||||
self._recent_call_times.append(time.monotonic())
|
||||
executed = True
|
||||
# 执行任务
|
||||
result = await coro_factory()
|
||||
if not future.done():
|
||||
future.set_result(result)
|
||||
except asyncio.CancelledError:
|
||||
# worker 被取消时,把取消传播给等待的调用方
|
||||
if not future.done():
|
||||
future.cancel()
|
||||
raise
|
||||
except Exception as e:
|
||||
if not future.done():
|
||||
future.set_exception(e)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
# 仅在任务真正执行过时延时,限流失败的任务不延时
|
||||
if executed:
|
||||
await asyncio.sleep(self.send_delay_ms / 1000.0)
|
||||
2393
bridge/server.py
Normal file
2393
bridge/server.py
Normal file
File diff suppressed because it is too large
Load Diff
83
bridge/tools/extract_key_standalone.py
Normal file
83
bridge/tools/extract_key_standalone.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""独立的 Linux 微信进程内存 key 提取工具(调试用)。
|
||||
|
||||
用法(在容器内执行):
|
||||
python3 /opt/woc-bridge/tools/extract_key_standalone.py
|
||||
|
||||
设计目标:
|
||||
- 与 woc-bridge 主服务隔离,扫描失败不影响 bridge
|
||||
- 自动检测所有微信进程并按内存大小排序扫描
|
||||
- 匹配 x'<hex>' 模式提取 enc_key
|
||||
- 输出 salt -> enc_key 映射,供 bridge 注入使用
|
||||
|
||||
注:生产环境请通过 /api/db/init 接口触发自动提取,本脚本仅用于调试与故障排查。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 添加父目录到 sys.path,便于从 tools/ 子目录 import 父级模块
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from key_extractor import KeyExtractor
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
_logger = logging.getLogger("extract_key")
|
||||
|
||||
|
||||
def _find_encrypted_db_dir() -> str:
|
||||
"""尝试自动定位微信数据根目录。"""
|
||||
db_root = "/config"
|
||||
if os.path.isdir(db_root):
|
||||
return db_root
|
||||
return "/"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="从 Linux 微信进程内存提取 SQLCipher DB enc_key")
|
||||
parser.add_argument("--pid", type=int, default=0, help="指定微信进程 PID(不指定则自动检测)")
|
||||
parser.add_argument("--db", type=str, default="", help="微信 DB 文件或目录(不指定则自动查找 /config)")
|
||||
parser.add_argument("--full", action="store_true", help="完整扫描所有可读内存区域(默认优先堆)")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_path = args.db or _find_encrypted_db_dir()
|
||||
_logger.info("目标 DB: %s", db_path)
|
||||
|
||||
extractor = KeyExtractor(args.pid, db_path)
|
||||
key_map = extractor.extract_all_keys(full_scan=args.full)
|
||||
|
||||
if not key_map:
|
||||
print("\n[-] 未能提取到任何有效 enc_key")
|
||||
return 1
|
||||
|
||||
print(f"\n[+] 成功提取 {len(key_map)} 个 enc_key:")
|
||||
print("-" * 80)
|
||||
for salt_hex, enc_key_hex in key_map.items():
|
||||
print(f"salt: {salt_hex}")
|
||||
print(f"key: {enc_key_hex}")
|
||||
print(f" 前 8 位: {enc_key_hex[:8]}... 后 8 位: ...{enc_key_hex[-8:]}")
|
||||
print("-" * 80)
|
||||
|
||||
# 如果只有一个 key,同时输出可直接注入 bridge 的 curl 命令
|
||||
if len(key_map) == 1:
|
||||
enc_key_hex = next(iter(key_map.values()))
|
||||
print("\n[+] 可通过以下命令注入 bridge:")
|
||||
print(
|
||||
f" curl -X POST http://localhost:8088/api/db/decrypt "
|
||||
f"-H 'Content-Type: application/json' -d '{{\"key\":\"{enc_key_hex}\"}}'"
|
||||
)
|
||||
else:
|
||||
print("\n[+] 多个 salt 时,建议直接启用 bridge 自动提取(WOC_DB_KEY_AUTO_EXTRACT=true)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
127
bridge/tools/salt_probe.py
Normal file
127
bridge/tools/salt_probe.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""通过 salt 定位快速探测内存中的 SQLCipher enc_key。
|
||||
|
||||
策略:
|
||||
1. 读取加密 DB 的 salt
|
||||
2. 在微信进程内存中搜索该 salt
|
||||
3. 对每个 salt 命中点,在 ±2KB 范围内枚举 32 字节候选
|
||||
4. 用 HMAC-SHA512 验证候选是否为 enc_key
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
PAGE_SZ = 4096
|
||||
KEY_SZ = 32
|
||||
SALT_SZ = 16
|
||||
RESERVE_SIZE = 80
|
||||
IV_SIZE = 16
|
||||
HMAC_SIZE = 64
|
||||
MAC_SALT_XOR = 0x3A
|
||||
|
||||
|
||||
def _verify_enc_key(enc_key: bytes, page1: bytes) -> bool:
|
||||
salt = page1[:SALT_SZ]
|
||||
mac_salt = bytes(b ^ MAC_SALT_XOR for b in salt)
|
||||
mac_key = hashlib.pbkdf2_hmac("sha512", enc_key, mac_salt, 2, dklen=KEY_SZ)
|
||||
hmac_data = page1[SALT_SZ: PAGE_SZ - RESERVE_SIZE + IV_SIZE]
|
||||
stored_hmac = page1[PAGE_SZ - HMAC_SIZE: PAGE_SZ]
|
||||
hm = hmac.new(mac_key, hmac_data, hashlib.sha512)
|
||||
hm.update(struct.pack("<I", 1))
|
||||
return hm.digest() == stored_hmac
|
||||
|
||||
|
||||
def _get_regions(pid: int) -> list[tuple[int, int, str]]:
|
||||
regions = []
|
||||
try:
|
||||
with open(f"/proc/{pid}/maps", "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
perms = parts[1]
|
||||
if "r" not in perms:
|
||||
continue
|
||||
name = parts[5] if len(parts) >= 6 else ""
|
||||
if name in ("[vdso]", "[vsyscall]", "[vvar]"):
|
||||
continue
|
||||
start_s, end_s = parts[0].split("-")
|
||||
start = int(start_s, 16)
|
||||
end = int(end_s, 16)
|
||||
regions.append((start, end - start, name))
|
||||
except Exception as e:
|
||||
print(f"读取 maps 失败: {e}")
|
||||
return regions
|
||||
|
||||
|
||||
def _read_chunk(mem, base: int, offset: int, size: int) -> bytes:
|
||||
try:
|
||||
mem.seek(base + offset)
|
||||
return mem.read(size)
|
||||
except Exception:
|
||||
return b""
|
||||
|
||||
|
||||
def probe_pid(pid: int, salt: bytes, page1: bytes, window: int = 2048) -> Optional[bytes]:
|
||||
regions = _get_regions(pid)
|
||||
print(f"PID {pid}: {len(regions)} 个可读区域,salt={salt.hex()}")
|
||||
found = 0
|
||||
with open(f"/proc/{pid}/mem", "rb") as mem:
|
||||
for base, size, name in regions:
|
||||
pos = 0
|
||||
chunk_size = 4 * 1024 * 1024
|
||||
while pos < size:
|
||||
to_read = min(chunk_size, size - pos)
|
||||
data = _read_chunk(mem, base, pos, to_read)
|
||||
if not data:
|
||||
break
|
||||
idx = 0
|
||||
while True:
|
||||
hit = data.find(salt, idx)
|
||||
if hit < 0:
|
||||
break
|
||||
abs_addr = base + pos + hit
|
||||
found += 1
|
||||
if found % 100 == 0:
|
||||
print(f" 已找到 {found} 个 salt 命中...")
|
||||
# 读取 ±window 区域
|
||||
start = max(0, hit - window)
|
||||
end = min(len(data), hit + SALT_SZ + window)
|
||||
region = data[start:end]
|
||||
# 枚举 32 字节候选(16 字节对齐)
|
||||
for cand_off in range(0, len(region) - KEY_SZ + 1):
|
||||
cand = region[cand_off:cand_off + KEY_SZ]
|
||||
if _verify_enc_key(cand, page1):
|
||||
print(f" !! 找到 enc_key @ PID={pid} addr=0x{abs_addr - window + cand_off:016x} name={name}")
|
||||
return cand
|
||||
idx = hit + 1
|
||||
pos += len(data)
|
||||
print(f"PID {pid}: salt 命中 {found} 次,未找到 enc_key")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
db_path = sys.argv[1] if len(sys.argv) > 1 else "/config/xwechat_files/wxid_zfj3oc1hn5no22_43c4/db_storage/contact/contact.db"
|
||||
pid = int(sys.argv[2]) if len(sys.argv) > 2 else 348
|
||||
|
||||
with open(db_path, "rb") as f:
|
||||
page1 = f.read(PAGE_SZ)
|
||||
salt = page1[:SALT_SZ]
|
||||
print(f"DB: {db_path}, salt={salt.hex()}")
|
||||
|
||||
key = probe_pid(pid, salt, page1)
|
||||
if key:
|
||||
print(f"enc_key={key.hex()}")
|
||||
return 0
|
||||
|
||||
print("未找到 enc_key")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
612
bridge/xdotool_driver.py
Normal file
612
bridge/xdotool_driver.py
Normal file
@ -0,0 +1,612 @@
|
||||
"""xdotool / xclip 异步驱动。
|
||||
|
||||
封装对微信窗口的所有 X11 自动化操作:查找窗口、检测登录态、激活窗口、
|
||||
通过 Ctrl+F 搜索会话并粘贴发送文本。所有外部命令通过 asyncio 子进程执行,
|
||||
不阻塞 event loop。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from models import BridgeError, LoginState
|
||||
|
||||
|
||||
# 模块级 logger:与 server.py 同名,便于 s6 统一收日志
|
||||
logger = logging.getLogger("woc-bridge")
|
||||
|
||||
|
||||
class XdotoolDriver:
|
||||
"""xdotool/xclip 异步驱动。
|
||||
|
||||
所有方法均为 async,内部使用 asyncio.create_subprocess_exec 调用
|
||||
xdotool / xclip / pgrep 等命令,并通过 DISPLAY 环境变量指定 X server。
|
||||
"""
|
||||
|
||||
def __init__(self, display: str = ":1") -> None:
|
||||
"""保存 DISPLAY 环境变量值。
|
||||
|
||||
Args:
|
||||
display: X server display 地址,如 ":1"
|
||||
"""
|
||||
self.display = display
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 环境与底层工具
|
||||
# ------------------------------------------------------------------
|
||||
def _env(self) -> dict:
|
||||
"""返回带 DISPLAY 的环境副本。"""
|
||||
env = dict(os.environ)
|
||||
env["DISPLAY"] = self.display
|
||||
return env
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
args: list[str],
|
||||
*,
|
||||
input_bytes: Optional[bytes] = None,
|
||||
) -> tuple[int, bytes, bytes]:
|
||||
"""执行一条命令并返回 (returncode, stdout, stderr)。
|
||||
|
||||
Args:
|
||||
args: 命令及其参数列表,如 ["xdotool", "search", "--name", "微信"]
|
||||
input_bytes: 需要写入 stdin 的字节
|
||||
"""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdin=asyncio.subprocess.PIPE if input_bytes is not None else None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=self._env(),
|
||||
)
|
||||
stdout, stderr = await proc.communicate(input=input_bytes)
|
||||
return proc.returncode, stdout, stderr
|
||||
|
||||
async def _key(self, key: str) -> None:
|
||||
"""执行 xdotool key <key>。"""
|
||||
await self._run(["xdotool", "key", key])
|
||||
|
||||
async def _sleep(self, seconds: float) -> None:
|
||||
"""asyncio.sleep 封装,便于测试与统一调速。"""
|
||||
await asyncio.sleep(seconds)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 窗口与进程检测
|
||||
# ------------------------------------------------------------------
|
||||
async def find_wechat_window(self) -> Optional[int]:
|
||||
"""用 xdotool search --name "微信" 查找微信窗口 ID。
|
||||
|
||||
Returns:
|
||||
窗口 ID(int),找不到返回 None
|
||||
"""
|
||||
returncode, stdout, stderr = await self._run(
|
||||
["xdotool", "search", "--name", "微信"]
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
text = stdout.decode(errors="ignore").strip()
|
||||
if not text:
|
||||
return None
|
||||
# 取第一个匹配的窗口 ID
|
||||
first_line = text.splitlines()[0].strip()
|
||||
try:
|
||||
return int(first_line)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
async def is_wechat_running(self) -> bool:
|
||||
"""判断微信进程是否运行。
|
||||
|
||||
用 pgrep -x 精确匹配进程名 wechat(避免 -f 子串匹配误中
|
||||
bridge 自身命令行里的 /config/.config/xwechat 路径)。
|
||||
若 pgrep 不可用,回落到 xdotool search(窗口存在即视为进程运行)。
|
||||
"""
|
||||
# 用 -x 精确进程名匹配,避免 -f 子串误中 bridge 自身
|
||||
# 微信 4.0 Linux 进程名通常为 "wechat"
|
||||
for name in ("wechat", "WeChat"):
|
||||
returncode, stdout, _ = await self._run(["pgrep", "-x", name])
|
||||
if returncode == 0 and stdout.decode(errors="ignore").strip():
|
||||
return True
|
||||
# 回落:窗口存在即视为运行
|
||||
window_id = await self.find_wechat_window()
|
||||
return window_id is not None
|
||||
|
||||
async def activate_window(self, window_id: Optional[int] = None) -> None:
|
||||
"""激活微信窗口。
|
||||
|
||||
Args:
|
||||
window_id: 指定窗口 ID;为 None 时自动查找
|
||||
"""
|
||||
if window_id is None:
|
||||
window_id = await self.find_wechat_window()
|
||||
if window_id is None:
|
||||
raise BridgeError(
|
||||
code="WINDOW_NOT_FOUND",
|
||||
message="未找到微信窗口,无法激活",
|
||||
)
|
||||
await self._run(["xdotool", "windowactivate", "--sync", str(window_id)])
|
||||
|
||||
async def _activate_window_fast(self, window_id: Optional[int] = None) -> None:
|
||||
"""非阻塞激活微信窗口,避免 --sync 在 VNC 无人操作时死等。"""
|
||||
if window_id is None:
|
||||
window_id = await self.find_wechat_window()
|
||||
if window_id is None:
|
||||
raise BridgeError(
|
||||
code="WINDOW_NOT_FOUND",
|
||||
message="未找到微信窗口,无法激活",
|
||||
)
|
||||
await self._run(["xdotool", "windowactivate", str(window_id)])
|
||||
|
||||
async def restart_wechat(self, timeout_sec: int = 30) -> int:
|
||||
"""重启微信进程:发 SIGTERM 让微信优雅退出,等待 autostart 拉起新进程。
|
||||
|
||||
微信由容器内 autostart 脚本常驻拉起(while true 循环 + sleep 2),
|
||||
故 bridge 只需 kill,autostart 会在 2 秒后自动重启。不强制 SIGKILL,
|
||||
给微信优雅退出机会(避免 DB 写入未刷盘)。
|
||||
|
||||
Args:
|
||||
timeout_sec: 等待新进程出现的最大秒数,默认 30
|
||||
|
||||
Returns:
|
||||
新进程的 PID
|
||||
|
||||
Raises:
|
||||
BridgeError(RESTART_TIMEOUT): 超时未检测到新进程
|
||||
"""
|
||||
# 1. 获取当前 PID(若有)
|
||||
returncode, stdout, _ = await self._run(["pgrep", "-x", "wechat"])
|
||||
old_pids: set[int] = set()
|
||||
if returncode == 0:
|
||||
for line in stdout.decode(errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
old_pids.add(int(line))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 2. 若有旧进程,发 SIGTERM
|
||||
for pid in old_pids:
|
||||
# 用 kill(shell 内建)发 SIGTERM,不强制 SIGKILL
|
||||
await self._run(["kill", "-TERM", str(pid)])
|
||||
|
||||
# 3. 等待新进程出现(autostart 会在 2 秒后拉起)
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
while time.monotonic() < deadline:
|
||||
await self._sleep(1.0)
|
||||
rc, stdout, _ = await self._run(["pgrep", "-x", "wechat"])
|
||||
if rc == 0:
|
||||
for line in stdout.decode(errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
new_pid = int(line)
|
||||
except ValueError:
|
||||
continue
|
||||
# 确认是新 PID(不在旧 PID 集合中)
|
||||
if new_pid not in old_pids:
|
||||
return new_pid
|
||||
|
||||
# 4. 超时
|
||||
raise BridgeError(
|
||||
code="RESTART_TIMEOUT",
|
||||
message=f"等待微信重启超时({timeout_sec}s)",
|
||||
)
|
||||
|
||||
async def detect_login_state(self) -> str:
|
||||
"""检测登录态(启发式)。
|
||||
|
||||
MVP 阶段判定逻辑:
|
||||
- 微信进程未运行 → not_running
|
||||
- 进程运行但窗口未找到 → not_logged_in
|
||||
- 窗口存在 → logged_in
|
||||
|
||||
Returns:
|
||||
LoginState 枚举值(字符串)
|
||||
"""
|
||||
running = await self.is_wechat_running()
|
||||
if not running:
|
||||
return LoginState.NOT_RUNNING.value
|
||||
window_id = await self.find_wechat_window()
|
||||
if window_id is None:
|
||||
return LoginState.NOT_LOGGED_IN.value
|
||||
# MVP:窗口存在即视为已登录
|
||||
return LoginState.LOGGED_IN.value
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 显式启动微信(供 diagnostic autofix 使用)
|
||||
# ------------------------------------------------------------------
|
||||
# 微信可执行文件固定路径(与 docker/app-defs.sh 中 wechat 类型一致)。
|
||||
# 仅对 wechat 类型实例有意义;Telegram/Chromium/自定义应用的生命周期
|
||||
# 不应通过 bridge autofix 干预。
|
||||
_WECHAT_BIN = "/config/wechat/opt/wechat/wechat"
|
||||
|
||||
async def start_wechat(self, timeout_sec: int = 10) -> Optional[int]:
|
||||
"""显式启动微信进程,返回新 PID 或 None。
|
||||
|
||||
用于 diagnostic autofix:pkill 后 autostart watchdog 未拉起时,
|
||||
bridge 显式启动作为兜底。避免引入 shell 依赖,直接执行二进制。
|
||||
|
||||
竞态规避:
|
||||
1. 启动前再次 pgrep,已有进程则直接返回(不重复启动)
|
||||
2. 启动后轮询 pgrep,等待新 PID 出现
|
||||
|
||||
Args:
|
||||
timeout_sec: 等待新进程出现的最大秒数,默认 10
|
||||
|
||||
Returns:
|
||||
新进程 PID;启动失败或超时返回 None
|
||||
"""
|
||||
# 1. 启动前再 pgrep,已有则跳过(避免与 autostart 竞态)
|
||||
existing_pid = await self.check_wechat_pid()
|
||||
if existing_pid is not None:
|
||||
return existing_pid
|
||||
|
||||
# 2. 显式启动(disown 语义:不等待进程结束)
|
||||
# DISPLAY/XAUTHORITY 由 _env() 透传,确保 wechat 能连接 X
|
||||
try:
|
||||
await asyncio.create_subprocess_exec(
|
||||
self._WECHAT_BIN,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
env=self._env(),
|
||||
# 启动后立即 detach,不让 wechat 成为 bridge 的子进程
|
||||
# (避免 bridge 退出时连带杀掉 wechat)
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("start_wechat: 启动 %s 失败: %r", self._WECHAT_BIN, e)
|
||||
return None
|
||||
|
||||
# 3. 等待新 PID 出现(不依赖 subprocess 返回的 pid,因为 wechat
|
||||
# 可能 fork 出独立进程,pgrep 比依赖 proc.pid 更可靠)
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
while time.monotonic() < deadline:
|
||||
await self._sleep(1.0)
|
||||
new_pid = await self.check_wechat_pid()
|
||||
if new_pid is not None:
|
||||
return new_pid
|
||||
|
||||
logger.warning("start_wechat: 等待 %ss 内未检测到新进程", timeout_sec)
|
||||
return None
|
||||
|
||||
async def check_wechat_pid(self) -> Optional[int]:
|
||||
"""返回当前运行的微信主进程 PID,无则返回 None。"""
|
||||
returncode, stdout, _ = await self._run(["pgrep", "-x", "wechat"])
|
||||
if returncode != 0:
|
||||
return None
|
||||
for line in stdout.decode(errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
return int(line)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 剪贴板粘贴
|
||||
# ------------------------------------------------------------------
|
||||
async def _paste_via_xclip(self, text: str) -> None:
|
||||
"""通过 xclip 写入剪贴板并触发 Ctrl+V 粘贴。
|
||||
|
||||
直接把原始字节写入 xclip stdin(create_subprocess_exec 不经 shell,
|
||||
无转义问题,因此无需 base64 编码)。并行等待 stdin 写入与进程退出,
|
||||
避免大文本时管道缓冲阻塞导致的死锁。
|
||||
|
||||
Args:
|
||||
text: 待粘贴文本
|
||||
|
||||
Raises:
|
||||
BridgeError(SEND_FAILED): xclip 退出码非 0
|
||||
"""
|
||||
xclip_proc = await asyncio.create_subprocess_exec(
|
||||
"xclip", "-selection", "clipboard",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=self._env(),
|
||||
)
|
||||
assert xclip_proc.stdin is not None
|
||||
|
||||
async def _feed() -> None:
|
||||
try:
|
||||
xclip_proc.stdin.write(text.encode("utf-8"))
|
||||
await xclip_proc.stdin.drain()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
# xclip 已退出,忽略
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
xclip_proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 并行:喂 stdin + 等进程退出,避免管道缓冲满死锁
|
||||
_, (_, stderr) = await asyncio.gather(_feed(), xclip_proc.communicate())
|
||||
if xclip_proc.returncode != 0:
|
||||
err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
|
||||
raise BridgeError(
|
||||
code="SEND_FAILED",
|
||||
message=f"xclip 写入剪贴板失败 (code={xclip_proc.returncode}): {err}",
|
||||
)
|
||||
# 触发粘贴
|
||||
await self._key("ctrl+v")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 会话定位(Ctrl+F 搜索)
|
||||
# ------------------------------------------------------------------
|
||||
async def _open_session_by_name(
|
||||
self,
|
||||
name: str,
|
||||
timeout_sec: float = 10.0,
|
||||
) -> None:
|
||||
"""通过微信搜索框定位并进入指定联系人的会话。
|
||||
|
||||
流程:
|
||||
1. 非阻塞激活微信窗口
|
||||
2. 按 Esc 关闭可能存在的搜索框/弹窗
|
||||
3. Ctrl+F 打开搜索
|
||||
4. Ctrl+A 全选旧内容,粘贴 display_name
|
||||
5. 等待搜索结果渲染
|
||||
6. 按 ↓ 选中第一个结果,回车进入会话
|
||||
7. 再按 Esc 确保退出搜索模式,焦点落在聊天输入框
|
||||
|
||||
Args:
|
||||
name: 用于搜索的显示名(备注/昵称/微信号)
|
||||
timeout_sec: 整体超时(秒),默认 10 秒
|
||||
|
||||
Raises:
|
||||
BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口
|
||||
BridgeError(SEND_FAILED): 超时或 xdotool/xclip 操作失败
|
||||
"""
|
||||
if not name:
|
||||
raise BridgeError(
|
||||
code="SEND_FAILED",
|
||||
message="display_name 不能为空,无法定位会话",
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
|
||||
async def _step(coro: Awaitable[None], desc: str) -> None:
|
||||
"""执行单个 UI 步骤并加超时保护。"""
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise BridgeError(
|
||||
code="SEND_FAILED",
|
||||
message=f"定位会话超时: {desc}",
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(coro, timeout=max(1.0, remaining))
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise BridgeError(
|
||||
code="SEND_FAILED",
|
||||
message=f"定位会话步骤超时: {desc}",
|
||||
) from exc
|
||||
|
||||
# 1. 激活窗口(非阻塞,避免 --sync 死等)
|
||||
window_id = await self.find_wechat_window()
|
||||
if window_id is None:
|
||||
raise BridgeError(
|
||||
code="WINDOW_NOT_FOUND",
|
||||
message="未找到微信窗口,无法定位会话",
|
||||
)
|
||||
await _step(self._activate_window_fast(window_id), "激活窗口")
|
||||
await _step(self._sleep(0.3), "等待窗口激活")
|
||||
|
||||
# 2. 关闭可能存在的搜索框/弹窗
|
||||
await _step(self._key("Escape"), "关闭搜索框")
|
||||
await _step(self._sleep(0.2), "等待 Esc 生效")
|
||||
|
||||
# 3. 打开搜索
|
||||
await _step(self._key("ctrl+f"), "打开搜索")
|
||||
await _step(self._sleep(0.4), "等待搜索框打开")
|
||||
|
||||
# 4. 清空并输入搜索关键词
|
||||
await _step(self._key("ctrl+a"), "全选搜索框内容")
|
||||
await _step(self._sleep(0.1), "等待全选")
|
||||
await _step(self._paste_via_xclip(name), "粘贴搜索关键词")
|
||||
await _step(self._sleep(0.8), "等待搜索结果")
|
||||
|
||||
# 5. 选中第一个结果并进入会话
|
||||
await _step(self._key("Down"), "选中搜索结果")
|
||||
await _step(self._sleep(0.3), "等待选中")
|
||||
await _step(self._key("Return"), "进入会话")
|
||||
await _step(self._sleep(0.6), "等待会话打开")
|
||||
|
||||
# 6. 退出搜索模式,确保焦点在输入框
|
||||
await _step(self._key("Escape"), "退出搜索模式")
|
||||
await _step(self._sleep(0.2), "等待焦点稳定")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 发送文本
|
||||
# ------------------------------------------------------------------
|
||||
async def send_text(
|
||||
self,
|
||||
to_wxid: str,
|
||||
content: str,
|
||||
display_name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""发送文本消息。
|
||||
|
||||
流程:
|
||||
1. 通过微信搜索框定位会话(使用 display_name,未提供时回退到 to_wxid)
|
||||
2. 粘贴文本内容并回车发送
|
||||
3. 返回本地生成的 channel_msg_id
|
||||
|
||||
Args:
|
||||
to_wxid: 目标 wxid
|
||||
content: 文本内容
|
||||
display_name: 用于搜索定位会话的显示名(备注/昵称/微信号)
|
||||
|
||||
Returns:
|
||||
本地生成的 channel_msg_id,格式 local_<unix秒>_<随机>
|
||||
"""
|
||||
# 1. 定位会话
|
||||
await self._open_session_by_name(display_name if display_name else to_wxid)
|
||||
# 2. 粘贴内容并发送
|
||||
await self._paste_via_xclip(content)
|
||||
await self._sleep(0.2)
|
||||
await self._key("Return")
|
||||
# 3. 生成 local_send_id(本地 ID,非微信原生 msg_id)
|
||||
local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
|
||||
return local_send_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 发送文件 / 图片
|
||||
# ------------------------------------------------------------------
|
||||
async def send_file(
|
||||
self,
|
||||
to_wxid: str,
|
||||
file_path: str,
|
||||
is_image: bool = False,
|
||||
display_name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""发送文件/图片(MVP 简化版)。
|
||||
|
||||
MVP 策略:检查文件可读 → 进入会话(复用 _open_session_by_name)
|
||||
→ 发送"[图片/文件] 文件名"提示文本。真实文件传输机制留给后续完善。
|
||||
|
||||
Args:
|
||||
to_wxid: 目标 wxid
|
||||
file_path: 容器内文件绝对路径
|
||||
is_image: 是否为图片
|
||||
display_name: 用于搜索定位会话的显示名
|
||||
|
||||
Returns:
|
||||
本地生成的 channel_msg_id,格式 local_<unix秒>_<随机>
|
||||
|
||||
Raises:
|
||||
BridgeError(INVALID_PARAMS): 文件不存在或不可读
|
||||
"""
|
||||
# 文件存在性校验
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
raise BridgeError(
|
||||
code="INVALID_PARAMS",
|
||||
message=f"文件不存在: {file_path}",
|
||||
)
|
||||
if not os.access(file_path, os.R_OK):
|
||||
raise BridgeError(
|
||||
code="INVALID_PARAMS",
|
||||
message=f"文件不可读: {file_path}",
|
||||
)
|
||||
|
||||
# 1. 定位会话
|
||||
await self._open_session_by_name(display_name if display_name else to_wxid)
|
||||
|
||||
# 2. MVP:发送文件路径提示文本(真实文件传输留给后续完善)
|
||||
filename = os.path.basename(file_path)
|
||||
prefix = "[图片]" if is_image else "[文件]"
|
||||
hint = f"{prefix} {filename}"
|
||||
await self._paste_via_xclip(hint)
|
||||
await self._sleep(0.2)
|
||||
await self._key("Return")
|
||||
|
||||
# 3. 生成 local_send_id(本地 ID,非微信原生 msg_id)
|
||||
local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
|
||||
return local_send_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 退出登录
|
||||
# ------------------------------------------------------------------
|
||||
async def logout(self) -> None:
|
||||
"""通过 UI 操作退出微信登录。
|
||||
|
||||
流程:
|
||||
1. 检测登录态,若非 logged_in 直接返回(幂等)
|
||||
2. 查找并激活微信窗口(复用 window_id)
|
||||
3. 获取窗口几何,点击左下角主菜单图标
|
||||
4. 等待菜单弹出,用方向键导航到「退出登录」并回车
|
||||
5. 若出现确认对话框,按回车确认
|
||||
6. 等待 2 秒让 UI 完成切换
|
||||
|
||||
注意:下方点击与方向键坐标/次数均为估算值,需在目标分辨率
|
||||
实测后调优(微信 4.0 Linux UI 路径见模块 docstring)。
|
||||
|
||||
Raises:
|
||||
BridgeError(LOGOUT_FAILED): 窗口未找到或 UI 操作失败
|
||||
"""
|
||||
# 1. 检测登录态(幂等)
|
||||
state = await self.detect_login_state()
|
||||
if state != LoginState.LOGGED_IN.value:
|
||||
# 未登录,无需退出
|
||||
return
|
||||
|
||||
# 2. 查找窗口 ID 并激活(复用 window_id,避免二次 search)
|
||||
window_id = await self.find_wechat_window()
|
||||
if window_id is None:
|
||||
raise BridgeError(
|
||||
code="LOGOUT_FAILED",
|
||||
message="未找到微信窗口,无法退出登录",
|
||||
)
|
||||
await self.activate_window(window_id)
|
||||
|
||||
# 3. 获取窗口几何(用已知的 window_id,避免命令链歧义)
|
||||
returncode, stdout, _ = await self._run(
|
||||
["xdotool", "getwindowgeometry", "--shell", str(window_id)]
|
||||
)
|
||||
if returncode != 0:
|
||||
raise BridgeError(
|
||||
code="LOGOUT_FAILED",
|
||||
message="无法获取微信窗口几何信息",
|
||||
)
|
||||
# 解析 getwindowgeometry --shell 输出(KEY=VALUE 格式)
|
||||
geom = {}
|
||||
for line in stdout.decode(errors="ignore").splitlines():
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
geom[k.strip()] = v.strip()
|
||||
try:
|
||||
win_x = int(geom.get("X", 0))
|
||||
win_y = int(geom.get("Y", 0))
|
||||
win_w = int(geom.get("WIDTH", 800))
|
||||
win_h = int(geom.get("HEIGHT", 600))
|
||||
except ValueError:
|
||||
raise BridgeError(
|
||||
code="LOGOUT_FAILED",
|
||||
message="解析窗口几何信息失败",
|
||||
)
|
||||
|
||||
# 4. 点击左下角主菜单图标
|
||||
# 估算:窗口左侧栏底部,约 x=win_x+30, y=win_y+win_h-30
|
||||
# 该坐标随分辨率/缩放变化,需实测调优。
|
||||
menu_x = win_x + 30
|
||||
menu_y = win_y + win_h - 30
|
||||
rc, _, _ = await self._run(
|
||||
["xdotool", "mousemove", "--sync", str(menu_x), str(menu_y)]
|
||||
)
|
||||
if rc != 0:
|
||||
raise BridgeError(code="LOGOUT_FAILED", message="移动鼠标到主菜单失败")
|
||||
# 用 xdotool click 1(左键单击)
|
||||
rc, _, _ = await self._run(["xdotool", "click", "1"])
|
||||
if rc != 0:
|
||||
raise BridgeError(code="LOGOUT_FAILED", message="点击主菜单失败")
|
||||
|
||||
# 5. 等待菜单弹出
|
||||
await self._sleep(0.8)
|
||||
|
||||
# 6. 用方向键导航到「退出登录」并回车
|
||||
# 微信菜单项顺序通常为:设置 / 切换账号 / 退出登录 / 关闭
|
||||
# 退出登录一般在第 3 项,从顶部按 ↓ 2 次到达
|
||||
# 该顺序为估算,需实测调优。
|
||||
await self._key("Down")
|
||||
await self._sleep(0.2)
|
||||
await self._key("Down")
|
||||
await self._sleep(0.2)
|
||||
await self._key("Return")
|
||||
|
||||
# 7. 等待确认对话框
|
||||
await self._sleep(0.8)
|
||||
# 按回车确认(默认焦点通常在确认按钮)
|
||||
await self._key("Return")
|
||||
|
||||
# 8. 等待 UI 完成切换
|
||||
await self._sleep(2.0)
|
||||
@ -42,6 +42,9 @@ services:
|
||||
# DNS-rebinding 防护:套 HTTPS 反代部署时把对外域名加进 .env(详见 .env.example)。
|
||||
# 默认仅放行 loopback + RFC1918 私网,直连 NAS / 局域网无需改动。
|
||||
- PANEL_ALLOWED_HOSTS=${PANEL_ALLOWED_HOSTS:-}
|
||||
# bridge 业务 API 的 M2M 鉴权 token(供外部系统如 ForcePilot 调用 /api/bridge/:id/*)
|
||||
# 留空=仅允许管理员会话鉴权(兼容旧部署);配置后支持 Authorization: Bearer <token> 直连。
|
||||
- WOC_BRIDGE_API_TOKEN=${WOC_BRIDGE_API_TOKEN:-}
|
||||
|
||||
volumes:
|
||||
# 面板账号数据(用户、实例元信息、密码哈希)
|
||||
|
||||
@ -67,35 +67,68 @@ ENV LANG=zh_CN.UTF-8 \
|
||||
# 累积不 reset、退格风暴,导致大量丢字 / ~21 字卡住 / 跨浏览器不稳。改为只在 compositionend
|
||||
# 用 e.data 直发成品字符串(详见 woc-www-patch.sh / woc-ime.pl)。
|
||||
# 注意:实际加载的是 webpack 产物 dist/main.bundle.js(app/ui.js 是未打包源码、运行时不加载),故必须改 bundle。
|
||||
COPY woc-www-patch.sh woc-ime.pl /woc/
|
||||
COPY docker/woc-www-patch.sh docker/woc-ime.pl /woc/
|
||||
RUN chmod 755 /woc/woc-www-patch.sh && chmod 644 /woc/woc-ime.pl && /woc/woc-www-patch.sh
|
||||
|
||||
# 微信下载/解压控制脚本(运行时由面板经 docker exec 触发,状态写入数据卷 /config/.woc-state)
|
||||
COPY wechat-ctl.sh /woc/wechat-ctl.sh
|
||||
COPY docker/wechat-ctl.sh /woc/wechat-ctl.sh
|
||||
RUN chmod 755 /woc/wechat-ctl.sh
|
||||
|
||||
# v1.2.0 多应用:应用定义(app-defs.sh,被 autostart/app-ctl 引用)+ 通用安装/状态控制(app-ctl.sh,
|
||||
# 微信委托回 wechat-ctl.sh、其它应用各自实现)。app-defs.sh 是被 source 的、不需可执行位。
|
||||
COPY app-defs.sh app-ctl.sh /woc/
|
||||
COPY docker/app-defs.sh docker/app-ctl.sh /woc/
|
||||
RUN chmod 644 /woc/app-defs.sh && chmod 755 /woc/app-ctl.sh
|
||||
|
||||
# openbox 会话启动时执行此脚本:等待微信就绪 + 常驻拉起微信 + 最小化自动复原看守
|
||||
COPY autostart /defaults/autostart
|
||||
COPY docker/autostart /defaults/autostart
|
||||
RUN chmod 755 /defaults/autostart
|
||||
|
||||
# 启动钩子(00):给每个实例唯一且持久的 machine-id,避免所有实例共用镜像里烤死的同一个,
|
||||
# 触发腾讯"设备农场"风控导致登录即被强制退出。须在 autostart(拉起微信)之前执行,故用 00 前缀。
|
||||
COPY woc-identity.sh /custom-cont-init.d/00-woc-identity
|
||||
COPY docker/woc-identity.sh /custom-cont-init.d/00-woc-identity
|
||||
RUN chmod 755 /custom-cont-init.d/00-woc-identity
|
||||
|
||||
# 启动钩子(01):每次启动用镜像内最新 autostart 覆盖数据卷旧副本(否则旧实例升级后用不上新逻辑)
|
||||
COPY woc-update-autostart /custom-cont-init.d/01-woc-autostart
|
||||
COPY docker/woc-update-autostart /custom-cont-init.d/01-woc-autostart
|
||||
RUN chmod 755 /custom-cont-init.d/01-woc-autostart
|
||||
|
||||
# 启动钩子(02):把容器环境 WOC_APP_TYPE 写入 /config/.woc-app,供 autostart 选择启动哪个应用。
|
||||
# 须在 autostart 之前执行;缺 WOC_APP_TYPE 则不写 → autostart 回退微信(向后兼容)。
|
||||
COPY woc-app-init.sh /custom-cont-init.d/02-woc-app
|
||||
COPY docker/woc-app-init.sh /custom-cont-init.d/02-woc-app
|
||||
RUN chmod 755 /custom-cont-init.d/02-woc-app
|
||||
|
||||
# ptrace 权限配置(key_extractor 读微信进程内存需要)
|
||||
COPY docker/woc-ptrace-init.sh /custom-cont-init.d/03-woc-ptrace
|
||||
RUN chmod 755 /custom-cont-init.d/03-woc-ptrace
|
||||
|
||||
# 3000 = HTTP web 客户端, 3001 = HTTPS
|
||||
EXPOSE 3000 3001
|
||||
# ===== 新增:woc-bridge 业务 API 服务 =====
|
||||
# 安装 Python3 + 依赖 + sqlite3(DB 读取用)+ scrot(截图用,供 /api/screenshot 与 /api/login/qr/*)
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip \
|
||||
sqlite3 \
|
||||
scrot; \
|
||||
pip3 install --break-system-packages --no-cache-dir \
|
||||
fastapi==0.115.0 \
|
||||
"uvicorn[standard]==0.30.0" \
|
||||
python-multipart==0.0.9 \
|
||||
pillow==10.4.0 \
|
||||
cryptography; \
|
||||
apt-get clean; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 拷贝 bridge 服务代码到 /opt/woc-bridge/
|
||||
COPY bridge/ /opt/woc-bridge/
|
||||
RUN chmod 755 /opt/woc-bridge/server.py
|
||||
|
||||
# 注册为 s6-rc longrun 服务(svc-woc-bridge)
|
||||
RUN mkdir -p /etc/s6-overlay/s6-rc.d/svc-woc-bridge
|
||||
COPY bridge/s6/woc-bridge/run /etc/s6-overlay/s6-rc.d/svc-woc-bridge/run
|
||||
COPY bridge/s6/woc-bridge/type /etc/s6-overlay/s6-rc.d/svc-woc-bridge/type
|
||||
RUN chmod 755 /etc/s6-overlay/s6-rc.d/svc-woc-bridge/run \
|
||||
&& touch /etc/s6-overlay/s6-rc.d/user/contents.d/svc-woc-bridge
|
||||
|
||||
# 3000 = KasmVNC web 客户端(HTTP), 3001 = KasmVNC web 客户端(HTTPS), 8088 = woc-bridge 业务 API
|
||||
EXPOSE 3000 3001 8088
|
||||
|
||||
5
docker/woc-ptrace-init.sh
Normal file
5
docker/woc-ptrace-init.sh
Normal file
@ -0,0 +1,5 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
# 设置 ptrace_scope=0,允许 bridge 进程读取微信进程内存
|
||||
# 用于 SQLCipher 密钥自动提取(key_extractor.py 读 /proc/<pid>/mem)
|
||||
echo 0 > /proc/sys/kernel/yama/ptrace_scope 2>/dev/null || \
|
||||
echo "woc-ptrace: 无法设置 ptrace_scope(可能需要 --privileged 或 SYS_PTRACE capability)" >&2
|
||||
@ -177,6 +177,17 @@ function envList(inst: Instance): string[] {
|
||||
// 微信等 Chromium 系应用即跟随系统深色)。开关由面板顶栏主题统一控制、持久化在 accounts.json,
|
||||
// 运行中的实例则通过 setInstanceDark 实时切换(见下)。
|
||||
if (getDesktopDark()) env.push('WOC_DARK=1');
|
||||
// woc-bridge 行为参数透传到实例容器(bridge 进程在容器内 os.environ.get 读取)。
|
||||
// WOC_BRIDGE_PORT 不透传:端口写死 8088,与 Dockerfile EXPOSE / 面板反代 target 三处耦合。
|
||||
for (const k of [
|
||||
'WOC_BRIDGE_SEND_DELAY_MS',
|
||||
'WOC_BRIDGE_MAX_CALLS_PER_SEC',
|
||||
'WOC_BRIDGE_MAX_BATCH_SIZE',
|
||||
'WOC_BRIDGE_POLL_INTERVAL_MS',
|
||||
]) {
|
||||
const v = process.env[k];
|
||||
if (v) env.push(`${k}=${v}`);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
@ -221,6 +232,7 @@ export async function runInstance(inst: Instance): Promise<void> {
|
||||
Binds: [`${inst.volumeName}:/config`],
|
||||
NetworkMode: net || undefined,
|
||||
SecurityOpt: ['seccomp=unconfined'],
|
||||
CapAdd: ['SYS_PTRACE'],
|
||||
ShmSize: SHM_SIZE,
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
};
|
||||
@ -253,7 +265,10 @@ export async function runInstance(inst: Instance): Promise<void> {
|
||||
// 反代靠容器名 name 寻址,与此 hostname 无关。
|
||||
Hostname: realisticHostname(inst.id),
|
||||
Env: envList(inst),
|
||||
ExposedPorts: { '3000/tcp': {} },
|
||||
ExposedPorts: {
|
||||
'3000/tcp': {},
|
||||
'8088/tcp': {},
|
||||
},
|
||||
HostConfig: hostConfig,
|
||||
};
|
||||
// 自定义网络时,MAC 须写到对应 endpoint 上(新版 docker 弃用顶层 MacAddress);默认网络则用顶层。
|
||||
|
||||
@ -1263,6 +1263,83 @@ const desktopHandler = (req: FastifyRequest, reply: FastifyReply) => {
|
||||
app.all('/desktop/:id', desktopHandler);
|
||||
app.all('/desktop/:id/*', desktopHandler);
|
||||
|
||||
// ---------- 反向代理到实例的 woc-bridge 业务 API ----------
|
||||
// /api/bridge/:id/* → http://woc-wx-<id>:8088/*
|
||||
// 双鉴权:
|
||||
// 1. M2M 调用:Authorization: Bearer <WOC_BRIDGE_API_TOKEN>(供外部系统如 ForcePilot 后端调用)
|
||||
// 2. 浏览器调用:管理员会话 cookie(woc_sess)
|
||||
// 业务 API 等同微信会话凭据(可发消息/读消息/查联系人),任一鉴权通过即可访问。
|
||||
// WOC_BRIDGE_API_TOKEN 未配置时仅允许会话鉴权(兼容旧部署)。
|
||||
const BRIDGE_API_TOKEN = process.env.WOC_BRIDGE_API_TOKEN || '';
|
||||
function parseBridgeUrl(rawUrl: string): { id: string; rest: string } | null {
|
||||
const m = rawUrl.match(/^\/api\/bridge\/([0-9a-f]{6,})(\/.*|\?.*|)?$/);
|
||||
if (!m) return null;
|
||||
const id = m[1];
|
||||
let rest = m[2] || '/';
|
||||
if (rest.startsWith('?')) rest = '/' + rest;
|
||||
if (rest === '') rest = '/';
|
||||
return { id, rest };
|
||||
}
|
||||
|
||||
// 常量时间字符串比较,避免 Bearer token 校验遭受时序攻击
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
const bridgeHandler = (req: FastifyRequest, reply: FastifyReply) => {
|
||||
// 鉴权 1:Bearer token(M2M 调用)
|
||||
// 仅当面板配置了 WOC_BRIDGE_API_TOKEN 时启用,避免空 token 误放行
|
||||
let tokenAuthorized = false;
|
||||
if (BRIDGE_API_TOKEN) {
|
||||
const auth = req.headers.authorization || '';
|
||||
if (auth.startsWith('Bearer ') && timingSafeEqual(auth.slice(7), BRIDGE_API_TOKEN)) {
|
||||
tokenAuthorized = true;
|
||||
}
|
||||
}
|
||||
// 鉴权 2:未通过 token 则要求管理员会话
|
||||
if (!tokenAuthorized) {
|
||||
if (!requireAdmin(req, reply)) return;
|
||||
}
|
||||
const parsed = parseBridgeUrl(req.raw.url || '');
|
||||
if (!parsed) {
|
||||
reply.code(404).send({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
const inst = findInstance(parsed.id);
|
||||
if (!inst) {
|
||||
reply.code(404).send({ error: '实例不存在' });
|
||||
return;
|
||||
}
|
||||
reply.hijack();
|
||||
req.raw.url = parsed.rest;
|
||||
proxy.web(req.raw, reply.raw, {
|
||||
target: `http://${inst.containerName}:8088`,
|
||||
onError: (err: any) => {
|
||||
try {
|
||||
reply.raw.writeHead(502, { 'content-type': 'application/json' });
|
||||
reply.raw.end(JSON.stringify({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'BRIDGE_UNAVAILABLE',
|
||||
message: 'bridge 服务暂时不可用',
|
||||
details: String(err?.message || err || ''),
|
||||
},
|
||||
}));
|
||||
} catch {
|
||||
// 响应已部分写出,无法再写 JSON,仅记日志
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
app.all('/api/bridge/:id', bridgeHandler);
|
||||
app.all('/api/bridge/:id/*', bridgeHandler);
|
||||
|
||||
// ---------- 静态 SPA + 前端路由回退 ----------
|
||||
await app.register(fstatic, { root: STATIC_DIR, wildcard: false, index: ['index.html'] });
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
|
||||
1504
panel/server/src/index.ts.bak
Normal file
1504
panel/server/src/index.ts.bak
Normal file
File diff suppressed because it is too large
Load Diff
@ -26,7 +26,8 @@ echo "==> 构建面板镜像 ${PANEL_IMAGE} (版本号 ${VER})"
|
||||
docker build --provenance=false --sbom=false --build-arg "WOC_VERSION=${VER}" -t "${PANEL_IMAGE}" "${ROOT}/panel"
|
||||
|
||||
echo "==> 构建微信实例镜像 ${WECHAT_IMAGE}"
|
||||
docker build --provenance=false --sbom=false -t "${WECHAT_IMAGE}" "${ROOT}/docker"
|
||||
# 构建上下文为项目根:Dockerfile 内 COPY 同时引用 docker/ 内文件与 bridge/ 目录
|
||||
docker build --provenance=false --sbom=false -f "${ROOT}/docker/Dockerfile" -t "${WECHAT_IMAGE}" "${ROOT}"
|
||||
|
||||
echo
|
||||
echo "完成。本地镜像:"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user