feat: 新增woc-bridge服务及配套能力

新增完整的woc-bridge业务服务,实现微信API代理、密钥自动提取、消息队列限流等功能:
1. 新增bridge核心代码与s6服务配置
2. 新增ptrace权限初始化脚本与docker配置
3. 新增M2M鉴权API代理与环境变量配置
4. 新增SQLCipher解密、密钥缓存、二维码截图等工具模块
5. 完善docker构建与实例部署配置
This commit is contained in:
Kris 2026-07-07 19:04:13 +08:00
parent 0036d5911b
commit a2201d8be3
21 changed files with 7875 additions and 10 deletions

View File

@ -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

File diff suppressed because it is too large Load Diff

345
bridge/decryptor.py Normal file
View 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 0x3APBKDF2-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 0x3APBKDF2-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
View 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] = {}
# 默认 keyenv 注入或单 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
View File

@ -0,0 +1,532 @@
"""Linux 微信进程内存密钥提取器。
参考 wechat-cli-main 实现
- 通过 /proc/<pid>/maps 枚举微信进程可读内存段
- 通过 /proc/<pid>/mem 读取内存
- 匹配 x'<hex>' 模式64 enc_key96 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 MiBfull_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
- 96enc_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
View 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_codeDB 不可达时的具体原因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_invaliddb_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_idbridge 本地生成的发送 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 salt32 位十六进制)。未指定时 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环境变量/ apiPOST 注入)/ 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
View 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
View 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}

View File

@ -0,0 +1 @@
longrun

135
bridge/send_queue.py Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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:
窗口 IDint找不到返回 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 只需 killautostart 会在 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:
# 用 killshell 内建)发 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 autofixpkill 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 stdincreate_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)

View File

@ -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:
# 面板账号数据(用户、实例元信息、密码哈希)

View File

@ -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.jsapp/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 + 依赖 + sqlite3DB 读取用)+ 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

View 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

View File

@ -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默认网络则用顶层。

View File

@ -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. 浏览器调用:管理员会话 cookiewoc_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) => {
// 鉴权 1Bearer tokenM2M 调用)
// 仅当面板配置了 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) => {

File diff suppressed because it is too large Load Diff

View File

@ -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 "完成。本地镜像:"