diff --git a/.env.example b/.env.example index 8333a13..5955a0f 100644 --- a/.env.example +++ b/.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 即可,无需管理员会话 cookie。 +# 不配置(留空)时退化为仅允许管理员会话鉴权(兼容旧部署,浏览器访问不受影响)。 +# +# 生成建议: openssl rand -hex 32 (64 位十六进制字符) +# +# 权限范围: 持有该 token 即可访问全部实例的全部 bridge 业务 API +# (发消息/读消息/查联系人/退出登录/重启微信等),无 per-instance / per-action 粒度。 +# 请妥善保管;轮换时修改 .env 后 `docker compose up -d` 重建面板容器即生效。 +# +# 调用示例(ForcePilot 侧): +# GET http://:36080/api/bridge//api/messages/since +# Authorization: Bearer +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 diff --git a/bridge/db_reader.py b/bridge/db_reader.py new file mode 100644 index 0000000..51836a4 --- /dev/null +++ b/bridge/db_reader.py @@ -0,0 +1,1045 @@ +"""微信本地 DB 只读访问。 + +通过解密缓存避免锁定原 DB,再用 sqlite3 读取消息。所有方法为同步阻塞操作, +在 server.py 中通过 asyncio.to_thread() 包装调用,避免阻塞 event loop。 + +架构升级(bridge 1.3.0+): +- 密钥统一由 KeyCache 管理(single source of truth),DbReader 不再自持密钥存储 +- 密钥持久化、salt 映射、默认 key、已拒绝 salt 全部由 KeyCache 负责 +- 解密后的明文 DB 缓存到 /config/woc-decrypted//,按 mtime 失效 +- WAL 文件自动合并到明文缓存 +- /api/status 不触发内存扫描,直接判断 keys 文件和缓存有效性 +- 新增 clear_default_key 转发,env/api key 验证失败时仅清默认 key +""" + +from __future__ import annotations + +import atexit +import os +import re as _re +import sqlite3 +import threading +from typing import Optional + +from decryptor import Decryptor +from key_cache import KeyCache +from models import BridgeError + + +# SQLCipher page 常量 +PAGE_SIZE = 4096 +SALT_SIZE = 16 + +# 消息类型 → render_type 映射(规格 12.2 节) +_TYPE_TO_RENDER_TYPE: dict[int, str] = { + 1: "text", + 3: "image", + 34: "voice", + 43: "video", + 49: "file", + 10000: "system", + 10002: "system", +} + + +class DbReader: + """微信本地 DB 只读访问器。 + + 核心变化: + 1. 以 /db_storage 为根,通过相对路径访问各类 DB + 2. 密钥统一由 key_cache(KeyCache)管理,DbReader 仅查询不存储 + 3. 解密缓存到 decrypted_dir(/config/woc-decrypted//) + 4. 查询时按需解密 + 自动 WAL 合并 + """ + + def __init__( + self, + db_root: str = "/config", + key_cache: Optional[KeyCache] = None, + decrypted_dir: str = "/config/woc-decrypted", + ) -> None: + """初始化 DbReader。 + + Args: + db_root: 微信数据根目录,默认 /config + key_cache: 密钥统一存储(KeyCache 实例)。为 None 时内部创建 + 默认 KeyCache(/config/woc-keys.json),便于独立测试 + decrypted_dir: 解密缓存根目录 + """ + self.db_root = db_root + self.decrypted_dir = decrypted_dir + # 密钥统一来源:所有 key 查询/注入通过 key_cache + self.key_cache: KeyCache = key_cache if key_cache is not None else KeyCache() + + self._lock = threading.Lock() + + # 解密缓存的内存索引:rel_path -> (db_mtime, wal_mtime, decrypted_path) + self._decrypted_cache: dict[str, tuple[float, float, str]] = {} + + # 媒体路径缓存:msg_id -> path(或 None),避免每次 get_media 都 os.walk + self._media_path_cache: dict[str, Optional[str]] = {} + self._media_path_cache_max = 2000 + + # 进程退出时清理未持久化的临时文件 + atexit.register(self._cleanup_temp_files) + + # ------------------------------------------------------------------ + # 当前账号 / db_storage 路径探测 + # ------------------------------------------------------------------ + def _get_current_wxid(self) -> Optional[str]: + """通过 /config/xwechat_files/all_users/login//key_info.db mtime 判断当前登录账号。""" + login_dir = os.path.join(self.db_root, "xwechat_files", "all_users", "login") + if not os.path.isdir(login_dir): + return None + candidates = [] + for name in os.listdir(login_dir): + key_info = os.path.join(login_dir, name, "key_info.db") + if os.path.isfile(key_info): + candidates.append((name, os.path.getmtime(key_info))) + if not candidates: + return None + candidates.sort(key=lambda x: x[1], reverse=True) + return candidates[0][0] + + def _find_db_storage_dir(self) -> Optional[str]: + """查找当前登录账号的 db_storage 目录。""" + base = os.path.join(self.db_root, "xwechat_files") + if not os.path.isdir(base): + return None + + candidates = [] + for name in os.listdir(base): + if name == "all_users": + continue + path = os.path.join(base, name, "db_storage") + if os.path.isdir(path): + # 确认目录下确实有 DB + has_db = False + for root, _, files in os.walk(path): + if any(f.endswith(".db") for f in files): + has_db = True + break + if has_db: + candidates.append((path, os.path.getmtime(path))) + + if not candidates: + return None + + # 优先匹配当前 wxid + current_wxid = self._get_current_wxid() + if current_wxid: + for path, _ in candidates: + if current_wxid in path: + return path + + # 否则选最近修改的 + candidates.sort(key=lambda x: x[1], reverse=True) + return candidates[0][0] + + def _find_db_path(self) -> Optional[str]: + """返回一个可直接用于验证/探测的 DB 文件路径(contact.db 或 message_0.db)。""" + storage = self._find_db_storage_dir() + if storage is None: + return None + for rel in ("contact/contact.db", "message/message_0.db"): + path = os.path.join(storage, rel) + if os.path.isfile(path): + return path + return None + + def _get_db_abs_path(self, rel_path: str) -> Optional[str]: + """把相对路径(如 contact/contact.db)转成绝对路径。 + + 优先按拼接路径返回;若文件不存在则按 basename 在 storage 下递归查找, + 兼容 WeChat 4.x 不同版本 DB 实际存放路径可能与硬编码相对路径不一致的情况。 + """ + storage = self._find_db_storage_dir() + if storage is None: + return None + abs_path = os.path.normpath(os.path.join(storage, rel_path)) + # 防止路径穿越 + if not abs_path.startswith(os.path.normpath(storage) + os.sep): + return None + # 快速路径:拼接路径存在则直接返回 + if os.path.isfile(abs_path): + return abs_path + # fallback:按 basename 在 storage 下递归查找 + target_name = os.path.basename(rel_path) + for root, _, files in os.walk(storage): + if target_name in files: + return os.path.join(root, target_name) + return None + + def _get_wxid_from_storage(self, storage_dir: Optional[str] = None) -> Optional[str]: + """从 db_storage 路径推断 wxid。""" + if storage_dir is None: + storage_dir = self._find_db_storage_dir() + if storage_dir is None: + return None + # 路径形如 /config/xwechat_files/wxid_xxx_1234/db_storage + parts = os.path.normpath(storage_dir).split(os.sep) + for part in reversed(parts): + if part.startswith("wxid_"): + return part + return None + + # ------------------------------------------------------------------ + # 密钥访问(全部转发到 KeyCache,DbReader 不再自持密钥存储) + # ------------------------------------------------------------------ + def set_keys( + self, + key_map: dict[str, str], + source: str = "auto_extract", + pid: Optional[int] = None, + start_time: Optional[float] = None, + ) -> None: + """批量设置 salt -> enc_key 映射。 + + 转发到 KeyCache.set_key_map,由 KeyCache 负责持久化。 + keys 变化后清空解密缓存索引,避免旧密钥解密的脏数据被复用。 + + Args: + key_map: {salt_hex: enc_key_hex} + source: 密钥来源(auto_extract / api / env) + pid: 自动提取时关联的微信进程 PID(用于进程重启后失效检测) + start_time: 自动提取时关联的进程启动时间 + """ + wxid = self._get_current_wxid() + self.key_cache.set_wxid(wxid) + self.key_cache.set_key_map( + key_map, + source=source, + verified=True, + pid=pid, + start_time=start_time, + ) + # keys 变化后清空解密缓存,让查询重新生成 + self._decrypted_cache.clear() + + def set_key( + self, + key_hex: Optional[str], + salt_hex: Optional[str] = None, + ) -> None: + """设置单个密钥(转发到 KeyCache)。 + + 任何 key 变更都会清空解密缓存索引,避免旧密钥解密的脏数据被复用。 + """ + if key_hex is None: + self.key_cache.clear() + else: + self.key_cache.set_key(key_hex, salt_hex=salt_hex) + self._decrypted_cache.clear() + + def set_default_key(self, key_hex: Optional[str]) -> None: + """设置默认密钥(env 注入专用,转发到 KeyCache)。 + + 不影响 salt_to_key 持久化映射,env key 仅作为默认兜底。 + """ + self.key_cache.set_default_key(key_hex, source="env", verified=False) + + def clear_default_key(self) -> None: + """仅清空默认 key(保留持久化 salt_to_key 映射)。 + + 用于 env/api key 验证失败后清除默认 key,不影响已持久化的多 salt 映射。 + keys 变化后清空解密缓存索引。 + """ + self.key_cache.clear_default_key() + self._decrypted_cache.clear() + + def get_keys(self) -> dict[str, str]: + """返回当前持久化的 salt -> key 映射副本。""" + return self.key_cache.get_keys() + + def has_keys(self) -> bool: + """是否有可用的密钥(持久化或默认 key)。""" + return self.key_cache.has_keys() + + def _get_effective_key(self, salt_hex: Optional[str] = None) -> Optional[str]: + """返回指定 salt 的有效密钥(转发到 KeyCache)。""" + return self.key_cache.get_key(salt_hex) + + def _mark_default_key_mismatch(self, salt_hex: str) -> None: + """标记默认 key 不匹配该 salt(转发到 KeyCache)。""" + self.key_cache.mark_default_key_mismatch(salt_hex) + + def _read_db_salt(self, db_path: str) -> Optional[str]: + """读取 DB 第 1 页前 16 字节 salt。""" + try: + with open(db_path, "rb") as f: + page1 = f.read(PAGE_SIZE) + if len(page1) < SALT_SIZE: + return None + return page1[:SALT_SIZE].hex() + except OSError: + return None + + # ------------------------------------------------------------------ + # 解密缓存 + # ------------------------------------------------------------------ + def _cleanup_temp_files(self) -> None: + """进程退出时清理临时解密文件(如果有)。""" + # 持久化缓存保留,不删除 + pass + + def _decrypted_path_for(self, rel_path: str, wxid: Optional[str] = None) -> str: + """计算解密缓存文件路径。""" + if wxid is None: + wxid = self._get_wxid_from_storage() or "unknown" + # rel_path 本身通常以 .db 结尾(如 contact/contact.db),直接保留即可 + safe_rel = rel_path.replace(os.sep, "_").replace("/", "_") + cache_dir = os.path.join(self.decrypted_dir, wxid) + os.makedirs(cache_dir, exist_ok=True) + return os.path.join(cache_dir, safe_rel) + + def _is_plain_db(self, db_path: str) -> bool: + """DB 是否为明文 SQLite。""" + try: + with open(db_path, "rb") as f: + header = f.read(16) + return header == b"SQLite format 3\x00" + except OSError: + return False + + def _ensure_decrypted(self, rel_path: str) -> str: + """确保指定相对路径的 DB 已解密到缓存,返回明文 DB 绝对路径。 + + 流程: + 1. 定位原 DB + 2. 明文 DB 直接返回原路径 + 3. 读取 salt,查找对应 key + 4. 检查解密缓存是否有效(mtime + key 一致) + 5. 无效则重新解密,并合并 WAL + """ + db_path = self._get_db_abs_path(rel_path) + if db_path is None: + raise BridgeError( + code="DB_NOT_FOUND", + message=f"未找到微信 DB: {rel_path}", + ) + if not os.path.isfile(db_path): + raise BridgeError( + code="DB_NOT_FOUND", + message=f"DB 文件不存在: {rel_path}", + ) + + # 明文直接返回 + if self._is_plain_db(db_path): + return db_path + + # 加密 DB:需要 key + salt_hex = self._read_db_salt(db_path) + if not salt_hex: + raise BridgeError( + code="DB_ENCRYPTED", + message=f"无法读取 DB salt: {rel_path}", + ) + + key_hex = self._get_effective_key(salt_hex) + if not key_hex: + raise BridgeError( + code="DB_NEED_INIT", + message="DB 已加密且没有可用密钥,请先调用 /api/db/init", + ) + + wxid = self._get_wxid_from_storage() + decrypted_path = self._decrypted_path_for(rel_path, wxid) + wal_path = db_path + "-wal" + + try: + db_mtime = os.path.getmtime(db_path) + wal_mtime = os.path.getmtime(wal_path) if os.path.exists(wal_path) else 0.0 + except OSError as e: + raise BridgeError( + code="DB_NOT_FOUND", + message=f"无法获取 DB mtime: {e}", + ) + + # 检查内存缓存索引 + cached = self._decrypted_cache.get(rel_path) + if cached and cached[0] == db_mtime and cached[1] == wal_mtime and os.path.exists(cached[2]): + return cached[2] + + with self._lock: + # 双重检查 + cached = self._decrypted_cache.get(rel_path) + if cached and cached[0] == db_mtime and cached[1] == wal_mtime and os.path.exists(cached[2]): + return cached[2] + + # 解密 + decryptor = Decryptor(key_hex) + result = decryptor.decrypt_db(db_path, decrypted_path) + if not result.get("success"): + error = result.get("error", "unknown") + if os.path.exists(decrypted_path): + os.remove(decrypted_path) + # 默认 key 解密失败时标记 salt,避免后续重复尝试 + if key_hex == self.key_cache.get_default_key() and salt_hex: + self._mark_default_key_mismatch(salt_hex) + raise BridgeError( + code="DB_ENCRYPTED", + message=f"解密 {rel_path} 失败: {error}", + ) + + # 合并 WAL + if os.path.exists(wal_path): + try: + decryptor.decrypt_wal(wal_path, decrypted_path) + except Exception: + # WAL 合并失败不影响主 DB 读取,只是可能缺最新数据 + pass + + os.chmod(decrypted_path, 0o644) + self._decrypted_cache[rel_path] = (db_mtime, wal_mtime, decrypted_path) + return decrypted_path + + def invalidate_decrypted_cache(self) -> None: + """清空解密缓存索引(不删除文件,下次按 mtime 重新判断)。""" + with self._lock: + self._decrypted_cache.clear() + + # ------------------------------------------------------------------ + # DB 可达性状态 + # ------------------------------------------------------------------ + def check_db_status(self) -> str: + """检测 DB 状态。 + + Returns: + "ok": 有 DB 且可直接/解密读取 + "not_found": 未找到 DB + "need_init": DB 加密但没有密钥文件/内存 key + "key_invalid": 有密钥但验证失败(极少见) + """ + storage = self._find_db_storage_dir() + if storage is None: + return "not_found" + + # 找一个需要读取的 DB 探针(contact.db 或 message_0.db) + probe_rel_paths = ["contact/contact.db", "message/message_0.db"] + probe_path = None + for rel in probe_rel_paths: + p = os.path.join(storage, rel) + if os.path.isfile(p): + probe_path = p + break + + if probe_path is None: + # 目录存在但找不到探针文件,可能 DB 还没完全生成 + return "not_found" + + if self._is_plain_db(probe_path): + return "ok" + + if not self.has_keys(): + return "need_init" + + # 有 keys,快速验证是否能解密 + salt_hex = self._read_db_salt(probe_path) + if salt_hex and self._get_effective_key(salt_hex): + return "ok" + + return "key_invalid" + + def is_db_accessible(self) -> bool: + """DB 是否可读。""" + return self.check_db_status() == "ok" + + # ------------------------------------------------------------------ + # 辅助:表/列探测 + # ------------------------------------------------------------------ + @staticmethod + def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]: + """用 PRAGMA table_info 取指定表的所有列名。""" + try: + cur = conn.execute(f"PRAGMA table_info({table})") + return [str(row[1]) for row in cur.fetchall() if row[1]] + except sqlite3.Error: + return [] + + @staticmethod + def _pick_column(columns: list[str], candidates: list[str]) -> Optional[str]: + """从候选列名中返回第一个匹配(不区分大小写)。""" + lower = {c.lower(): c for c in columns} + for cand in candidates: + if cand.lower() in lower: + return lower[cand.lower()] + return None + + @staticmethod + def _find_contact_table(conn: sqlite3.Connection) -> Optional[str]: + """探测联系人表名。""" + try: + cur = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + all_tables = {row[0] for row in cur.fetchall() if row[0]} + except sqlite3.Error: + return None + for cand in ("contact", "contacts", "rcontact"): + if cand in all_tables: + return cand + for name in all_tables: + if "contact" in name.lower(): + return name + return None + + @staticmethod + def _find_table_by_name(conn: sqlite3.Connection, candidates: list[str]) -> Optional[str]: + """按候选名查找表。""" + try: + cur = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + all_tables = {row[0] for row in cur.fetchall() if row[0]} + except sqlite3.Error: + return None + for cand in candidates: + if cand in all_tables: + return cand + return None + + # ------------------------------------------------------------------ + # 当前账号信息 + # ------------------------------------------------------------------ + def get_self_info(self) -> dict: + """读取当前登录账号的 wxid 与 nickname。""" + result = {"wxid": "", "nickname": ""} + storage = self._find_db_storage_dir() + if storage is None: + return result + wxid = self._get_wxid_from_storage(storage) + result["wxid"] = wxid or "" + try: + db_path = self._ensure_decrypted("contact/contact.db") + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + table = self._find_contact_table(conn) + if table is None: + return result + cols = self._table_columns(conn, table) + username_col = self._pick_column(cols, ["username", "wxid"]) + nickname_col = self._pick_column(cols, ["nickname", "nick_name"]) + if username_col is None or not wxid: + return result + cur = conn.execute( + f"SELECT {nickname_col or 'NULL'} AS nickname FROM {table} " + f"WHERE {username_col} = ? LIMIT 1", + (wxid,), + ) + row = cur.fetchone() + if row: + result["nickname"] = str(row["nickname"] or "") + finally: + conn.close() + except Exception: + pass + return result + + # ------------------------------------------------------------------ + # 消息查询 + # ------------------------------------------------------------------ + def _row_to_message(self, row: sqlite3.Row) -> dict: + """将 SQL 行映射为 Message dict。""" + msg_id = str(row["msg_id"]) + talker = row["talker"] or "" + is_sender = bool(row["is_sender"]) + msg_type = int(row["type"]) if row["type"] is not None else 0 + content = row["content"] or "" + create_time = int(row["create_time"]) if row["create_time"] is not None else 0 + + render_type = _TYPE_TO_RENDER_TYPE.get(msg_type, "text") + session_type = "group" if talker.endswith("@chatroom") else "p2p" + + sender = "" + if session_type == "group" and is_sender is False and "\n" in content: + head, _, _ = content.partition("\n") + if head.endswith(":"): + sender = head[:-1] + content = content.split("\n", 1)[1] + + return { + "msg_id": msg_id, + "talker": talker, + "sender": sender, + "is_sender": is_sender, + "type": msg_type, + "render_type": render_type, + "content": content, + "create_time": create_time, + "session_type": session_type, + } + + def get_messages_since(self, cursor: int, limit: int = 50) -> dict: + """读取 create_time > cursor 的增量消息。""" + db_path = self._ensure_decrypted("message/message_0.db") + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + cur = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='message'" + ) + if cur.fetchone() is None: + raise BridgeError( + code="DB_NOT_FOUND", + message="message 表不存在", + ) + cur = conn.execute( + "SELECT msg_id, talker, is_sender, type, content, create_time " + "FROM message WHERE create_time > ? ORDER BY create_time ASC LIMIT ?", + (cursor, limit), + ) + rows = cur.fetchall() + finally: + conn.close() + + messages = [self._row_to_message(row) for row in rows] + next_cursor = messages[-1]["create_time"] if messages else cursor + has_more = len(messages) >= limit + return { + "messages": messages, + "next_cursor": next_cursor, + "has_more": has_more, + } + + # ------------------------------------------------------------------ + # 联系人字段映射(公共) + # ------------------------------------------------------------------ + def _pick_contact_columns(self, cols: list[str]) -> dict[str, Optional[str]]: + """根据实际表列名,挑选 contact 表各字段对应的列名。""" + return { + "username": self._pick_column(cols, ["username", "wxid"]), + "nickname": self._pick_column(cols, ["nickname", "nick_name"]), + "remark": self._pick_column(cols, ["remark", "conRemark"]), + "alias": self._pick_column(cols, ["alias"]), + "encrypt_username": self._pick_column(cols, ["encrypt_username", "encryptUsername"]), + "quan_pin": self._pick_column(cols, ["quan_pin", "quanPin"]), + "pin_yin_initial": self._pick_column(cols, ["pin_yin_initial", "pinYinInitial"]), + "big_head_url": self._pick_column(cols, ["big_head_url", "bigHeadImgUrl"]), + "small_head_url": self._pick_column(cols, ["small_head_url", "smallHeadImgUrl", "head_img_url"]), + "description": self._pick_column(cols, ["description", "signature"]), + "local_type": self._pick_column(cols, ["local_type", "localType"]), + "verify_flag": self._pick_column(cols, ["verify_flag", "verifyFlag"]), + "delete_flag": self._pick_column(cols, ["delete_flag", "deleteFlag"]), + "chat_room_type": self._pick_column(cols, ["chat_room_type", "chatRoomType"]), + } + + @staticmethod + def _avatar_url_from_row(row: sqlite3.Row, wxid: str) -> str: + """优先使用 DB 里的头像 URL,没有则回退到本地 avatar 接口。""" + for col in ("small_head_url", "big_head_url", "avatar"): + val = row[col] if col in row.keys() else None + if val: + return str(val) + return f"/api/media/avatar/{wxid}" + + def _row_to_contact(self, row: sqlite3.Row, contact_type: str = "friend") -> dict: + """把 sqlite3.Row 转成 Contact 兼容字典。""" + wxid = str(row["username"] or "") + + def _val(col: str) -> Optional[str]: + if col not in row.keys(): + return None + v = row[col] + if v is None: + return None + return str(v) if not isinstance(v, bytes) else None + + def _int_val(col: str) -> Optional[int]: + if col not in row.keys(): + return None + v = row[col] + if v is None: + return None + try: + return int(v) + except (TypeError, ValueError): + return None + + return { + "wxid": wxid, + "nickname": _val("nickname") or "", + "remark": _val("remark") or "", + "avatar_url": self._avatar_url_from_row(row, wxid), + "type": contact_type, + "alias": _val("alias"), + "encrypt_username": _val("encrypt_username"), + "quan_pin": _val("quan_pin"), + "pin_yin_initial": _val("pin_yin_initial"), + "big_head_url": _val("big_head_url"), + "small_head_url": _val("small_head_url"), + "description": _val("description"), + "local_type": _int_val("local_type"), + "verify_flag": _int_val("verify_flag"), + "delete_flag": _int_val("delete_flag"), + "chat_room_type": _int_val("chat_room_type"), + } + + def _select_contact_columns(self, col_map: dict[str, Optional[str]]) -> str: + """生成 SELECT 子句,只选实际存在的列。""" + selects = [f"{col_map['username']} AS username"] + for alias, col in col_map.items(): + if alias == "username" or col is None: + continue + selects.append(f"{col} AS {alias}") + return ", ".join(selects) + + def get_contacts(self, keyword: str = "", limit: int = 50) -> dict: + """联系人查询。""" + empty = {"contacts": [], "total": 0} + try: + db_path = self._ensure_decrypted("contact/contact.db") + except BridgeError: + return empty + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + table = self._find_contact_table(conn) + if table is None: + return empty + cols = self._table_columns(conn, table) + col_map = self._pick_contact_columns(cols) + username_col = col_map["username"] + nickname_col = col_map["nickname"] + remark_col = col_map["remark"] + if username_col is None: + return empty + + select_sql = self._select_contact_columns(col_map) + if keyword: + escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + like = f"%{escaped}%" + where_clauses = [f"{username_col} LIKE ? ESCAPE '\\'"] + params: list = [like] + if nickname_col: + where_clauses.append(f"{nickname_col} LIKE ? ESCAPE '\\'") + params.append(like) + if remark_col: + where_clauses.append(f"{remark_col} LIKE ? ESCAPE '\\'") + params.append(like) + where_sql = " OR ".join(where_clauses) + sql = ( + f"SELECT {select_sql} " + f"FROM {table} WHERE ({where_sql}) " + f"AND {username_col} NOT LIKE '%@chatroom' " + f"LIMIT ?" + ) + params.append(limit) + cur = conn.execute(sql, params) + else: + sql = ( + f"SELECT {select_sql} " + f"FROM {table} " + f"WHERE {username_col} NOT LIKE '%@chatroom' " + f"LIMIT ?" + ) + cur = conn.execute(sql, (limit,)) + contacts = [self._row_to_contact(row, contact_type="friend") for row in cur.fetchall()] + return {"contacts": contacts, "total": len(contacts)} + except sqlite3.Error: + return empty + finally: + conn.close() + + def get_contact_detail(self, wxid: str) -> Optional[dict]: + """单条联系人查询。""" + try: + db_path = self._ensure_decrypted("contact/contact.db") + except BridgeError: + return None + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + table = self._find_contact_table(conn) + if table is None: + return None + cols = self._table_columns(conn, table) + col_map = self._pick_contact_columns(cols) + username_col = col_map["username"] + if username_col is None: + return None + select_sql = self._select_contact_columns(col_map) + sql = f"SELECT {select_sql} FROM {table} WHERE {username_col} = ? LIMIT 1" + cur = conn.execute(sql, (wxid,)) + row = cur.fetchone() + if row is None: + return None + return self._row_to_contact(row, contact_type="friend") + except sqlite3.Error: + return None + finally: + conn.close() + + # ------------------------------------------------------------------ + # 群聊与群成员查询 + # ------------------------------------------------------------------ + def get_groups(self, limit: int = 50) -> dict: + """查询群聊列表。""" + empty = {"groups": [], "total": 0} + try: + db_path = self._ensure_decrypted("contact/contact.db") + except BridgeError: + return empty + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + table = self._find_contact_table(conn) + if table is None: + return empty + cols = self._table_columns(conn, table) + col_map = self._pick_contact_columns(cols) + username_col = col_map["username"] + if username_col is None: + return empty + select_sql = self._select_contact_columns(col_map) + sql = ( + f"SELECT {select_sql} " + f"FROM {table} WHERE {username_col} LIKE '%@chatroom' LIMIT ?" + ) + cur = conn.execute(sql, (limit,)) + groups = [self._row_to_contact(row, contact_type="group") for row in cur.fetchall()] + return {"groups": groups, "total": len(groups)} + except sqlite3.Error: + return empty + finally: + conn.close() + + def get_group_members(self, group_wxid: str) -> dict: + """查询群成员。""" + empty = {"group_wxid": group_wxid, "members": [], "total": 0} + try: + db_path = self._ensure_decrypted("contact/contact.db") + except BridgeError: + return empty + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + table = self._find_table_by_name( + conn, + ["chatroom_member", "chatroom_members", "group_member", "group_members"], + ) + if table is None: + return empty + cols = self._table_columns(conn, table) + member_wxid_col = self._pick_column(cols, ["username", "wxid", "member_wxid"]) + nickname_col = self._pick_column(cols, ["nickname", "nick_name"]) + display_name_col = self._pick_column(cols, ["display_name", "displayName", "group_nickname"]) + group_col = self._pick_column(cols, ["chatroom", "group_id", "chatroom_username", "room_id"]) + admin_col = self._pick_column( + cols, + ["is_admin", "admin", "is_manager", "manager", "room_admin", "is_room_admin"], + ) + if member_wxid_col is None or group_col is None: + return empty + sql = ( + f"SELECT {member_wxid_col} AS wxid, " + f"{nickname_col or 'NULL'} AS nickname, " + f"{display_name_col or 'NULL'} AS display_name, " + f"{admin_col or 'NULL'} AS is_admin " + f"FROM {table} WHERE {group_col} = ?" + ) + cur = conn.execute(sql, (group_wxid,)) + + owner_wxid = self._get_chatroom_owner(conn, group_wxid) + + members: list[dict] = [] + for row in cur.fetchall(): + wxid = str(row["wxid"] or "") + raw_admin = row["is_admin"] + if isinstance(raw_admin, str) and raw_admin.lower() in ("false", "0", "no", ""): + raw_admin = False + is_admin = bool(raw_admin) or (bool(wxid) and wxid == owner_wxid) + members.append({ + "wxid": wxid, + "nickname": str(row["nickname"] or ""), + "display_name": str(row["display_name"] or ""), + "is_admin": is_admin, + }) + return {"group_wxid": group_wxid, "members": members, "total": len(members)} + except sqlite3.Error: + return empty + finally: + conn.close() + + def _get_chatroom_owner(self, conn: sqlite3.Connection, group_wxid: str) -> Optional[str]: + """从 chatroom 表查询群主 wxid。""" + table = self._find_table_by_name(conn, ["chatroom", "chatrooms", "chat_room"]) + if table is None: + return None + cols = self._table_columns(conn, table) + owner_col = self._pick_column(cols, ["roomowner", "room_owner", "owner"]) + if owner_col is None: + return None + group_col = self._pick_column( + cols, + ["chatroom", "chatroomname", "chatroom_username", "group_id", "username", "room_id"], + ) + if group_col is None: + return None + try: + cur = conn.execute( + f"SELECT {owner_col} AS owner FROM {table} " + f"WHERE {group_col} = ? LIMIT 1", + (group_wxid,), + ) + row = cur.fetchone() + if row is None: + return None + return str(row["owner"] or "") or None + except sqlite3.Error: + return None + + # ------------------------------------------------------------------ + # 媒体路径解析 + # ------------------------------------------------------------------ + def get_media_path(self, msg_id: str) -> Optional[str]: + """根据 msg_id 解析媒体文件路径。""" + info = self._get_media_info(msg_id) + if info is None: + return None + return info.get("path") + + def get_media_bytes(self, msg_id: str) -> Optional[tuple[bytes, str]]: + """读取媒体文件内容。""" + path = self.get_media_path(msg_id) + if path is None or not os.path.isfile(path) or not os.access(path, os.R_OK): + return None + try: + with open(path, "rb") as f: + data = f.read() + except OSError: + return None + mime = self._guess_mime(path) + return (data, mime) + + def _get_media_info(self, msg_id: str) -> Optional[dict]: + """从 DB 查询单条消息的 type / content。""" + try: + db_path = self._ensure_decrypted("message/message_0.db") + except BridgeError: + return None + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + cols = self._table_columns(conn, "message") + if not cols: + return None + msg_id_col = self._pick_column(cols, ["msg_id"]) + type_col = self._pick_column(cols, ["type"]) + content_col = self._pick_column(cols, ["content"]) + if not (msg_id_col and type_col and content_col): + return None + cur = conn.execute( + f"SELECT {type_col} AS type, {content_col} AS content " + f"FROM message WHERE {msg_id_col} = ? LIMIT 1", + (msg_id,), + ) + row = cur.fetchone() + if row is None: + return None + msg_type = int(row["type"]) if row["type"] is not None else 0 + content = str(row["content"] or "") + path = self._resolve_media_path(msg_id, msg_type, content) + return {"type": msg_type, "content": content, "path": path} + except sqlite3.Error: + return None + finally: + conn.close() + + def _resolve_media_path(self, msg_id: str, msg_type: int, content: str) -> Optional[str]: + """根据消息类型与内容启发式解析媒体文件路径。 + + 优化点: + - 媒体路径按 msg_id 缓存,避免每次 get_media 都 os.walk 全盘扫描 + - 优先扫描已知媒体目录(attachment/message/file/image/video/voice), + 避免遍历 db_storage 下的 .db 文件 + - 移除 5000 文件硬上限(改为按目录优先级扫描,命中即返回) + """ + if msg_type not in (3, 34, 43, 49): + return None + # 命中缓存 + if msg_id in self._media_path_cache: + return self._media_path_cache[msg_id] + + result: Optional[str] = None + db_root_abs = os.path.realpath(self.db_root) + + # 策略 1:从 content 提取路径 + if content: + matches = _re.findall(r"/[^\s\"'<>]+", content) + for cand in matches: + if not os.path.isfile(cand) or not os.access(cand, os.R_OK): + continue + cand_abs = os.path.realpath(cand) + if cand_abs.startswith(db_root_abs + os.sep): + result = cand + break + + # 策略 2:在已知媒体目录下按 msg_id 前缀查找 + if result is None and msg_id: + storage = self._find_db_storage_dir() + search_dirs: list[str] = [] + if storage: + # 媒体文件通常在 db_storage 同级或上层目录 + parent = os.path.dirname(storage) + for sub in ("attachment", "message", "file", "image", "video", "voice", "audio"): + p = os.path.join(parent, sub) + if os.path.isdir(p): + search_dirs.append(p) + # db_storage 本身也作为兜底 + search_dirs.append(storage) + if not search_dirs and os.path.isdir(self.db_root): + search_dirs = [self.db_root] + + for search_dir in search_dirs: + if result: + break + for dirpath, _, filenames in os.walk(search_dir): + if result: + break + for name in filenames: + if name.endswith(".db"): + continue + stem, _ = os.path.splitext(name) + if stem == msg_id or stem.startswith(msg_id + "_") or stem.startswith(msg_id + "-"): + full = os.path.join(dirpath, name) + if os.access(full, os.R_OK): + result = full + break + + # 入缓存(含 None 结果,避免重复扫描未命中的 msg_id) + if len(self._media_path_cache) >= self._media_path_cache_max: + # FIFO 淘汰最旧 + oldest = next(iter(self._media_path_cache)) + del self._media_path_cache[oldest] + self._media_path_cache[msg_id] = result + return result + + @staticmethod + def _guess_mime(path: str) -> str: + """根据扩展名粗略猜测 MIME 类型。""" + ext = os.path.splitext(path)[1].lower() + return { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".bmp": "image/bmp", + ".webp": "image/webp", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".amr": "audio/amr", + ".mp4": "video/mp4", + ".pdf": "application/pdf", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".zip": "application/zip", + ".txt": "text/plain", + }.get(ext, "application/octet-stream") diff --git a/bridge/decryptor.py b/bridge/decryptor.py new file mode 100644 index 0000000..32f8b7e --- /dev/null +++ b/bridge/decryptor.py @@ -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) diff --git a/bridge/key_cache.py b/bridge/key_cache.py new file mode 100644 index 0000000..9daf1ba --- /dev/null +++ b/bridge/key_cache.py @@ -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 diff --git a/bridge/key_extractor.py b/bridge/key_extractor.py new file mode 100644 index 0000000..e70d450 --- /dev/null +++ b/bridge/key_extractor.py @@ -0,0 +1,532 @@ +"""Linux 微信进程内存密钥提取器。 + +参考 wechat-cli-main 实现: +- 通过 /proc//maps 枚举微信进程可读内存段 +- 通过 /proc//mem 读取内存 +- 匹配 x'' 模式(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//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(" 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 diff --git a/bridge/models.py b/bridge/models.py new file mode 100644 index 0000000..d003128 --- /dev/null +++ b/bridge/models.py @@ -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__<随机>。 + 仅用于客户端幂等去重,**不对应微信原生 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="失败原因") diff --git a/bridge/qr_capture.py b/bridge/qr_capture.py new file mode 100644 index 0000000..9a5614a --- /dev/null +++ b/bridge/qr_capture.py @@ -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 ` 截取整个屏幕到 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 diff --git a/bridge/s6/woc-bridge/run b/bridge/s6/woc-bridge/run new file mode 100644 index 0000000..d73004e --- /dev/null +++ b/bridge/s6/woc-bridge/run @@ -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} diff --git a/bridge/s6/woc-bridge/type b/bridge/s6/woc-bridge/type new file mode 100644 index 0000000..5883cff --- /dev/null +++ b/bridge/s6/woc-bridge/type @@ -0,0 +1 @@ +longrun diff --git a/bridge/send_queue.py b/bridge/send_queue.py new file mode 100644 index 0000000..5de9ed1 --- /dev/null +++ b/bridge/send_queue.py @@ -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) diff --git a/bridge/server.py b/bridge/server.py new file mode 100644 index 0000000..c0353ba --- /dev/null +++ b/bridge/server.py @@ -0,0 +1,2393 @@ +"""woc-bridge FastAPI 主入口(bridge 1.4.0)。 + +监听容器内 :8088,通过 xdotool/xclip 操作微信窗口、读取微信本地 DB, +提供收发消息等能力。 + +由 s6-rc 服务 `svc-woc-bridge` 常驻拉起(见 bridge/s6/woc-bridge/run), +启动前会等待微信窗口出现(最多 5 分钟),以确保 X 会话就绪。 + +版本演进: +- 1.0.0:P0 接口闭环(消息/联系人/群/发送/登录/重启/诊断基础能力) +- 1.1.0:补齐 6 项契约与能力缺口(W-05~W-10) + - W-05 发送响应字段 channel_msg_id → local_send_id,新增 placeholder + - W-06 群成员 is_admin 反映真实群主/管理员状态 + - W-07 /api/status 新增 send_queue_pending / media_supported / + bridge_capabilities / db_error_code 字段 + - W-08 /api/media 加密时返回 503 DB_ENCRYPTED(不再兜底 500) + - W-09 限流响应(429)携带 Retry-After 头 + - W-10 diagnostic autofix pkill 后 autostart 未拉起时显式启动兜底 +- 1.2.0:新增 SQLCipher DB 解密能力 +- 1.3.0:KeyCache 统一密钥来源;_resolve_db_state 统一 DB 状态解析; + 修复 env/api key 验证失败误清持久化 key;修复 _init_task start_time 硬编码 +- 1.4.0: + - _check_db_readable 改为非阻塞:触发后台提取后立即返回 503,不再卡住请求 + - 提取公共 _auto_extract_with_lock,diagnostic_autofix 复用同一加锁路径 + - login_qr_start 改用非阻塞窗口激活,避免 VNC 无人时死等 + - /api/status DB 状态加 1s 短时缓存,减少高频轮询时的重复 I/O + - send_text/display_name 解析加 LRU 缓存(5min TTL) + - 所有 assert 替换为显式 BridgeError,兼容 python -O + - _send_file 文件校验改用 isfile+access + - 移除 _read_current_wxid 死代码 + +路由总览(20 个): +- GET /api/status 状态聚合(进程/窗口/登录态/DB/版本/能力) +- GET /api/messages/since 增量消息拉取(按 create_time 游标) +- POST /api/send/text 发送文本消息(placeholder=false) +- POST /api/send/image 发送图片消息(占位实现,placeholder=true) +- POST /api/send/file 发送文件消息(占位实现,placeholder=true) +- POST /api/login/qr/start 启动扫码登录(截取二维码 data URL) +- GET /api/login/qr/wait 轮询等待扫码登录完成 +- POST /api/login/logout 退出微信登录(UI 操作,幂等) +- POST /api/wechat/restart 重启微信进程(pkill + autostart 拉起) +- GET /api/contacts 联系人列表(模糊匹配 wxid/昵称/备注) +- GET /api/contacts/{wxid} 单条联系人详情 +- GET /api/groups 群聊列表(username LIKE %@chatroom) +- GET /api/groups/{wxid}/members 群成员列表(含 is_admin 群主/管理员标识) +- GET /api/media/avatar/{wxid} 联系人头像(W-04 未实施,返回 404) +- GET /api/media/{msg_id} 下载消息媒体文件(DB 加密时返回 503) +- GET /api/diagnostic/connectivity 连通性检查(恒返回 reachable=true) +- GET /api/diagnostic/items 诊断项定义列表 +- POST /api/diagnostic/run/{check_id} 执行单个诊断项检查 +- POST /api/diagnostic/autofix/{check_id} 自动修复(pkill + autostart + 显式启动兜底) +- POST /api/screenshot 截取完整屏幕 PNG + +错误处理: +- 业务错误统一抛 `BridgeError(code, message, details)`,由全局异常处理器 + 转换为 `{"success": false, "error": {"code", "message", "details"}}` 结构 +- RATE_LIMITED (429) 额外携带 `Retry-After` 响应头 +- 非业务错误由兜底处理器捕获,返回 `BRIDGE_INTERNAL_ERROR` (HTTP 500), + 详细堆栈进日志,响应不泄露内部细节 +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import mimetypes +import os +import sys +import shutil +import threading +import time +import urllib.error +import urllib.request +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import AsyncIterator, Callable, Optional + +import uvicorn +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, Response + +from db_reader import DbReader +from models import ( + BridgeError, + ConnectivityResponse, + Contact, + ContactsResponse, + DbDecryptRequest, + DbDecryptResponse, + DbInitRequest, + DbInitResponse, + DbInitStatusResponse, + DbKeyStatusResponse, + DiagnosticItem, + DiagnosticRunResult, + ErrorResponse, + GroupMembersResponse, + GroupsResponse, + LoginState, + LogoutResponse, + MessagesResponse, + QrLoginStartResult, + QrLoginWaitResult, + RestartResponse, + SendFileRequest, + SendResponse, + SendTextRequest, + StatusResponse, +) +from qr_capture import QrCapture +from send_queue import SendQueue +from xdotool_driver import XdotoolDriver + + +# 模块级 logger:s6/supervise 会把 stderr 收进日志,便于排障 +logger = logging.getLogger("woc-bridge") + +# 为 woc-bridge logger 配置输出到 stderr 的 handler(默认 propagate=False,避免重复) +# uvicorn 自身会配置 root logger,这里单独配置确保业务日志一定可见 +if not logger.handlers: + _handler = logging.StreamHandler(sys.stderr) + _handler.setFormatter( + logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s") + ) + logger.addHandler(_handler) +logger.setLevel(logging.INFO) +logger.propagate = False + + +# bridge 版本号 +# 1.0.0 → 1.1.0:新增 send_queue_pending / media_supported / bridge_capabilities +# / db_error_code / placeholder / local_send_id / Retry-After 等字段与能力 +# 1.1.0 → 1.2.0:新增 SQLCipher DB 解密能力 +# 1.2.0 → 1.3.0:KeyCache 统一密钥来源;_resolve_db_state 统一 DB 状态解析; +# 修复 env/api key 验证失败误清持久化 key;修复 _init_task start_time 硬编码; +# _verify_db_key 内置 key_mode 返回;_check_db_readable 提取结果同步 init_state +# 1.3.0 → 1.4.0:_check_db_readable 非阻塞化;_auto_extract_with_lock 公共加锁提取; +# login_qr_start 非阻塞激活;DB 状态 1s 缓存;display_name LRU 缓存; +# assert → 显式 BridgeError;_send_file isfile+access;移除死代码 +BRIDGE_VERSION = "1.4.0" + +# bridge 能力清单(供客户端通过 /api/status 协商)。 +# 当前已落地能力:text_send(文本发送真实可用)、db_decrypt(SQLCipher DB 解密)。 +# 待落地能力(占位声明,未真正实现): +# - image_send:W-02 完成后追加 +# - file_send:W-02 完成后追加 +# - long_poll:W-03 完成后追加 +# - avatar:W-04 完成后追加 +# - group_admin:W-06 完成后追加 +BRIDGE_CAPABILITIES = ["text_send", "db_decrypt"] + +# 媒体发送是否已实现真实传输(W-02 完成后改 True)。 +# 客户端据此决定是否调 /api/send/image 与 /api/send/file。 +# +# **联动点**:W-02 完成时需同步修改三处: +# 1. 本常量 MEDIA_SUPPORTED = True +# 2. BRIDGE_CAPABILITIES 追加 "image_send" / "file_send" +# 3. _send_file 路由的 placeholder=True 改为 False +MEDIA_SUPPORTED = False + + +# --------------------------------------------------------------------------- +# 诊断项定义(spec 11.1 节) +# --------------------------------------------------------------------------- +DIAGNOSTIC_ITEMS = [ + { + "check_id": "wechat_running", + "name": "微信进程检查", + "severity": "critical", + "description": "检查微信客户端是否运行", + "auto_repairable": True, + }, + { + "check_id": "login_state", + "name": "登录状态检查", + "severity": "critical", + "description": "检查微信是否已登录", + "auto_repairable": False, + }, + { + "check_id": "db_accessible", + "name": "数据库可达性", + "severity": "error", + "description": "检查微信 DB 是否可读", + "auto_repairable": True, # 可通过自动提取 key 修复 + }, + { + "check_id": "xdotool_available", + "name": "xdotool 可用性", + "severity": "error", + "description": "检查 xdotool 是否可用", + "auto_repairable": True, + }, +] + + +# 诊断项 check_id → 定义映射,便于查找 +_DIAGNOSTIC_ITEM_BY_ID = {item["check_id"]: item for item in DIAGNOSTIC_ITEMS} + + +# --------------------------------------------------------------------------- +# 全局对象(在 lifespan 中初始化) +# --------------------------------------------------------------------------- +# KeyCache 已抽到独立模块 key_cache.py,作为单一密钥来源(single source of truth) +# - DbReader 通过 key_cache.get_key(salt) 查询解密密钥 +# - server.py 通过 key_cache.set_key / set_key_map / clear 注入 +# - 持久化(woc-keys.json)由 KeyCache 内部负责,避免双份同步 +from key_cache import KeyCache + + +class InitState: + """DB 初始化(密钥提取)后台任务状态。""" + + def __init__(self) -> None: + self.state: str = "idle" # idle / running / success / failed + self.progress_pct: Optional[float] = None + self.message: Optional[str] = None + self.key_count: Optional[int] = None + self.error: Optional[str] = None + self._thread: Optional[threading.Thread] = None + + def start(self, target: Callable, args: tuple = ()) -> None: + """启动后台初始化线程。 + + 若前一个线程仍存活,拒绝重复启动(调用方应先检查 state == running)。 + """ + if self._thread is not None and self._thread.is_alive(): + return + self.state = "running" + self.progress_pct = 0.0 + self.message = "开始初始化" + self.error = None + self._thread = threading.Thread(target=target, args=args, daemon=True) + self._thread.start() + + def set_progress(self, pct: float, message: str) -> None: + self.progress_pct = pct + self.message = message + + def set_success(self, key_count: int, message: str) -> None: + self.state = "success" + self.progress_pct = 100.0 + self.key_count = key_count + self.message = message + self.error = None + + def set_failed(self, error: str) -> None: + self.state = "failed" + self.progress_pct = None + self.error = error + self.message = f"初始化失败: {error}" + + +@dataclass +class BridgeConfig: + """bridge 服务的配置项(来自命令行参数 + 环境变量)。 + + 在启动时一次性构造并注入 AppState,运行期只读不写。 + 所有 os.environ.get / 类型转换集中在此处,避免散落。 + """ + + # 命令行参数 + listen: str = "0.0.0.0:8088" + wechat_db: str = "/config" + display: str = ":1" + # 环境变量 + send_delay_ms: int = 800 + max_calls_per_sec: int = 10 + max_batch_size: int = 50 + poll_interval_ms: int = 2000 + db_key: str = "" + auto_extract_enabled: bool = True + + @classmethod + def from_args_and_env(cls, args: argparse.Namespace) -> "BridgeConfig": + """从 argparse 结果 + 环境变量一次性构造配置。""" + return cls( + listen=args.listen, + wechat_db=args.wechat_db, + display=args.display, + send_delay_ms=int(os.environ.get("WOC_BRIDGE_SEND_DELAY_MS", "800")), + max_calls_per_sec=int(os.environ.get("WOC_BRIDGE_MAX_CALLS_PER_SEC", "10")), + max_batch_size=int(os.environ.get("WOC_BRIDGE_MAX_BATCH_SIZE", "50")), + poll_interval_ms=int(os.environ.get("WOC_BRIDGE_POLL_INTERVAL_MS", "2000")), + db_key=os.environ.get("WOC_DB_KEY", "").strip(), + auto_extract_enabled=os.environ.get( + "WOC_DB_KEY_AUTO_EXTRACT", "true" + ).lower() not in ("false", "0", "no", "off"), + ) + + +class AppState: + """运行期共享状态容器。""" + + def __init__(self) -> None: + self.config: BridgeConfig = BridgeConfig() + self.xdotool: XdotoolDriver | None = None + self.db_reader: DbReader | None = None + self.send_queue: SendQueue | None = None + self.qr_capture: QrCapture | None = None + self.start_time: float = 0.0 + # DB 解密密钥缓存(env / api / auto_extract / file 注入) + self.key_cache = KeyCache() + # DB 初始化后台任务状态 + self.init_state = InitState() + # 自动提取异步锁:防止并发请求同时触发内存扫描 + # asyncio.Lock 需在事件循环中创建,延迟到 lifespan 首次使用时初始化 + self._extract_lock: asyncio.Lock | None = None + + def extract_lock(self) -> asyncio.Lock: + """懒初始化 asyncio.Lock(需在事件循环中创建)。""" + if self._extract_lock is None: + self._extract_lock = asyncio.Lock() + return self._extract_lock + + +_state = AppState() + + +# --------------------------------------------------------------------------- +# 依赖检查辅助函数(替代 assert,兼容 python -O) +# --------------------------------------------------------------------------- +def _require_xdotool() -> XdotoolDriver: + """返回 xdotool 驱动,未初始化时抛 BridgeError。""" + if _state.xdotool is None: + raise BridgeError( + code="BRIDGE_INTERNAL_ERROR", + message="xdotool driver 未初始化", + ) + return _state.xdotool + + +def _require_db_reader() -> DbReader: + """返回 DB 读取器,未初始化时抛 BridgeError。""" + if _state.db_reader is None: + raise BridgeError( + code="BRIDGE_INTERNAL_ERROR", + message="db_reader 未初始化", + ) + return _state.db_reader + + +def _require_send_queue() -> SendQueue: + """返回发送队列,未初始化时抛 BridgeError。""" + if _state.send_queue is None: + raise BridgeError( + code="BRIDGE_INTERNAL_ERROR", + message="send_queue 未初始化", + ) + return _state.send_queue + + +def _require_qr_capture() -> QrCapture: + """返回二维码截图器,未初始化时抛 BridgeError。""" + if _state.qr_capture is None: + raise BridgeError( + code="BRIDGE_INTERNAL_ERROR", + message="qr_capture 未初始化", + ) + return _state.qr_capture + + +# --------------------------------------------------------------------------- +# lifespan +# --------------------------------------------------------------------------- +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """应用生命周期。 + + 兼容两种启动方式: + 1. `python3 server.py`:__main__ 已 _init_state,lifespan 直接用 + 2. `uvicorn server:app`:__main__ 未执行,lifespan 内兜底初始化 + + 启动 send_queue,yield,停止 send_queue。 + """ + # 兜底初始化:兼容 uvicorn server:app 启动方式 + if _state.xdotool is None: + _init_state_from_env() + _state.start_time = time.monotonic() + if _state.send_queue is not None: + await _state.send_queue.start() + try: + yield + finally: + if _state.send_queue is not None: + await _state.send_queue.stop() + + +def _init_state_from_env() -> None: + """从环境变量初始化全局状态(uvicorn 启动兜底路径)。""" + args = argparse.Namespace( + display=os.environ.get("DISPLAY", ":1") or ":1", + wechat_db=os.environ.get("WOC_WECHAT_DB", "/config"), + ) + _init_state(args) + + +app = FastAPI(title="woc-bridge", version=BRIDGE_VERSION, lifespan=lifespan) + + +# --------------------------------------------------------------------------- +# 全局异常处理 +# --------------------------------------------------------------------------- +@app.exception_handler(BridgeError) +async def bridge_error_handler(request: Request, exc: BridgeError) -> JSONResponse: + """捕获 BridgeError,返回统一错误结构。 + + 对 RATE_LIMITED 错误额外设置 Retry-After 响应头,供客户端退避。 + """ + headers: dict[str, str] | None = None + if exc.code == "RATE_LIMITED" and exc.details: + retry_after = exc.details.get("retry_after") + if isinstance(retry_after, int): + headers = {"Retry-After": str(retry_after)} + return JSONResponse( + status_code=exc.http_status, + content=ErrorResponse( + success=False, + error={ + "code": exc.code, + "message": exc.message, + "details": exc.details, + }, + ).model_dump(), + headers=headers, + ) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """捕获其他异常,返回 BRIDGE_INTERNAL_ERROR。 + + 日志里记完整异常信息便于排障;响应只回通用 message,不泄露内部细节。 + """ + logger.exception("unhandled exception on %s %s", request.method, request.url.path) + return JSONResponse( + status_code=500, + content=ErrorResponse( + success=False, + error={ + "code": "BRIDGE_INTERNAL_ERROR", + "message": "bridge internal error", + "details": None, + }, + ).model_dump(), + ) + + +# --------------------------------------------------------------------------- +# 请求/响应日志中间件:统一打印入口/出口日志与耗时 +# --------------------------------------------------------------------------- +# 慢请求阈值(毫秒):超过则升级为 WARNING +_SLOW_REQUEST_MS = 2000 + +# 不打印入口日志的路径(高频健康检查,避免日志噪声) +# 仍会打印出口日志(含耗时),便于监控延迟 +_QUIET_ENTRY_PATHS: set[str] = set() + + +@app.middleware("http") +async def request_logging_middleware(request: Request, call_next): + """统一 HTTP 请求/响应日志。 + + 入口:INFO 级别,打印 method + path + query(仅非 quiet 路径) + 出口:INFO 级别,打印 method + path + status_code + 耗时ms + - 4xx/5xx 升级为 WARNING/ERROR + - 超过 _SLOW_REQUEST_MS 升级为 WARNING(带 ⚠️ 标记) + - 响应体含 error.code 时额外打印错误码与 message + """ + method = request.method + path = request.url.path + query = str(request.url.query) if request.url.query else "" + + # 入口日志 + if path not in _QUIET_ENTRY_PATHS: + if query: + logger.info("→ %s %s?%s", method, path, query) + else: + logger.info("→ %s %s", method, path) + + start = time.perf_counter() + + try: + response = await call_next(request) + except Exception as exc: + # 未被异常处理器捕获的极端情况 + elapsed_ms = (time.perf_counter() - start) * 1000 + logger.error( + "← %s %s → EXCEPTION %s %.1fms: %s", + method, path, type(exc).__name__, elapsed_ms, exc, + ) + raise + + elapsed_ms = (time.perf_counter() - start) * 1000 + + # 出口日志级别按状态码分级 + status = response.status_code + if status >= 500: + log_level = logging.ERROR + elif status >= 400: + log_level = logging.WARNING + else: + log_level = logging.INFO + + # 慢请求升级为 WARNING + slow_marker = "" + if elapsed_ms > _SLOW_REQUEST_MS and log_level == logging.INFO: + log_level = logging.WARNING + slow_marker = " ⚠️ slow" + + logger.log( + log_level, + "← %s %s → %d %.1fms%s", + method, path, status, elapsed_ms, slow_marker, + ) + + return response + + +# --------------------------------------------------------------------------- +# 路由:GET /api/status +# --------------------------------------------------------------------------- +@app.get("/api/status", response_model=StatusResponse) +async def get_status() -> StatusResponse: + """聚合返回 bridge 运行状态。 + + 一次请求内完成 4 类检测:进程运行、窗口存在、登录态、DB 可达性, + 并返回当前账号信息与客户端轮询建议参数。所有调用方(channels 适配器、 + 面板健康检查、诊断 autofix)都应优先调本接口判断前置条件。 + + Returns: + StatusResponse:含 bridge_version / wechat_running / wechat_window_found + / login_state / db_accessible / current_wxid / current_nickname + / uptime_seconds / display / max_batch_size / poll_interval_ms + + Notes: + - 不抛业务错误:即使微信未运行 / DB 加密,也返回 200 + 对应 false 字段, + 让调用方按字段判定下一步动作(而非按 HTTP 状态码) + - login_state 内联判定(不调 detect_login_state),避免重复 pgrep/xdotool + - DB 加密时细分:有 key 且验证过 → encrypted_key_ok + db_accessible=true; + 无 key 且自动提取未启用 → encrypted_no_key; + 自动提取已尝试但失败 → key_extract_failed; + 二者 db_accessible 均为 false + - current_wxid/nickname 仅在 db_accessible=true 且 login_state=logged_in + 时才尝试读 DB,失败静默为空字符串 + """ + xdotool = _require_xdotool() + db_reader = _require_db_reader() + + # 一次 status 请求内复用 pgrep/xdotool 结果,避免重复调用 + wechat_running = await xdotool.is_wechat_running() + window_id = await xdotool.find_wechat_window() if wechat_running else None + wechat_window_found = window_id is not None + # 登录态内联判定,避免 detect_login_state 内部再次 pgrep/xdotool search + if not wechat_running: + login_state = "not_running" + elif not wechat_window_found: + login_state = "not_logged_in" + else: + login_state = "logged_in" + + # DB 状态:统一走 _resolve_db_state(不触发自动提取,仅解析) + db_state = await _resolve_db_state() + db_accessible = db_state.db_accessible + db_error_code = db_state.db_error_code + init_in_progress = _state.init_state.state == "running" + init_progress_pct = _state.init_state.progress_pct + init_message = _state.init_state.message + + # current_wxid / current_nickname:从 DB 读,失败为空 + current_wxid = "" + current_nickname = "" + if db_accessible and login_state == "logged_in": + try: + self_info = await asyncio.to_thread(db_reader.get_self_info) + current_wxid = self_info.get("wxid", "") + current_nickname = self_info.get("nickname", "") + except Exception: + current_wxid = "" + current_nickname = "" + + uptime_seconds = time.monotonic() - _state.start_time + + return StatusResponse( + bridge_version=BRIDGE_VERSION, + wechat_running=wechat_running, + wechat_window_found=wechat_window_found, + login_state=login_state, + db_accessible=db_accessible, + db_error_code=db_error_code, + init_in_progress=init_in_progress, + init_progress_pct=init_progress_pct, + init_message=init_message, + current_wxid=current_wxid, + current_nickname=current_nickname, + uptime_seconds=uptime_seconds, + display=_state.config.display, + max_batch_size=_state.config.max_batch_size, + poll_interval_ms=_state.config.poll_interval_ms, + send_queue_pending=_state.send_queue.pending_count() if _state.send_queue else 0, + media_supported=MEDIA_SUPPORTED, + bridge_capabilities=list(BRIDGE_CAPABILITIES), + ) + + +@dataclass +class DbState: + """DB 状态解析结果(由 _resolve_db_state 返回)。 + + 统一三处调用方(get_status / _check_db_readable / diagnostic_run)的 + DB 状态判断逻辑,避免重复代码与行为不一致。 + """ + status: str # ok / not_found / need_init / key_invalid / unreadable + db_accessible: bool # 是否可读(ok 或 有有效 key) + db_error_code: Optional[str] # 不可读时的错误码 + salt_hex: Optional[str] # 当前 DB 的 salt(加密 DB 才有) + key_hex: Optional[str] # 当前 DB 对应的有效 key(无则 None) + need_extract: bool # 无有效 key 且需要自动提取 + + +# DB 状态短时缓存:避免高频 /api/status 重复读 salt/验证 key +_db_state_cache: Optional[tuple[float, DbState]] = None +_DB_STATE_CACHE_TTL = 1.0 # 秒 + + +def _invalidate_db_state_cache() -> None: + """失效 DB 状态缓存(key 变化、提取完成时调用)。""" + global _db_state_cache + _db_state_cache = None + + +async def _resolve_db_state() -> DbState: + """统一解析 DB 状态、salt、有效 key、pid 失效检测、env/api key 验证。 + + 本函数只做"状态解析",不触发自动提取;提取由调用方按 need_extract 决定。 + 单次请求内 salt/pid 只读一次,结果携带在 DbState 中供调用方复用。 + 带 1 秒短时缓存,减少高频轮询时的重复 I/O。 + + Returns: + DbState:含 status / db_accessible / db_error_code / salt_hex + / key_hex / need_extract + """ + global _db_state_cache + + # 检查缓存 + if _db_state_cache is not None: + cached_at, cached_state = _db_state_cache + if time.monotonic() - cached_at < _DB_STATE_CACHE_TTL: + return cached_state + + db_reader = _require_db_reader() + + status = await asyncio.to_thread(db_reader.check_db_status) + + # 明文 DB 或不可达:直接返回 + if status == "ok": + result = DbState( + status=status, db_accessible=True, db_error_code=None, + salt_hex=None, key_hex=None, need_extract=False, + ) + _db_state_cache = (time.monotonic(), result) + return result + if status in ("not_found", "unreadable"): + result = DbState( + status=status, db_accessible=False, db_error_code=status, + salt_hex=None, key_hex=None, need_extract=False, + ) + _db_state_cache = (time.monotonic(), result) + return result + + # status in ("need_init", "key_invalid"):加密 DB + salt_hex = await asyncio.to_thread(_read_db_salt, db_reader) + key = db_reader._get_effective_key(salt_hex) if salt_hex else db_reader._get_effective_key() + + # auto_extract 的 key 在进程重启后失效 + if key and _state.key_cache.cached and _state.key_cache.source == "auto_extract": + wechat_pid_info = await _get_wechat_pid() + current_pid = wechat_pid_info[0] if wechat_pid_info else None + current_start_time = wechat_pid_info[1] if wechat_pid_info else None + if not _state.key_cache.is_valid_for_pid(current_pid, current_start_time): + _state.key_cache.clear() + db_reader.invalidate_decrypted_cache() + key = None + + # env/api 注入的 key 若未验证,快速验证 page1(4KB),避免无效 key 反复失败 + if key and _state.key_cache.cached and _state.key_cache.source in ("env", "api") and not _state.key_cache.verified: + verified, _key_mode = await asyncio.to_thread(_verify_db_key, db_reader, key) + if verified: + _state.key_cache.set_meta(verified=True) + else: + # env/api key 验证失败:仅清默认 key,保留持久化 salt_to_key + db_reader.clear_default_key() + key = None + + # 有有效 key:DB 可读 + if key: + result = DbState( + status=status, db_accessible=True, db_error_code="encrypted_key_ok", + salt_hex=salt_hex, key_hex=key, need_extract=False, + ) + _db_state_cache = (time.monotonic(), result) + return result + + # 无有效 key + init_in_progress = _state.init_state.state == "running" + if init_in_progress: + db_error_code = "init_in_progress" + elif _state.config.auto_extract_enabled: + db_error_code = "need_init" + else: + db_error_code = "encrypted_no_key" + + result = DbState( + status=status, + db_accessible=False, + db_error_code=db_error_code, + salt_hex=salt_hex, + key_hex=None, + need_extract=_state.config.auto_extract_enabled and not init_in_progress, + ) + _db_state_cache = (time.monotonic(), result) + return result + + +async def _auto_extract_with_lock() -> Optional[dict[str, str]]: + """带锁的自动密钥提取,结果同步到 init_state。 + + 供 diagnostic_autofix 直接调用(阻塞等待结果); + _check_db_readable 通过 asyncio.create_task 在后台调用(非阻塞)。 + 串行化:通过 extract_lock 确保同一时间只有一个提取任务。 + + Returns: + salt_hex -> enc_key_hex 映射;失败返回 None + """ + async with _state.extract_lock(): + # 双重检查:持锁后可能已被其他请求提取成功 + state = await _resolve_db_state() + if state.db_accessible: + return _state.db_reader.get_keys() if _state.db_reader else None + + if _state.init_state.state == "running": + # 已在运行(不应该发生,lock 串行化),返回 None + return None + + # 标记运行中,阻止后续并发请求重复扫描 + _state.init_state.state = "running" + _state.init_state.progress_pct = 0.0 + _state.init_state.message = "正在扫描内存提取密钥" + + try: + extracted = await _try_auto_extract_key() + if extracted: + _state.init_state.set_success( + len(extracted), + f"成功提取 {len(extracted)} 个密钥", + ) + else: + _state.init_state.set_failed("自动提取密钥失败") + return extracted + except Exception as e: + _state.init_state.set_failed(str(e)) + return None + finally: + # 兜底:异常路径未设置 success/failed 时回退 idle + if _state.init_state.state == "running": + _state.init_state.state = "idle" + _state.init_state.progress_pct = None + _state.init_state.message = None + # 失效 DB 状态缓存,让下次查询看到新 key + _invalidate_db_state_cache() + + +async def _check_db_readable() -> None: + """检查 DB 是否可读,不可读时抛出对应错误码。 + + 非阻塞设计(1.4.0+): + - not_found → DB_NOT_FOUND + - unreadable → DB_NOT_FOUND + - ok 或有有效 key → 不抛异常 + - 无有效 key 且提取进行中 → 抛 DB_ENCRYPTED(提示稍后重试) + - 无有效 key 且需提取 → 后台触发提取(不等待),立即抛 DB_ENCRYPTED + - 无有效 key 且提取已失败 → 抛 DB_ENCRYPTED(提示注入 key) + - 自动提取未启用 → 抛 DB_ENCRYPTED(提示注入 key) + + Raises: + BridgeError(DB_NOT_FOUND / DB_ENCRYPTED) + """ + db_reader = _require_db_reader() + + state = await _resolve_db_state() + + if state.status == "not_found": + raise BridgeError(code="DB_NOT_FOUND", message="未在 /config 下找到微信消息 DB") + if state.status == "unreadable": + raise BridgeError(code="DB_NOT_FOUND", message="DB 文件存在但不可读(权限问题)") + if state.db_accessible: + # ok 或有有效 key:不抛异常 + return + + # 无有效 key + init_state = _state.init_state.state + + if init_state == "running": + pct = _state.init_state.progress_pct or 0 + raise BridgeError( + code="DB_ENCRYPTED", + message=f"DB 已加密,密钥提取进行中({pct:.0f}%),请稍后重试", + ) + + if not state.need_extract: + # 自动提取未启用,或已提取过但当前 salt 无匹配 + if init_state == "failed": + raise BridgeError( + code="DB_ENCRYPTED", + message="DB 已加密(SQLCipher),自动提取密钥失败,需通过 POST /api/db/decrypt 注入密钥或设置 WOC_DB_KEY", + ) + raise BridgeError( + code="DB_ENCRYPTED", + message="DB 已加密(SQLCipher),需通过 POST /api/db/decrypt 注入密钥或设置 WOC_DB_KEY", + ) + + # need_extract=True:后台触发提取(非阻塞),立即返回 503 + asyncio.create_task(_auto_extract_with_lock()) + raise BridgeError( + code="DB_ENCRYPTED", + message="DB 已加密(SQLCipher),已触发后台密钥提取,请稍后重试", + ) + + +def _verify_db_key( + db_reader: DbReader, + key_hex: str, + salt_hex: str | None = None, +) -> tuple[bool, Optional[str]]: + """验证 key 是否匹配当前 DB 文件,并返回 key_mode。 + + 用 db_reader._find_db_path 找到 DB 文件,调 Decryptor.verify_key 验证。 + 验证通过时一并返回 key_mode(raw_enc_key / sqlcipher_passphrase), + 避免调用方再读一次 page1。 + + Args: + db_reader: DB 读取器 + key_hex: 64 位十六进制密钥 + salt_hex: 未使用(保留参数兼容旧调用),自动读取当前 DB salt + + Returns: + (verified, key_mode): verified 为是否验证通过; + key_mode 为密钥形态(验证失败时为 None) + """ + from decryptor import Decryptor + db_path = db_reader._find_db_path() + if db_path is None: + return (False, None) + try: + decryptor = Decryptor(key_hex) + verified = decryptor.verify_key(db_path) + if not verified: + return (False, None) + # 验证通过,获取 key_mode(复用已读的 page1 逻辑) + key_mode = _get_key_mode(key_hex, db_path) + return (True, key_mode) + except Exception: + return (False, None) + + +def _read_db_salt(db_reader: DbReader) -> str | None: + """读取当前 DB 文件的 salt(同步阻塞)。""" + db_path = db_reader._find_db_path() + if db_path is None: + return None + return db_reader._read_db_salt(db_path) + + +async def _get_wechat_pid() -> tuple[int, float] | None: + """获取微信主进程 PID 与启动时间。 + + 使用与 key_extractor 一致的进程识别逻辑(检查 /proc//comm 与 exe), + 避免 `pgrep -f xwechat` 漏掉主进程(主进程命令行不含 xwechat)。 + + Returns: + (pid, start_time) 或 None(进程未运行) + """ + try: + from key_extractor import _get_wechat_pids + pids = await asyncio.to_thread(_get_wechat_pids) + if not pids: + return None + # 取内存占用最大的进程作为主进程 + pid, _rss_kb = pids[0] + # 读 /proc//stat 的第 22 字段(start_time,单位 jiffies) + try: + with open(f"/proc/{pid}/stat", "r") as f: + stat_fields = f.read().split() + start_time = float(stat_fields[21]) if len(stat_fields) > 21 else 0.0 + except (OSError, IndexError, ValueError): + start_time = 0.0 + return (pid, start_time) + except Exception: + return None + + +async def _try_auto_extract_key() -> dict[str, str] | None: + """尝试从微信进程内存自动提取 SQLCipher 密钥。 + + 流程: + 1. 检查 auto_extract_enabled 开关 + 2. 检查微信进程是否运行(pgrep) + 3. 获取全部微信 PID 候选(不只取第一个) + 4. 找到加密 DB 目录作为探针 + 5. 对每个 PID 调 KeyExtractor.extract_all_keys() 提取 salt->enc_key 映射 + 6. 成功则缓存到 KeyCache(含 pid / start_time)并注入 DbReader + + Returns: + salt_hex -> enc_key_hex 映射字典;失败返回 None + """ + if not _state.config.auto_extract_enabled: + return None + _require_db_reader() + _require_xdotool() + + # 检查微信进程是否运行 + wechat_running = await _state.xdotool.is_wechat_running() + if not wechat_running: + return None + + # 找到加密 DB 目录作为探针 + db_path = await asyncio.to_thread(_state.db_reader._find_db_path) + if db_path is None: + logger.warning("自动提取密钥失败:未找到 DB 路径") + return None + + # 优先扫描整个 /config/xwechat_files,让 KeyExtractor 自己探测所有微信 PID + # 原 pgrep -f xwechat 会漏掉主进程(主进程命令行里没有 xwechat 字样) + db_dir = "/config/xwechat_files" + if not os.path.isdir(db_dir): + db_dir = os.path.dirname(db_path) + logger.info("自动提取密钥开始,探针目录: %s", db_dir) + + from key_extractor import KeyExtractor + try: + # pid=0 让 KeyExtractor 用 /proc 自探测,比 pgrep 更可靠 + extractor = KeyExtractor(0, db_path) + key_map = await asyncio.to_thread(extractor.extract_all_keys, db_dir) + if key_map: + # 记录成功时关联的 PID(取 key_map 中第一个地址反推太麻烦, + # 这里用 _get_wechat_pid 拿到主进程 PID 做缓存校验) + wechat_pid_info = await _get_wechat_pid() + wechat_pid = wechat_pid_info[0] if wechat_pid_info else 0 + start_time = wechat_pid_info[1] if wechat_pid_info else 0.0 + + # KeyExtractor 内部已通过 page 1 HMAC 验证,无需再次全库验证 + # 统一通过 db_reader.set_keys 转发到 KeyCache.set_key_map, + # 避免 key_cache 与 db_reader 两份重复同步 + _state.db_reader.set_keys( + key_map, + source="auto_extract", + pid=wechat_pid, + start_time=start_time, + ) + _invalidate_db_state_cache() + logger.info( + "自动提取 DB 密钥成功(主 PID=%s,%d salts),来源:微信进程内存扫描", + wechat_pid, + len(key_map), + ) + return key_map + except Exception as e: + logger.exception("自动提取密钥异常: %s", e) + + logger.warning("自动提取 DB 密钥失败:扫描全部微信进程后未找到有效密钥") + return None + + +def _get_key_mode(key_hex: str, db_path: str) -> str | None: + """获取 key 形态(raw_enc_key / sqlcipher_passphrase)。 + + 通过读取 DB 第 1 页 + _resolve_page1_key_material 验证后返回 mode。 + """ + try: + from decryptor import _resolve_page1_key_material + with open(db_path, "rb") as f: + page1 = f.read(4096) + key_bytes = bytes.fromhex(key_hex) + result = _resolve_page1_key_material(key_bytes, page1) + if result: + return result[2] + except Exception: + pass + return None + + +# --------------------------------------------------------------------------- +# 路由:POST /api/db/decrypt(手动 key 注入) +# --------------------------------------------------------------------------- +@app.post("/api/db/decrypt", response_model=DbDecryptResponse) +async def decrypt_db_with_key(request: DbDecryptRequest) -> DbDecryptResponse: + """手动传入 SQLCipher 密钥触发解密验证与缓存。 + + 外部系统(如 ForcePilot)或管理员通过本接口注入 64 位十六进制密钥, + bridge 验证 key 是否匹配当前 DB,验证通过后按 salt 缓存到 KeyCache, + 后续所有 DB 查询接口立即恢复可用。 + + 请求体除 key 外,可选传 salt 字段指定该 key 对应的 salt;不传时 bridge + 自动读取当前 DB 的 salt 进行匹配。 + + Args: + request: 含 key 字段(64 位十六进制字符串),可选 salt 字段 + + Returns: + DbDecryptResponse:含 success / verified / key_mode / error + """ + key = request.key.strip() + # 格式校验 + if len(key) != 64 or not all(c in "0123456789abcdefABCDEF" for c in key): + raise BridgeError( + code="INVALID_PARAMS", + message="key 必须是 64 位十六进制字符串", + ) + db_reader = _require_db_reader() + # 检查 DB 是否存在 + db_status = await asyncio.to_thread(db_reader.check_db_status) + if db_status == "not_found": + raise BridgeError( + code="DB_NOT_FOUND", + message="未找到微信 DB,无法验证 key", + ) + if db_status == "unreadable": + raise BridgeError( + code="DB_NOT_FOUND", + message="DB 文件存在但不可读(权限问题),无法验证 key", + ) + # 明文 DB 无需 key,直接返回提示 + if db_status == "ok": + return DbDecryptResponse( + success=True, + verified=False, + key_mode=None, + error="db_not_encrypted", + ) + # 获取当前 DB 的 salt(用户未指定时自动识别) + salt_hex = getattr(request, "salt", None) + if not salt_hex: + salt_hex = await asyncio.to_thread(_read_db_salt, db_reader) + if not salt_hex: + raise BridgeError( + code="DB_ENCRYPTED", + message="无法读取当前 DB 的 salt", + ) + # 验证 key(_verify_db_key 已内置 key_mode 获取,无需再读一次 page1) + try: + verified, key_mode = await asyncio.to_thread(_verify_db_key, db_reader, key) + except Exception: + verified, key_mode = False, None + if verified: + # 缓存 key 并注入 DbReader(按 salt 存储) + # db_reader.set_key 已转发到 key_cache.set_key,避免双份同步 + db_reader.set_key(key, salt_hex=salt_hex) + _invalidate_db_state_cache() + # 补充元信息(source/verified 在 set_key 默认为 auto_extract/True,这里覆盖为 api) + _state.key_cache.set_meta(source="api", verified=True) + logger.info( + "db/decrypt: key=%s...%s salt=%s → 验证成功, key_mode=%s", + key[:4], key[-4:], salt_hex, key_mode, + ) + return DbDecryptResponse( + success=True, + verified=True, + key_mode=key_mode, + error=None, + ) + else: + logger.warning( + "db/decrypt: key=%s...%s salt=%s → 验证失败(key 不匹配)", + key[:4], key[-4:], salt_hex, + ) + return DbDecryptResponse( + success=True, + verified=False, + key_mode=None, + error="key_mismatch", + ) + + +# --------------------------------------------------------------------------- +# 路由:GET /api/db/key/status(key 缓存状态查询) +# --------------------------------------------------------------------------- +@app.get("/api/db/key/status", response_model=DbKeyStatusResponse) +async def get_db_key_status() -> DbKeyStatusResponse: + """返回当前 DB 解密密钥缓存状态。 + + 供调用方判断是否需要注入 key 或等待自动提取。 + + Returns: + DbKeyStatusResponse:含 cached / source / verified / key_prefix + """ + return DbKeyStatusResponse( + cached=_state.key_cache.cached, + source=_state.key_cache.source, + verified=_state.key_cache.verified, + key_prefix=_state.key_cache.key_prefix(), + ) + + +# --------------------------------------------------------------------------- +# 路由:POST /api/db/init(显式触发密钥提取) +# --------------------------------------------------------------------------- +@app.post("/api/db/init", response_model=DbInitResponse) +async def init_db(request: DbInitRequest = DbInitRequest()) -> DbInitResponse: + """显式触发 DB 初始化(密钥提取)。 + + 后台线程执行内存扫描,避免阻塞 /api/status 等接口。调用方可通过 + GET /api/db/init/status 轮询进度,或观察 /api/status 中的 + init_in_progress / init_progress_pct / init_message 字段。 + + Args: + request: 可选 pid / db_dir / force + + Returns: + DbInitResponse:请求受理状态 + """ + _require_db_reader() + + if _state.init_state.state == "running": + return DbInitResponse( + success=True, + state="in_progress", + message=_state.init_state.message or "初始化中,请稍后", + key_count=None, + ) + + if _state.db_reader.has_keys() and not request.force: + key_count = len(_state.db_reader.get_keys()) + return DbInitResponse( + success=True, + state="already_done", + message=f"已初始化({key_count} 个密钥),使用 force=true 可强制重新提取", + key_count=key_count, + ) + + def _init_task(pid: Optional[int], db_dir: Optional[str], force: bool) -> None: + from key_extractor import KeyExtractor, _get_wechat_pids + + try: + _state.init_state.set_progress(10.0, "正在检测微信进程") + if pid: + pids = [(pid, 0)] + else: + pids = _get_wechat_pids() + if not pids: + _state.init_state.set_failed("微信进程未运行") + return + + _state.init_state.set_progress(20.0, "正在定位数据库目录") + if db_dir is None: + db_path = _state.db_reader._find_db_path() + if db_path is None: + _state.init_state.set_failed("未找到微信数据库目录") + return + db_dir = os.path.dirname(db_path) + scan_dir = "/config/xwechat_files" if os.path.isdir("/config/xwechat_files") else db_dir + + _state.init_state.set_progress(30.0, "正在扫描内存提取密钥") + extractor = KeyExtractor(pid or 0, db_dir) + key_map = extractor.extract_all_keys(scan_dir) + if not key_map: + _state.init_state.set_failed("未提取到任何有效密钥") + return + + _state.init_state.set_progress(80.0, "正在保存密钥到文件") + wechat_pid = pids[0][0] + # 读取真实 start_time,用于进程重启后 key 缓存失效检测 + start_time = 0.0 + try: + with open(f"/proc/{wechat_pid}/stat", "r") as f: + stat_fields = f.read().split() + start_time = float(stat_fields[21]) if len(stat_fields) > 21 else 0.0 + except (OSError, IndexError, ValueError): + start_time = 0.0 + _state.db_reader.set_keys( + key_map, + source="auto_extract", + pid=wechat_pid, + start_time=start_time, + ) + _invalidate_db_state_cache() + _state.init_state.set_success( + len(key_map), + f"成功提取 {len(key_map)} 个密钥", + ) + except Exception as e: + _state.init_state.set_failed(str(e)) + + _state.init_state.start(_init_task, args=(request.pid, request.db_dir, request.force)) + return DbInitResponse( + success=True, + state="started", + message="初始化已启动,可通过 /api/db/init/status 查询进度", + key_count=None, + ) + + +# --------------------------------------------------------------------------- +# 路由:GET /api/db/init/status +# --------------------------------------------------------------------------- +@app.get("/api/db/init/status", response_model=DbInitStatusResponse) +async def get_init_status() -> DbInitStatusResponse: + """查询 DB 初始化后台任务状态。""" + return DbInitStatusResponse( + state=_state.init_state.state, + progress_pct=_state.init_state.progress_pct, + message=_state.init_state.message, + key_count=_state.init_state.key_count, + error=_state.init_state.error, + ) + + +# --------------------------------------------------------------------------- +# 路由:GET /api/messages/since +# --------------------------------------------------------------------------- +@app.get("/api/messages/since", response_model=MessagesResponse) +async def get_messages_since(cursor: int = 0, limit: int = 50) -> MessagesResponse: + """增量消息拉取(按 create_time 游标)。 + + channels PullerAdapter 的核心接口:调用方维护本地 cursor(首次为 0), + 每次拉取 create_time > cursor 的消息,把响应中 next_cursor 存下来作为 + 下次的 cursor。has_more=true 时应立即继续拉取,否则按 poll_interval_ms 轮询。 + + Args: + cursor: 上次拉取的最大 create_time,首次传 0(拉全量) + limit: 最多返回条数,1~200,默认 50。建议用 /api/status 返回的 + max_batch_size 作为上限参考 + + Returns: + MessagesResponse:含 messages 列表 / next_cursor / has_more + + Raises: + BridgeError(INVALID_PARAMS): cursor<0 或 limit 越界(HTTP 400) + BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) + BridgeError(DB_ENCRYPTED): DB 已加密,需 SQLCipher 密钥(HTTP 503) + + Notes: + - DB 读取通过 cp 快照避免锁定原文件,sqlite3 同步阻塞,故用 + asyncio.to_thread 包装,避免阻塞 event loop + - 群消息 content 形如 "wxid:\\n正文",DbReader 会拆出 sender 字段 + - has_more=true 当且仅当返回条数 >= limit(可能还有更多未拉取) + """ + # 参数校验 + if cursor < 0: + raise BridgeError( + code="INVALID_PARAMS", + message=f"cursor 必须 >= 0,收到 {cursor}", + ) + if limit < 1 or limit > 200: + raise BridgeError( + code="INVALID_PARAMS", + message=f"limit 必须在 1~200 之间,收到 {limit}", + ) + + db_reader = _require_db_reader() + + # DB 加密时返回 DB_ENCRYPTED,而非空数据 + await _check_db_readable() + + # DB 读取是同步阻塞操作,用 to_thread 包装 + result = await asyncio.to_thread(db_reader.get_messages_since, cursor, limit) + msg_count = len(result.get("messages", [])) + logger.info( + "messages/since: cursor=%d limit=%d → 返回 %d 条, next_cursor=%s has_more=%s", + cursor, limit, msg_count, result.get("next_cursor"), result.get("has_more"), + ) + return MessagesResponse(**result) + + +# --------------------------------------------------------------------------- +# 路由:POST /api/send/text +# --------------------------------------------------------------------------- +# display_name 解析缓存:wxid -> (display_name, timestamp) +# 联系人备注/昵称不常变,5 分钟 TTL 足够;最大 256 条防内存膨胀 +_display_name_cache: dict[str, tuple[str, float]] = {} +_DISPLAY_NAME_CACHE_TTL = 300.0 # 5 分钟 +_DISPLAY_NAME_CACHE_MAX = 256 + + +async def _resolve_display_name( + db_reader: Optional[DbReader], + to_wxid: str, + display_name: Optional[str], +) -> str: + """解析用于微信搜索框定位会话的显示名。 + + 优先级: + 1. 调用方显式传入的 display_name(strip 后非空才用) + 2. 缓存命中(5 分钟 TTL) + 3. 从 contact.db 读取该 wxid 的备注(remark) + 4. 从 contact.db 读取该 wxid 的昵称(nickname) + 5. 回退到 wxid 本身 + + DB 不可用时静默回退到 wxid,避免阻塞发送流程。 + """ + # 显式传入优先 + if display_name and display_name.strip(): + return display_name.strip() + + # 缓存命中 + cached = _display_name_cache.get(to_wxid) + if cached and time.monotonic() - cached[1] < _DISPLAY_NAME_CACHE_TTL: + return cached[0] + + if db_reader is None: + return to_wxid + try: + contact = await asyncio.to_thread(db_reader.get_contact_detail, to_wxid) + except Exception: + return to_wxid + if contact: + resolved = contact.get("remark") or contact.get("nickname") or to_wxid + else: + resolved = to_wxid + + # 写入缓存(简单 LRU:超限时删最旧条目) + if len(_display_name_cache) >= _DISPLAY_NAME_CACHE_MAX: + oldest = min(_display_name_cache, key=lambda k: _display_name_cache[k][1]) + del _display_name_cache[oldest] + _display_name_cache[to_wxid] = (resolved, time.monotonic()) + return resolved + + +@app.post("/api/send/text", response_model=SendResponse) +async def send_text(req: SendTextRequest) -> SendResponse: + """发送文本消息(channels OutboundAdapter 核心接口)。 + + 流程: + 1. 校验 to_wxid / content 非空 + 2. 解析 display_name:显式传入 > 备注 > 昵称 > wxid + 3. 检测登录态,非 logged_in 直接拒绝(避免 UI 操作引发不可预期行为) + 4. 经 send_queue 串行执行:activate_window → Ctrl+F 搜索会话(按 display_name) → + 选中并进入 → xclip 粘贴 content → 回车发送 + 5. 返回本地生成的 local_send_id(不等待微信发送回执) + + Args: + req: SendTextRequest(to_wxid + content + 可选 display_name) + + Returns: + SendResponse:success=true / local_send_id(格式 local__<随机>) + / placeholder=false(文本发送为真实发送,非占位) + + Raises: + BridgeError(INVALID_PARAMS): to_wxid 或 content 为空(HTTP 400) + BridgeError(WECHAT_NOT_LOGGED_IN): 当前未登录(HTTP 401) + BridgeError(SEND_FAILED): xdotool/xclip 操作失败(HTTP 500) + + Notes: + - 发送队列串行化避免 Ctrl+F 搜索框竞态(前一条消息的搜索未关闭时, + 后一条会粘到错误的会话) + - local_send_id 由 bridge 本地生成,不对应微信真实 msg_id; + 调用方应用它做幂等去重,**禁止用它到 /api/media/{msg_id} 查媒体** + - 发送队列的发送间隔与限频由 .env 的 WOC_BRIDGE_SEND_DELAY_MS / + WOC_BRIDGE_MAX_CALLS_PER_SEC 控制 + """ + xdotool = _require_xdotool() + send_queue = _require_send_queue() + + # 校验请求体 + if not req.to_wxid: + raise BridgeError(code="INVALID_PARAMS", message="to_wxid 不能为空") + if not req.content: + raise BridgeError(code="INVALID_PARAMS", message="content 不能为空") + + # 检查登录态 + login_state = await xdotool.detect_login_state() + if login_state != "logged_in": + raise BridgeError( + code="WECHAT_NOT_LOGGED_IN", + message=f"当前登录态为 {login_state},无法发送消息", + ) + + # 解析显示名(优先备注/昵称,不再直接用 wxid 搜索) + display_name = await _resolve_display_name( + _state.db_reader, req.to_wxid, req.display_name + ) + + # 经发送队列串行执行 + try: + local_send_id = await send_queue.enqueue( + lambda: xdotool.send_text(req.to_wxid, req.content, display_name) + ) + except BridgeError: + raise + except Exception as e: + raise BridgeError( + code="SEND_FAILED", + message=f"发送失败: {e}", + ) + + logger.info( + "send/text: to=%s display_name=%s content_len=%d → 成功 local_send_id=%s", + req.to_wxid, display_name, len(req.content), local_send_id, + ) + return SendResponse(success=True, local_send_id=local_send_id, placeholder=False, error=None) + + +# --------------------------------------------------------------------------- +# 路由:POST /api/login/qr/start +# --------------------------------------------------------------------------- +@app.post("/api/login/qr/start", response_model=QrLoginStartResult) +async def login_qr_start() -> QrLoginStartResult: + """启动扫码登录:截取二维码区域并返回 data URL。 + + channels LoginAdapter 的扫码入口。调用本接口后,调用方应把 qr_data_url + 渲染给用户扫描,然后立即调 GET /api/login/qr/wait 轮询登录结果。 + + Returns: + QrLoginStartResult:含 qr_data_url(data:image/png;base64,...) / + message / connected=false + + Raises: + BridgeError(WINDOW_NOT_FOUND): 微信窗口未找到,无法截图(HTTP 503) + 其他异常由全局兜底处理器返回 BRIDGE_INTERNAL_ERROR + + Notes: + - 激活微信窗口后再截图,确保二维码可见(不被其他窗口遮挡) + - 二维码有时效(微信约 60s 刷新),调用方应在 qr/wait 超时后 + 重新调本接口获取新二维码 + - 若微信已登录,本接口仍会返回二维码截图(调用方应先调 + /api/status 判断 login_state,避免重复登录) + """ + xdotool = _require_xdotool() + qr_capture = _require_qr_capture() + + # 激活微信窗口(非阻塞,避免 VNC 无人操作时 --sync 死等) + await xdotool._activate_window_fast() + qr_data_url = await qr_capture.capture_qr_code() + + return QrLoginStartResult( + qr_data_url=qr_data_url, + message="请使用微信扫描二维码", + connected=False, + ) + + +# --------------------------------------------------------------------------- +# 路由:GET /api/login/qr/wait +# --------------------------------------------------------------------------- +@app.get("/api/login/qr/wait", response_model=QrLoginWaitResult) +async def login_qr_wait(timeout: int = 30) -> QrLoginWaitResult: + """轮询登录态,等待扫码登录完成。 + + 每 2 秒调一次 detect_login_state,直到 logged_in / not_running / 超时。 + 长轮询模式(响应在登录成功或超时后才返回),调用方无需在 client 端做 + 轮询间隔控制,直接发请求阻塞等待即可。 + + Args: + timeout: 最大等待秒数,默认 30,上限 120(防止长时间占用连接) + + Returns: + QrLoginWaitResult:含 connected / message / qr_data_url(成功时为空) + / credentials(成功时含 wxid + nickname,否则为 None) + + Raises: + BridgeError(INVALID_PARAMS): timeout < 1(HTTP 400) + + Notes: + - 微信进程未运行(not_running)时立即返回,不继续等待(继续等无意义) + - 登录成功时尝试从 DB 读 wxid/nickname;DB 不可达时返回空字符串 + (credentials 不为 None,但 wxid 为空) + - 超时返回 connected=false,message="等待扫码超时",调用方应重新调 + POST /api/login/qr/start 获取新二维码(旧二维码可能已过期) + """ + if timeout < 1: + raise BridgeError(code="INVALID_PARAMS", message="timeout 必须 >= 1") + if timeout > 120: + timeout = 120 + + xdotool = _require_xdotool() + db_reader = _require_db_reader() + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + state = await xdotool.detect_login_state() + if state == "logged_in": + # 登录成功:从 DB 读取 wxid / nickname + try: + self_info = await asyncio.to_thread(db_reader.get_self_info) + wxid = self_info.get("wxid", "") + nickname = self_info.get("nickname", "") + except Exception: + wxid = "" + nickname = "" + logger.info("login/qr/wait: → 登录成功 wxid=%s nickname=%s", wxid, nickname) + return QrLoginWaitResult( + connected=True, + message="登录成功", + qr_data_url="", + credentials={"wxid": wxid, "nickname": nickname}, + ) + if state == "not_running": + # 微信进程未运行,继续等无意义,直接返回 + logger.warning("login/qr/wait: → 微信进程未运行") + return QrLoginWaitResult( + connected=False, + message="微信进程未运行,无法扫码登录", + qr_data_url="", + credentials=None, + ) + # not_logged_in / logging_in:继续等待 + await asyncio.sleep(2) + + # 超时 + logger.warning("login/qr/wait: → 等待扫码超时(%ds)", timeout) + return QrLoginWaitResult( + connected=False, + message="等待扫码超时", + qr_data_url="", + credentials=None, + ) + + +# --------------------------------------------------------------------------- +# 路由:POST /api/login/logout +# --------------------------------------------------------------------------- +@app.post("/api/login/logout", response_model=LogoutResponse) +async def login_logout() -> LogoutResponse: + """退出微信登录(channels LifecycleAdapter 调用)。 + + 通过 UI 操作退出登录,避免直接 kill 进程导致登录态文件未刷盘。 + 幂等:未登录时直接返回成功(不抛错),方便调用方无需先查状态再调用。 + + 流程(仅 logged_in 时执行): + 1. detect_login_state 确认登录态 + 2. xdotool.logout() 执行 UI 操作:激活窗口 → 点击主菜单 → 方向键导航 + 到「退出登录」→ 回车 → 确认对话框(具体坐标/次数为估算值,需实测调优) + + Returns: + LogoutResponse:success=true / message("已退出登录" 或 "当前未登录,无需退出") + + Raises: + BridgeError(LOGOUT_FAILED): 窗口未找到或 UI 操作失败(HTTP 500) + + Notes: + - 幂等:not_running / not_logged_in 状态下都返回 success=true + - logout() 的 UI 坐标/方向键次数为估算值,spec 已记录需实测调优 + - 退出后微信进程仍在运行(只是登录态变 not_logged_in),autostart + 不会拉起新进程;如需重新登录走 /api/login/qr/start + - 不删除任何文件:登录态由微信自身管理,bridge 不破坏数据 + """ + xdotool = _require_xdotool() + + # 记录调用前的登录态(用于返回 message) + state_before = await xdotool.detect_login_state() + + # 若已登录,执行 UI 退出操作 + if state_before == LoginState.LOGGED_IN.value: + try: + await xdotool.logout() + except BridgeError: + raise + except Exception as e: + raise BridgeError( + code="LOGOUT_FAILED", + message=f"退出登录失败: {e}", + ) + return LogoutResponse(success=True, message="已退出登录") + + # 未登录,幂等返回 + return LogoutResponse(success=True, message="当前未登录,无需退出") + + +# --------------------------------------------------------------------------- +# 路由:POST /api/wechat/restart +# --------------------------------------------------------------------------- +@app.post("/api/wechat/restart", response_model=RestartResponse) +async def wechat_restart() -> RestartResponse: + """重启微信进程(不破坏登录态,数据卷保留)。 + + 流程: + 1. pgrep -x wechat 获取当前所有 PID + 2. 对每个 PID 发 SIGTERM(不强制 SIGKILL,给微信优雅退出机会,避免 DB 写入未刷盘) + 3. 轮询 pgrep -x wechat,等待新 PID 出现(autostart 2 秒后拉起) + 4. 30 秒内未出现新进程抛 RESTART_TIMEOUT + + 与 docker stop/start 的区别: + - 本接口只重启微信进程,不重启容器,X 会话/VNC 连接保持,速度更快(~5s) + - docker stop 会杀整个容器,VNC 断连,需重新连接 + + Returns: + RestartResponse:success=true / message("微信已重启" 或 "微信已启动") + / pid(新进程 PID) + + Raises: + BridgeError(RESTART_TIMEOUT): 30 秒内未检测到新进程(HTTP 408) + BridgeError(BRIDGE_INTERNAL_ERROR): pgrep 等命令异常(HTTP 500) + + Notes: + - 数据卷保留,登录态不丢(除非 SIGTERM 期间微信主动退出登录) + - 不提供 stop/start 接口:autostart watchdog 会立即拉起被 stop + 的微信进程,stop 没有意义;启动由 autostart 管理 + - 若 was_running=false(重启前未运行),message="微信已启动" + - 调用方应在收到 success=true 后调 /api/status 确认 login_state + 恢复到 logged_in(autostart 拉起后微信会自动尝试恢复登录) + """ + xdotool = _require_xdotool() + + # 记录调用前是否在运行 + was_running = await xdotool.is_wechat_running() + + try: + new_pid = await xdotool.restart_wechat(timeout_sec=30) + except BridgeError: + raise + except Exception as e: + # 非 timeout 的意外错误(如 pgrep 不可用)归为内部错误, + # 不误报为 RESTART_TIMEOUT(调用方会按 timeout 语义重试,无意义) + raise BridgeError( + code="BRIDGE_INTERNAL_ERROR", + message=f"重启微信失败: {e}", + ) + + message = "微信已重启" if was_running else "微信已启动" + return RestartResponse(success=True, message=message, pid=new_pid) + + +# --------------------------------------------------------------------------- +# 路由:GET /api/contacts +# --------------------------------------------------------------------------- +@app.get("/api/contacts", response_model=ContactsResponse) +async def get_contacts(keyword: str = "", limit: int = 50) -> ContactsResponse: + """联系人列表查询(channels SessionAdapter / WizardAdapter 使用)。 + + Args: + keyword: 模糊匹配 wxid / nickname / remark,空串返回全部 + limit: 1~200,默认 50 + + Returns: + ContactsResponse:含 contacts 列表 / total + + Raises: + BridgeError(INVALID_PARAMS): limit 越界(HTTP 400) + BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) + BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503) + + Notes: + - 查询走 cp 快照 + sqlite3 同步阻塞,asyncio.to_thread 包装 + - keyword 为空时仍走索引,不会全表扫描 + """ + if limit < 1 or limit > 200: + raise BridgeError( + code="INVALID_PARAMS", + message=f"limit 必须在 1~200 之间,收到 {limit}", + ) + + db_reader = _require_db_reader() + + # DB 加密时返回 DB_ENCRYPTED,而非空数据 + await _check_db_readable() + + result = await asyncio.to_thread(db_reader.get_contacts, keyword, limit) + contacts_list = result.get("contacts", []) + logger.info( + "contacts: keyword='%s' limit=%d → 返回 %d 条, total=%d", + keyword, limit, len(contacts_list), result.get("total", 0), + ) + return ContactsResponse( + contacts=[Contact(**c) for c in contacts_list], + total=result.get("total", 0), + ) + + +# --------------------------------------------------------------------------- +# 路由:GET /api/contacts/{wxid} +# --------------------------------------------------------------------------- +@app.get("/api/contacts/{wxid}", response_model=Contact) +async def get_contact_detail(wxid: str) -> Contact: + """单条联系人详情查询。 + + Args: + wxid: 路径参数,联系人 wxid(如 wxid_xxx 或 gh_xxx 公众号) + + Returns: + Contact:单条联系人记录 + + Raises: + BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) + BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503) + BridgeError(CONTACT_NOT_FOUND): 指定 wxid 不存在(HTTP 404) + + Notes: + - 不存在的 wxid 返回 CONTACT_NOT_FOUND(404),与 DB 不可达错误区分 + - 群聊 username(xxx@chatroom)也可用本接口查(DbReader 视为联系人) + """ + db_reader = _require_db_reader() + + # DB 加密时返回 DB_ENCRYPTED,而非空数据 + await _check_db_readable() + + result = await asyncio.to_thread(db_reader.get_contact_detail, wxid) + if result is None: + raise BridgeError( + code="CONTACT_NOT_FOUND", + message=f"未找到联系人: {wxid}", + ) + return Contact(**result) + + +# --------------------------------------------------------------------------- +# 路由:GET /api/groups +# --------------------------------------------------------------------------- +@app.get("/api/groups", response_model=GroupsResponse) +async def get_groups(limit: int = 50) -> GroupsResponse: + """群聊列表查询。 + + Args: + limit: 1~200,默认 50 + + Returns: + GroupsResponse:含 groups 列表(每项为 Contact 结构)/ total + + Raises: + BridgeError(INVALID_PARAMS): limit 越界(HTTP 400) + BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) + BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503) + + Notes: + - 群聊判定条件:username LIKE '%@chatroom' + - 返回结构复用 Contact(群也存于 contact 表,字段相同) + """ + if limit < 1 or limit > 200: + raise BridgeError( + code="INVALID_PARAMS", + message=f"limit 必须在 1~200 之间,收到 {limit}", + ) + + db_reader = _require_db_reader() + + # DB 加密时返回 DB_ENCRYPTED,而非空数据 + await _check_db_readable() + + result = await asyncio.to_thread(db_reader.get_groups, limit) + groups_list = result.get("groups", []) + logger.info( + "groups: limit=%d → 返回 %d 个群, total=%d", + limit, len(groups_list), result.get("total", 0), + ) + return GroupsResponse( + groups=[Contact(**g) for g in groups_list], + total=result.get("total", 0), + ) + + +# --------------------------------------------------------------------------- +# 路由:GET /api/groups/{wxid}/members +# --------------------------------------------------------------------------- +@app.get("/api/groups/{wxid}/members", response_model=GroupMembersResponse) +async def get_group_members(wxid: str) -> GroupMembersResponse: + """群成员列表查询。 + + Args: + wxid: 路径参数,群聊 wxid(形如 xxxxx@chatroom) + + Returns: + GroupMembersResponse:含 members 列表 / total / group_wxid + + Raises: + BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) + BridgeError(DB_ENCRYPTED): DB 已加密(HTTP 503) + + Notes: + - 群成员信息存于 group_members 表,DbReader 联表查询 + - 若群 wxid 不存在或无成员记录,返回空列表而非错误 + """ + db_reader = _require_db_reader() + + # DB 加密时返回 DB_ENCRYPTED,而非空数据 + await _check_db_readable() + + result = await asyncio.to_thread(db_reader.get_group_members, wxid) + return GroupMembersResponse(**result) + + +# --------------------------------------------------------------------------- +# 路由:POST /api/send/image +# --------------------------------------------------------------------------- +@app.post("/api/send/image", response_model=SendResponse) +async def send_image(req: SendFileRequest) -> SendResponse: + """发送图片消息(MVP 简化版)。 + + 当前 MVP 实现仅校验文件存在 + 进入会话 + 发送"[图片] 文件名"提示文本, + 真实图片传输(拖拽 / Ctrl+V 粘贴文件路径)留给后续完善(W-02)。 + 响应中 placeholder=true 标识当前为占位实现。 + + Args: + req: SendFileRequest(to_wxid + file_path + 可选 display_name) + + Returns: + SendResponse:success=true / local_send_id / placeholder=true + + Raises: + 同 _send_file:INVALID_PARAMS / WECHAT_NOT_LOGGED_IN / SEND_FAILED + """ + return await _send_file(req, is_image=True) + + +# --------------------------------------------------------------------------- +# 路由:POST /api/send/file +# --------------------------------------------------------------------------- +@app.post("/api/send/file", response_model=SendResponse) +async def send_file(req: SendFileRequest) -> SendResponse: + """发送文件消息(MVP 简化版)。 + + 当前 MVP 实现仅校验文件存在 + 进入会话 + 发送"[文件] 文件名"提示文本, + 真实文件传输(拖拽 / 文件选择器)留给后续完善(W-02)。 + 响应中 placeholder=true 标识当前为占位实现。 + + Args: + req: SendFileRequest(to_wxid + file_path + 可选 display_name) + + Returns: + SendResponse:success=true / local_send_id / placeholder=true + + Raises: + 同 _send_file:INVALID_PARAMS / WECHAT_NOT_LOGGED_IN / SEND_FAILED + """ + return await _send_file(req, is_image=False) + + +async def _send_file(req: SendFileRequest, is_image: bool) -> SendResponse: + """send_image / send_file 共用实现。 + + 流程: + 1. 校验 to_wxid / file_path 非空,文件存在 + 2. 解析 display_name:显式传入 > 备注 > 昵称 > wxid + 3. 检测登录态,非 logged_in 直接拒绝 + 4. 经 send_queue 串行执行:activate → Ctrl+F 搜索会话(按 display_name) → + 选中并进入 → xclip 粘贴"[图片/文件] 文件名" → 回车发送 + + Args: + req: SendFileRequest(to_wxid + file_path + 可选 display_name) + is_image: True=图片,False=普通文件(仅影响提示文本前缀) + + Returns: + SendResponse:success=true / local_send_id / placeholder=true + (W-02 完成前为占位实现,仅发提示文本;完成后改为 placeholder=false) + + Raises: + BridgeError(INVALID_PARAMS): to_wxid/file_path 为空或文件不存在(HTTP 400) + BridgeError(WECHAT_NOT_LOGGED_IN): 当前未登录(HTTP 401) + BridgeError(SEND_FAILED): xdotool/xclip 操作失败(HTTP 500) + + Notes: + - file_path 必须是容器内绝对路径(不是宿主机路径) + - MVP 阶段不传输真实文件内容,仅发提示文本;后续完善时需考虑 + 拖拽到聊天窗口 / Ctrl+V 粘贴文件 / 文件选择器对话框等方案 + - local_send_id 不对应微信原生 msg_id,禁止用于 /api/media/{msg_id} + """ + xdotool = _require_xdotool() + send_queue = _require_send_queue() + + if not req.to_wxid: + raise BridgeError(code="INVALID_PARAMS", message="to_wxid 不能为空") + if not req.file_path: + raise BridgeError(code="INVALID_PARAMS", message="file_path 不能为空") + if not os.path.isfile(req.file_path) or not os.access(req.file_path, os.R_OK): + raise BridgeError( + code="INVALID_PARAMS", + message=f"文件不存在或不可读: {req.file_path}", + ) + + # 检查登录态 + login_state = await xdotool.detect_login_state() + if login_state != "logged_in": + raise BridgeError( + code="WECHAT_NOT_LOGGED_IN", + message=f"当前登录态为 {login_state},无法发送消息", + ) + + # 解析显示名(优先备注/昵称,不再直接用 wxid 搜索) + display_name = await _resolve_display_name( + _state.db_reader, req.to_wxid, req.display_name + ) + + try: + local_send_id = await send_queue.enqueue( + lambda: xdotool.send_file( + req.to_wxid, req.file_path, is_image=is_image, display_name=display_name + ) + ) + except BridgeError: + raise + except Exception as e: + raise BridgeError( + code="SEND_FAILED", + message=f"发送失败: {e}", + ) + + kind = "image" if is_image else "file" + logger.info( + "send/%s: to=%s display_name=%s file=%s → 成功 local_send_id=%s (placeholder)", + kind, req.to_wxid, display_name, req.file_path, local_send_id, + ) + # W-02 完成前,图片/文件发送为占位实现(仅发提示文本) + return SendResponse(success=True, local_send_id=local_send_id, placeholder=True, error=None) + + +# --------------------------------------------------------------------------- +# 路由:GET /api/media/avatar/{wxid} +# --------------------------------------------------------------------------- +# 注意:此路由必须在 /api/media/{msg_id} 之前声明,否则会被 {msg_id} +# 匹配到(FastAPI 按声明顺序匹配,先匹配先得)。 +@app.get("/api/media/avatar/{wxid}") +async def get_avatar(wxid: str) -> Response: + """获取联系人头像。 + + 最小实现:优先从 contact.db 读取 big_head_url / small_head_url / avatar_url, + 若存在 HTTP(S) 链接则拉取并透传二进制;否则返回 MEDIA_NOT_FOUND。 + 本地 .dat 头像缓存解密仍留给后续 W-04 完善。 + + Args: + wxid: 路径参数,联系人 wxid + + Returns: + Response:头像二进制内容 + Content-Type + + Raises: + BridgeError(MEDIA_NOT_FOUND): 未找到联系人或无有效头像 URL(HTTP 404) + BridgeError(DB_ENCRYPTED): DB 已加密,无法读取联系人表(HTTP 503) + + Notes: + - 路由声明顺序敏感:必须在 /api/media/{msg_id} 之前,否则 + FastAPI 会把 "avatar" 当作 msg_id 匹配 + """ + db_reader = _require_db_reader() + + # DB 不可读时统一返回 503,行为与 /api/media/{msg_id} 一致 + await _check_db_readable() + + contact = await asyncio.to_thread(db_reader.get_contact_detail, wxid) + url: Optional[str] = None + if contact: + url = ( + contact.get("big_head_url") + or contact.get("small_head_url") + or contact.get("avatar_url") + ) + + if not url or not url.startswith(("http://", "https://")): + raise BridgeError( + code="MEDIA_NOT_FOUND", + message=f"未找到联系人头像 URL: {wxid}", + ) + + try: + data = await asyncio.to_thread(_fetch_avatar_url, url) + except Exception as e: + logger.warning("avatar: wxid=%s url=%s 拉取失败: %s", wxid, url, e) + raise BridgeError( + code="MEDIA_NOT_FOUND", + message=f"头像下载失败: {wxid}", + ) + + mime = mimetypes.guess_type(url)[0] or "image/jpeg" + logger.info("avatar: wxid=%s url=%s → %d bytes (%s)", wxid, url, len(data), mime) + return Response(content=data, media_type=mime) + + +def _fetch_avatar_url(url: str) -> bytes: + """同步拉取头像 URL 二进制内容。""" + with urllib.request.urlopen(url, timeout=15) as resp: + return resp.read() + + +# --------------------------------------------------------------------------- +# 路由:GET /api/media/{msg_id} +# --------------------------------------------------------------------------- +@app.get("/api/media/{msg_id}") +async def get_media(msg_id: str) -> Response: + """下载消息媒体文件(图片/语音/视频/文件)。 + + Args: + msg_id: 路径参数,消息 ID(来自 /api/messages/since 返回的 msg_id) + + Returns: + Response:二进制内容 + Content-Type(image/jpeg / audio/amr / + video/mp4 / application/octet-stream 等) + + Raises: + BridgeError(MEDIA_NOT_FOUND): 媒体文件未找到或解析失败(HTTP 404) + BridgeError(DB_ENCRYPTED): DB 已加密,无法查 media 表(HTTP 503) + BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) + + Notes: + - 前置调 _check_db_readable(),DB 加密时直接返回 503 DB_ENCRYPTED, + 不再走 sqlite3.DatabaseError 被兜底为 500 的旧路径 + - DbReader.get_media_bytes 内部:DB 查 msg_id → 找对应 .dat 文件 + → XOR 解密 → 返回 (data, mime) + - 大文件可能阻塞较久,建议调用方设置合理的 timeout + """ + db_reader = _require_db_reader() + + # 前置 DB 可达性检查:加密时直接返回 503,避免 sqlite3 异常被兜底 500 + await _check_db_readable() + + result = await asyncio.to_thread(db_reader.get_media_bytes, msg_id) + if result is None: + logger.info("media: msg_id=%s → 未找到", msg_id) + raise BridgeError( + code="MEDIA_NOT_FOUND", + message=f"未找到媒体文件: {msg_id}", + ) + data, mime = result + logger.info("media: msg_id=%s → %d bytes (%s)", msg_id, len(data), mime) + return Response(content=data, media_type=mime) + + +# --------------------------------------------------------------------------- +# 路由:GET /api/diagnostic/connectivity +# --------------------------------------------------------------------------- +@app.get("/api/diagnostic/connectivity", response_model=ConnectivityResponse) +async def diagnostic_connectivity() -> ConnectivityResponse: + """bridge 连通性检查(channels ProbeableAdapter 调用)。 + + MVP 阶段恒返回 reachable=true / latency_ms=0 / error=null。 + 真实实现可加:内部 health ping / 依赖项检查 / 平均响应时延。 + + Returns: + ConnectivityResponse:reachable / latency_ms / error + """ + return ConnectivityResponse(reachable=True, latency_ms=0, error=None) + + +# --------------------------------------------------------------------------- +# 路由:GET /api/diagnostic/items +# --------------------------------------------------------------------------- +@app.get("/api/diagnostic/items") +async def diagnostic_items() -> dict: + """返回诊断项定义列表。 + + 前端「诊断」页面用本接口渲染可执行的检查项卡片,每张卡片含: + - check_id(POST /api/diagnostic/run/{check_id} 的路径参数) + - name(卡片标题)/ description(卡片副标题) + - severity(critical/error/warn/info,决定卡片颜色与是否阻断) + - auto_repairable(true 时显示「自动修复」按钮,调 autofix 接口) + + Returns: + {"items": [DiagnosticItem, ...]}(共 4 项:wechat_running / + login_state / db_accessible / xdotool_available) + """ + return { + "items": [DiagnosticItem(**item) for item in DIAGNOSTIC_ITEMS], + } + + +# --------------------------------------------------------------------------- +# 路由:POST /api/diagnostic/run/{check_id} +# --------------------------------------------------------------------------- +@app.post("/api/diagnostic/run/{check_id}", response_model=DiagnosticRunResult) +async def diagnostic_run(check_id: str) -> DiagnosticRunResult: + """执行单个诊断项检查(channels DoctorAdapter 调用)。 + + 支持的 check_id: + - wechat_running:pgrep -x wechat 检测进程,auto_repairable=true + - login_state:detect_login_state(),区分 not_running/not_logged_in/logged_in + - db_accessible:check_db_status() 区分 ok/not_found/encrypted/unreadable, + encrypted 时返回 repair_plan 提示独立专项解决 + - xdotool_available:shutil.which('xdotool') 检测可执行文件 + + Args: + check_id: 路径参数,诊断项 ID(见 /api/diagnostic/items 返回) + + Returns: + DiagnosticRunResult:含 passed / severity / message / auto_repairable + / repair_plan(仅 db_accessible=encrypted/unreadable 时非空) + + Raises: + BridgeError(INVALID_PARAMS): 未知 check_id(HTTP 400) + + Notes: + - 不抛业务错误的诊断项:即使 passed=false 也返回 200 + 详细 message, + 让调用方按字段判定后续动作(如显示修复建议、调 autofix) + - repair_plan 字段仅在 db_accessible 失败时给出建议,其他项为 None + """ + item = _DIAGNOSTIC_ITEM_BY_ID.get(check_id) + if item is None: + raise BridgeError( + code="INVALID_PARAMS", + message=f"未知诊断项: {check_id}", + ) + + xdotool = _require_xdotool() + db_reader = _require_db_reader() + + if check_id == "wechat_running": + passed = await xdotool.is_wechat_running() + message = "微信进程运行中" if passed else "微信进程未运行" + elif check_id == "login_state": + state = await xdotool.detect_login_state() + passed = state == "logged_in" + message = f"登录态: {state}" + elif check_id == "db_accessible": + # 统一走 _resolve_db_state(含 salt/key/pid/验证逻辑) + db_state = await _resolve_db_state() + if db_state.db_accessible: + source = _state.key_cache.source or "unknown" + msg = "微信 DB 可读" if db_state.status == "ok" \ + else f"DB 加密但已具备解密能力(key 来源: {source})" + return DiagnosticRunResult( + check_id=check_id, + passed=True, + severity=item["severity"], + message=msg, + auto_repairable=item["auto_repairable"], + repair_plan=None, + ) + if db_state.status == "not_found": + return DiagnosticRunResult( + check_id=check_id, + passed=False, + severity=item["severity"], + message="未在 /config 下找到微信消息 DB", + auto_repairable=item["auto_repairable"], + repair_plan=None, + ) + if db_state.status == "unreadable": + return DiagnosticRunResult( + check_id=check_id, + passed=False, + severity=item["severity"], + message="DB 文件存在但不可读(权限问题)", + auto_repairable=item["auto_repairable"], + repair_plan="检查 /config/xwechat_files 下 DB 文件权限", + ) + # need_init / key_invalid 且无有效 key + return DiagnosticRunResult( + check_id=check_id, + passed=False, + severity=item["severity"], + message="DB 已加密(SQLCipher),需密钥才能读取", + auto_repairable=item["auto_repairable"], + repair_plan="通过 POST /api/db/decrypt 注入 64 位 hex 密钥,或设置 WOC_DB_KEY 环境变量,或启用 WOC_DB_KEY_AUTO_EXTRACT=true 自动提取", + ) + elif check_id == "xdotool_available": + path = shutil.which("xdotool") + passed = path is not None + message = f"xdotool 路径: {path}" if passed else "xdotool 不可用" + else: # 理论不可达 + raise BridgeError( + code="INVALID_PARAMS", + message=f"未知诊断项: {check_id}", + ) + + return DiagnosticRunResult( + check_id=check_id, + passed=passed, + severity=item["severity"], + message=message, + auto_repairable=item["auto_repairable"], + repair_plan=None, + ) + + +# --------------------------------------------------------------------------- +# 路由:POST /api/diagnostic/autofix/{check_id} +# --------------------------------------------------------------------------- +@app.post("/api/diagnostic/autofix/{check_id}", response_model=DiagnosticRunResult) +async def diagnostic_autofix(check_id: str) -> DiagnosticRunResult: + """尝试自动修复诊断项(channels DoctorAdapter 调用)。 + + 仅 auto_repairable=true 的项可调,否则抛 INVALID_PARAMS。 + 当前可修复项: + - wechat_running:pkill -x wechat 杀进程 → 等 2s 检查 autostart 是否拉起 + → 未拉起则 bridge 显式启动(start_wechat)作为兜底 + - xdotool_available:不可修复,返回 passed=false + "请重建容器" + (xdotool 缺失说明容器构建异常,bridge 内无法修复,只能重建) + - db_accessible:加密 DB 时尝试自动提取 key(P1 能力),失败返回 repair_plan + + Args: + check_id: 路径参数,诊断项 ID + + Returns: + DiagnosticRunResult:含 passed / message(描述修复结果或失败原因) + + Raises: + BridgeError(INVALID_PARAMS): 未知 check_id 或该项不可自动修复(HTTP 400) + + Notes: + - wechat_running 修复流程: + 1. pkill 杀旧进程 + 2. 等 2s 让 autostart watchdog 拉起 + 3. 若 autostart 已拉起 → 返回 passed=true + 新 PID + 4. 若 autostart 未拉起 → bridge 显式启动(start_wechat) + 5. 显式启动成功 → passed=true + 新 PID + 6. 显式启动失败 → passed=false + repair_plan 提示 + - 调用方收到 passed=true 后应间隔 3-5s 调 /api/diagnostic/run/wechat_running + 确认实际状态(进程可能在初始化中,窗口尚未出现) + """ + item = _DIAGNOSTIC_ITEM_BY_ID.get(check_id) + if item is None: + raise BridgeError( + code="INVALID_PARAMS", + message=f"未知诊断项: {check_id}", + ) + if not item["auto_repairable"]: + raise BridgeError( + code="INVALID_PARAMS", + message="该项不可自动修复", + ) + + if check_id == "wechat_running": + xdotool = _require_xdotool() + + # 1. pkill 旧进程(不强制 SIGKILL,给微信优雅退出机会) + try: + proc = await asyncio.create_subprocess_exec( + "pkill", "-x", "wechat", + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + except Exception: + pass + + # 2. 等 2s 让 autostart watchdog 拉起 + await asyncio.sleep(2) + + # 3. 检查 autostart 是否已拉起 + new_pid = await xdotool.check_wechat_pid() + + # 4. autostart 未拉起,bridge 显式启动兜底 + if new_pid is None: + new_pid = await xdotool.start_wechat(timeout_sec=10) + + # 5. 返回结果 + if new_pid is not None: + return DiagnosticRunResult( + check_id=check_id, + passed=True, + severity=item["severity"], + message=f"微信进程已恢复,PID={new_pid}", + auto_repairable=True, + repair_plan=None, + ) + return DiagnosticRunResult( + check_id=check_id, + passed=False, + severity=item["severity"], + message="autostart 未拉起且 bridge 显式启动失败", + auto_repairable=True, + repair_plan="检查 X 会话是否正常(Xvnc/openbox),或通过 VNC 桌面手动启动微信", + ) + elif check_id == "db_accessible": + db_reader = _require_db_reader() + # 检查 DB 状态 + status = await asyncio.to_thread(db_reader.check_db_status) + if status == "ok": + return DiagnosticRunResult( + check_id=check_id, + passed=True, + severity=item["severity"], + message="DB 可读,无需修复", + auto_repairable=item["auto_repairable"], + repair_plan=None, + ) + if status in ("need_init", "key_invalid"): + # 尝试自动提取 key(阻塞等待结果,复用公共加锁路径) + extracted = await _auto_extract_with_lock() + if extracted: + return DiagnosticRunResult( + check_id=check_id, + passed=True, + severity=item["severity"], + message="自动提取 DB 密钥成功,DB 已可解密读取", + auto_repairable=item["auto_repairable"], + repair_plan=None, + ) + else: + return DiagnosticRunResult( + check_id=check_id, + passed=False, + severity=item["severity"], + message="自动提取 DB 密钥失败", + auto_repairable=item["auto_repairable"], + repair_plan="通过 POST /api/db/decrypt 手动注入 64 位 hex 密钥,或设置 WOC_DB_KEY 环境变量", + ) + # not_found / unreadable 不可自动修复 + return DiagnosticRunResult( + check_id=check_id, + passed=False, + severity=item["severity"], + message=f"DB 状态: {status},无法自动修复", + auto_repairable=item["auto_repairable"], + repair_plan="检查 /config/xwechat_files 下 DB 文件是否存在且权限正确", + ) + elif check_id == "xdotool_available": + return DiagnosticRunResult( + check_id=check_id, + passed=False, + severity=item["severity"], + message="xdotool 不可用,请重建容器", + auto_repairable=True, + repair_plan=None, + ) + + # 理论不可达 + raise BridgeError( + code="INVALID_PARAMS", + message=f"未知诊断项: {check_id}", + ) + + +# --------------------------------------------------------------------------- +# 路由:POST /api/screenshot +# --------------------------------------------------------------------------- +@app.post("/api/screenshot") +async def screenshot() -> Response: + """截取完整屏幕并返回 PNG。 + + 用于前端「截图」按钮 / 调试时观察微信实际界面状态(如确认 UI 操作是否 + 触发了正确的对话框)。截取整个 X 桌面(DISPLAY 由 _state.config.display 指定)。 + + Returns: + Response:Content-Type=image/png,body 为 PNG 二进制 + + Raises: + BridgeError(WINDOW_NOT_FOUND): X 会话未就绪,无法截图(HTTP 503) + 其他异常由全局兜底处理器返回 BRIDGE_INTERNAL_ERROR + + Notes: + - 截图由 qr_capture.capture_full_screenshot 完成,底层调 + xdotool/getwindowgeometry + import(ImageMagick) + - 与 /api/login/qr/start 区别:本接口截全屏,qr/start 截二维码区域 + - 大屏幕截图可能 > 1MB,调用方注意带宽 + """ + qr_capture = _require_qr_capture() + + png_bytes = await qr_capture.capture_full_screenshot() + logger.info("screenshot: → 截图成功 %d bytes", len(png_bytes)) + return Response(content=png_bytes, media_type="image/png") + + +# --------------------------------------------------------------------------- +# 命令行入口 +# --------------------------------------------------------------------------- +def _parse_args() -> argparse.Namespace: + """解析命令行参数。""" + parser = argparse.ArgumentParser(description="woc-bridge FastAPI 服务") + parser.add_argument( + "--listen", + default="0.0.0.0:8088", + help="监听地址,格式 host:port,默认 0.0.0.0:8088", + ) + parser.add_argument( + "--wechat-db", + default="/config", + help="微信数据根目录,默认 /config(_find_db_path 会递归查找 message_0.db)", + ) + parser.add_argument( + "--display", + default=":1", + help="X server display,默认 :1", + ) + return parser.parse_args() + + +def _init_state(cfg: BridgeConfig) -> None: + """根据 BridgeConfig 初始化全局状态(运行期实例)。""" + _state.config = cfg + _state.xdotool = XdotoolDriver(display=cfg.display) + # DbReader 共享 AppState 中的 key_cache(单一密钥来源) + _state.db_reader = DbReader(db_root=cfg.wechat_db, key_cache=_state.key_cache) + _state.qr_capture = QrCapture(display=cfg.display) + + _state.send_queue = SendQueue( + send_delay_ms=cfg.send_delay_ms, + max_calls_per_sec=cfg.max_calls_per_sec, + ) + + # 读取 WOC_DB_KEY 环境变量,注入 DbReader + # 用 set_default_key 而非 set_key:保留 woc-keys.json 已持久化的多 salt 映射, + # env key 仅作为默认兜底,首次查询时按 salt 验证后入库 + # db_reader.set_default_key 已转发到 key_cache.set_default_key,无需重复调用 + if cfg.db_key: + _state.db_reader.set_default_key(cfg.db_key) + logger.info("从 WOC_DB_KEY 环境变量加载默认 DB 密钥(未验证,首次查询时验证)") + + +if __name__ == "__main__": + args = _parse_args() + cfg = BridgeConfig.from_args_and_env(args) + _init_state(cfg) + host, _, port_str = cfg.listen.rpartition(":") + if not host: + host = "0.0.0.0" + port = int(port_str) if port_str else 8088 + uvicorn.run(app, host=host, port=port) diff --git a/bridge/tools/extract_key_standalone.py b/bridge/tools/extract_key_standalone.py new file mode 100644 index 0000000..a2c8077 --- /dev/null +++ b/bridge/tools/extract_key_standalone.py @@ -0,0 +1,83 @@ +"""独立的 Linux 微信进程内存 key 提取工具(调试用)。 + +用法(在容器内执行): + python3 /opt/woc-bridge/tools/extract_key_standalone.py + +设计目标: +- 与 woc-bridge 主服务隔离,扫描失败不影响 bridge +- 自动检测所有微信进程并按内存大小排序扫描 +- 匹配 x'' 模式提取 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()) diff --git a/bridge/tools/salt_probe.py b/bridge/tools/salt_probe.py new file mode 100644 index 0000000..a602667 --- /dev/null +++ b/bridge/tools/salt_probe.py @@ -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(" 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()) diff --git a/bridge/xdotool_driver.py b/bridge/xdotool_driver.py new file mode 100644 index 0000000..ceec082 --- /dev/null +++ b/bridge/xdotool_driver.py @@ -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 。""" + 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__<随机> + """ + # 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__<随机> + + 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) diff --git a/docker-compose.yml b/docker-compose.yml index e4f1924..746cc53 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 直连。 + - WOC_BRIDGE_API_TOKEN=${WOC_BRIDGE_API_TOKEN:-} volumes: # 面板账号数据(用户、实例元信息、密码哈希) diff --git a/docker/Dockerfile b/docker/Dockerfile index 224e16c..4f882a5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/woc-ptrace-init.sh b/docker/woc-ptrace-init.sh new file mode 100644 index 0000000..fe857be --- /dev/null +++ b/docker/woc-ptrace-init.sh @@ -0,0 +1,5 @@ +#!/usr/bin/with-contenv bash +# 设置 ptrace_scope=0,允许 bridge 进程读取微信进程内存 +# 用于 SQLCipher 密钥自动提取(key_extractor.py 读 /proc//mem) +echo 0 > /proc/sys/kernel/yama/ptrace_scope 2>/dev/null || \ + echo "woc-ptrace: 无法设置 ptrace_scope(可能需要 --privileged 或 SYS_PTRACE capability)" >&2 diff --git a/panel/server/src/docker.ts b/panel/server/src/docker.ts index 0395b96..c8c17a1 100644 --- a/panel/server/src/docker.ts +++ b/panel/server/src/docker.ts @@ -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 { 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 { // 反代靠容器名 name 寻址,与此 hostname 无关。 Hostname: realisticHostname(inst.id), Env: envList(inst), - ExposedPorts: { '3000/tcp': {} }, + ExposedPorts: { + '3000/tcp': {}, + '8088/tcp': {}, + }, HostConfig: hostConfig, }; // 自定义网络时,MAC 须写到对应 endpoint 上(新版 docker 弃用顶层 MacAddress);默认网络则用顶层。 diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index 9ee6512..1c5f47c 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -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-:8088/* +// 双鉴权: +// 1. M2M 调用:Authorization: Bearer (供外部系统如 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) => { diff --git a/panel/server/src/index.ts.bak b/panel/server/src/index.ts.bak new file mode 100644 index 0000000..d12d852 --- /dev/null +++ b/panel/server/src/index.ts.bak @@ -0,0 +1,1504 @@ +import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; +import cookie from '@fastify/cookie'; +import fstatic from '@fastify/static'; +import httpProxy from 'http-proxy'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import type { IncomingMessage } from 'node:http'; +import type { Socket } from 'node:net'; +import { + initStore, + findByUsername, + findById, + verifyPassword, + publicUser, + listUsers, + createSub, + setDisabled, + resetPassword, + renameUser, + deleteUser, + setUserInstances, + listInstances, + findInstance, + setInstanceMemLimits, + userInstances, + userCanAccess, + createInstance, + removeInstance as removeInstanceRecord, + renameInstance, + setInstanceIcon, + setInstanceUsers, + publicInstance, + getDesktopDark, + setDesktopDark, + APP_TYPES, + type AppType, + type User, + type Instance, +} from './store.js'; +import { + ensureNetwork, + ensureRunning, + runInstance, + stopInstance, + upgradeInstance, + removeInstance as removeInstanceContainer, + instanceRuntime, + triggerWechat, + wechatStatus, + instanceTarget, + uploadToInstance, + listInstanceFiles, + downloadFromInstance, + deleteInstanceFile, + instanceLogs, + buildDiagnostics, + typeInInstance, + keyInInstance, + listOrphanVolumes, + removeVolume, + listOrphanContainers, + removeContainerById, + instanceMemoryMB, + instanceHttpHealthy, + regenInstanceMachineId, + listVolume, + volMkdir, + volMove, + volDelete, + volUploadFile, + volExtractArchive, + volDownloadFile, + volBackupStream, + volRestoreArchive, + listBackgrounds, + uploadBackground, + applyBackground, + deleteBackground, + getBackgroundImage, + getCurrentBackground, + clearBackground, + listFonts, + uploadFont, + deleteFont, + applyFont, + getAppliedFont, + getFontFamily, +} from './docker.js'; +import { createSession, getSession, destroySession, destroyUserSessions, SESSION_TTL_MS } from './sessions.js'; +import { parseHost, parseAllowedHosts, isRequestHostAllowed } from './host-guard.js'; +import { CURRENT_VERSION, versionInfo, ensureChecked, checkForUpdate, startUpdateChecker } from './version.js'; +import { triggerSelfUpdate } from './self-update.js'; +import { appendInstanceLog, readInstanceLog, appendPanelLog, readPanelLog, pruneOldLogs, filterSince, rangeToMs, DIAG_RANGES } from './logs.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const PORT = Number(process.env.PORT || 8080); +const HOST = process.env.HOST || '0.0.0.0'; +const STATIC_DIR = process.env.STATIC_DIR || join(__dirname, '../../web/dist'); +const COOKIE = 'woc_sess'; +// Public hostnames the panel will accept Host headers for, in addition to the +// always-on loopback + RFC1918 LAN allowlist. Required for HTTPS reverse-proxy +// deploys (Caddy/nginx/飞牛 内置反代) where the public hostname differs from +// the LAN IP. See .env.example. +const ALLOWED_HOSTS = parseAllowedHosts(process.env.PANEL_ALLOWED_HOSTS); + +function basicAuth(inst: Instance) { + return 'Basic ' + Buffer.from(`${inst.kasmUser}:${inst.kasmPassword}`).toString('base64'); +} + +initStore(); + +const app = Fastify({ logger: true, trustProxy: true }); + +// DNS-rebinding gate: reject requests whose Host header is neither a loopback / +// RFC1918 LAN address nor in PANEL_ALLOWED_HOSTS. Runs before every route so +// /api/*, /desktop/* and static-file responses are all covered. +app.addHook('onRequest', async (req, reply) => { + if (!isRequestHostAllowed(req.headers.host, req.headers['x-forwarded-host'], ALLOWED_HOSTS)) { + // 把被拒的 Host / X-Forwarded-Host 一起回显,反代调试时可一眼看出"后端实际收到的是什么" + // —— 决定是去白名单加这个 host,还是修反代让它透传 Host。不泄露敏感信息。 + reply.code(400).send({ + error: 'Host header not allowed', + host: parseHost(req.headers.host) || null, + forwardedHost: req.headers['x-forwarded-host'] || null, + hint: '反代部署请把对外域名加入 PANEL_ALLOWED_HOSTS(.env 逗号分隔,支持 *.example.com),改完用 docker compose up -d 重建容器(不是 restart)使其生效', + }); + } +}); + +await app.register(cookie); +// 文件上传走原始二进制(前端以 application/octet-stream 直传 File) +app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => done(null, body)); +// Heartbeat and other no-body POST routes send no Content-Type; fall through to this wildcard +// instead of being rejected with 415. Fastify's exact-match parsers above take priority. +app.addContentTypeParser('*', { parseAs: 'buffer' }, (_req, _body, done) => done(null, null)); + +// ---------- 鉴权辅助 ---------- +function currentUser(req: FastifyRequest): User | null { + const token = req.cookies?.[COOKIE]; + const s = getSession(token); + if (!s) return null; + const u = findById(s.userId); + if (!u || u.disabled) return null; + return u; +} + +function requireAuth(req: FastifyRequest, reply: FastifyReply): User | null { + const u = currentUser(req); + if (!u) { + reply.code(401).send({ error: '未登录' }); + return null; + } + return u; +} + +function requireAdmin(req: FastifyRequest, reply: FastifyReply): User | null { + const u = requireAuth(req, reply); + if (!u) return null; + if (u.role !== 'admin') { + reply.code(403).send({ error: '需要管理员权限' }); + return null; + } + return u; +} + +// ---------- 登录 / 会话 ---------- +app.post('/api/auth/login', async (req, reply) => { + const { username, password } = (req.body as any) ?? {}; + const u = username ? findByUsername(username) : undefined; + if (!u || u.disabled || !verifyPassword(u, password ?? '')) { + return reply.code(401).send({ error: '用户名或密码错误' }); + } + const token = createSession(u.id); + reply.setCookie(COOKIE, token, { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: Math.floor(SESSION_TTL_MS / 1000), // 与服务端会话时长一致(WOC_SESSION_DAYS,默认 30 天) + }); + return { user: publicUser(u) }; +}); + +app.post('/api/auth/logout', async (req, reply) => { + destroySession(req.cookies?.[COOKIE]); + reply.clearCookie(COOKIE, { path: '/' }); + return { ok: true }; +}); + +app.get('/api/auth/me', async (req, reply) => { + const u = currentUser(req); + if (!u) return reply.code(401).send({ error: '未登录' }); + return { user: publicUser(u) }; +}); + +// ---------- 版本与更新检测 ---------- +// 当前构建版本 + 缓存的「最新版」检测结果(后台每 6h 查一次 Docker Hub/GHCR)。任何登录用户可读。 +app.get('/api/version', async (req, reply) => { + if (!requireAuth(req, reply)) return; + ensureChecked(); // 刚启动还没首检时,触发一次后台检查(不阻塞本次响应) + return versionInfo(); +}); +// 立即重新检查(管理员,用于「检查更新」按钮)。 +app.post('/api/admin/version/check', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + return await checkForUpdate(); +}); + +// 一键更新面板自身(管理员):拉新镜像 → 派生 helper 容器重建 woc-panel(带健康检查 + 失败回滚)。 +// 返回后面板会在十几秒内被 helper 重启,前端提示用户稍候刷新。 +app.post('/api/admin/version/self-update', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + try { + const { target } = await triggerSelfUpdate(); + return { ok: true, target, message: '已开始更新:面板将在十几秒内重启为新版本,请稍候刷新页面' }; + } catch (e: any) { + appendPanelLog('ERROR', `面板自更新失败:${e?.message || e}`); + return reply.code(500).send({ error: '更新失败:' + (e?.message || e) }); + } +}); + +// ---------- 实例桌面深色(与面板主题统一的那个开关)---------- +// 读取当前实例深色状态(任何登录用户可读,用于前端同步主题开关与实例的一致性)。 +app.get('/api/desktop-theme', async (req, reply) => { + if (!requireAuth(req, reply)) return; + return { dark: getDesktopDark() }; +}); +// 设置实例深色(管理员)。面板顶栏主题开关切到 深/浅 时调用:持久化即可。它作为浏览器(Chromium)实例 +// 启动时的明暗(经 envList → WOC_DARK 下发,autostart 据此加 --force-dark-mode),故**重启实例后生效**, +// 不做在线切换(极简容器内无稳定的桌面 portal,微信也不跟随,详见 docker/autostart 注释)。 +app.post('/api/admin/desktop-theme', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const dark = !!(req.body as any)?.dark; + setDesktopDark(dark); + appendPanelLog('INFO', `实例深色设为 ${dark ? '深色' : '浅色'}(浏览器实例重启后生效)`); + return { ok: true, dark }; +}); + +// ---------- 自助改密 ---------- +app.post('/api/account/password', async (req, reply) => { + const u = requireAuth(req, reply); + if (!u) return; + const { oldPassword, newPassword } = (req.body as any) ?? {}; + if (!verifyPassword(u, oldPassword ?? '')) return reply.code(400).send({ error: '原密码错误' }); + if (!newPassword || String(newPassword).length < 6) return reply.code(400).send({ error: '新密码至少 6 位' }); + resetPassword(u.id, newPassword); + return { ok: true }; +}); + +// ---------- 管理员:子账号管理 ---------- +app.get('/api/admin/users', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + return { users: listUsers() }; +}); + +app.post('/api/admin/users', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const { username, password } = (req.body as any) ?? {}; + if (!username || !/^[a-zA-Z0-9_]{3,20}$/.test(username)) { + return reply.code(400).send({ error: '用户名为 3-20 位字母、数字或下划线' }); + } + if (!password || String(password).length < 6) return reply.code(400).send({ error: '密码至少 6 位' }); + const allowedInstances = Array.isArray((req.body as any)?.allowedInstances) ? (req.body as any).allowedInstances : []; + try { + return { user: createSub(username, password, allowedInstances) }; + } catch (e: any) { + return reply.code(400).send({ error: e.message }); + } +}); + +// 账户侧:设置某账户可访问的实例 +app.post('/api/admin/users/:id/instances', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const id = (req.params as any).id; + const instanceIds = Array.isArray((req.body as any)?.instanceIds) ? (req.body as any).instanceIds : []; + try { + return { user: setUserInstances(id, instanceIds) }; + } catch (e: any) { + return reply.code(400).send({ error: e.message }); + } +}); + +app.post('/api/admin/users/:id/disable', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const { disabled } = (req.body as any) ?? {}; + const id = (req.params as any).id; + try { + const user = setDisabled(id, !!disabled); + if (disabled) destroyUserSessions(id); + return { user }; + } catch (e: any) { + return reply.code(400).send({ error: e.message }); + } +}); + +app.post('/api/admin/users/:id/reset', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const { newPassword } = (req.body as any) ?? {}; + const id = (req.params as any).id; + if (!newPassword || String(newPassword).length < 6) return reply.code(400).send({ error: '密码至少 6 位' }); + try { + const user = resetPassword(id, newPassword); + destroyUserSessions(id); + return { user }; + } catch (e: any) { + return reply.code(400).send({ error: e.message }); + } +}); + +// 改用户名(登录名)。会话以 userId 为准,改名后保持登录、下次用新名登录即可。 +app.post('/api/admin/users/:id/rename', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const { username } = (req.body as any) ?? {}; + const id = (req.params as any).id; + if (!username || !/^[a-zA-Z0-9_]{3,20}$/.test(username)) { + return reply.code(400).send({ error: '用户名为 3-20 位字母、数字或下划线' }); + } + try { + return { user: renameUser(id, username) }; + } catch (e: any) { + return reply.code(400).send({ error: e.message }); + } +}); + +app.delete('/api/admin/users/:id', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const id = (req.params as any).id; + try { + deleteUser(id); + destroyUserSessions(id); + return { ok: true }; + } catch (e: any) { + return reply.code(400).send({ error: e.message }); + } +}); + +// ---------- 微信实例管理 ---------- +// 列出当前用户可见实例(含运行态 + 微信安装状态) +app.get('/api/instances', async (req, reply) => { + const u = requireAuth(req, reply); + if (!u) return; + const visible = userInstances(u); + const out = await Promise.all( + visible.map(async (pub) => { + const inst = findInstance(pub.id)!; + const [runtime, wx] = await Promise.all([instanceRuntime(inst), wechatStatus(inst)]); + return { ...pub, runtime, wechat: wx }; + }), + ); + return { instances: out }; +}); + +// 用户自助「卡死自愈」:当客户端检测到 VNC 多次干净重连仍连不上(多半是实例 KasmVNC 的 ws 接收器卡死—— +// nginx 仍能serve 静态页让 noVNC 显示"正在连接",但新 ws 永远 accept 不了,刷新/重启面板都无效、只能重启容器), +// 客户端调用本接口重启该实例(数据卷保留,约十几秒恢复)。需对该实例有访问权;每实例 3 分钟限一次防重启风暴。 +const lastHealAt = new Map(); +app.post('/api/instances/:id/heal', async (req, reply) => { + const u = requireAuth(req, reply); + if (!u) return; + const id = (req.params as any).id; + if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' }); + const inst = findInstance(id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + const now = Date.now(); + if (now - (lastHealAt.get(id) || 0) < 180000) { + return { ok: true, restarted: false, message: '近期已尝试恢复,请稍候重连' }; + } + lastHealAt.set(id, now); + appendPanelLog('WARN', `实例「${inst.name}」(id=${id}) 由 ${u.username} 触发卡死自愈(VNC 连不上 → 重启容器,数据保留)`); + try { + await runInstance(inst); + return { ok: true, restarted: true }; + } catch (e: any) { + appendPanelLog('ERROR', `实例「${inst.name}」(id=${id}) 卡死自愈重启失败:${e?.message || e}`); + return reply.code(500).send({ error: '恢复失败:' + (e?.message || e) }); + } +}); + +// 客户端连接日志:前端把 VNC 连接态/动作回传,记进实例持久日志([client] 前缀),与 [vnc] 服务端日志对齐排查。 +app.post('/api/instances/:id/clientlog', async (req, reply) => { + const u = requireAuth(req, reply); + if (!u) return; + const id = (req.params as any).id; + if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' }); + const msg = String((req.body as any)?.msg ?? '') + .replace(/[\r\n]+/g, ' ') + .slice(0, 200); + if (msg) appendInstanceLog(id, `[client] ${msg}(${u.username})`); + return { ok: true }; +}); + +// 新建实例(仅管理员):生成凭据 + docker run + 分配访问账户 +app.post('/api/admin/instances', async (req, reply) => { + const admin = requireAdmin(req, reply); + if (!admin) return; + const { name, reuseVolume, appType } = (req.body as any) ?? {}; + const allowedUserIds = Array.isArray((req.body as any)?.allowedUserIds) ? (req.body as any).allowedUserIds : []; + if (!name || String(name).trim().length === 0 || String(name).length > 30) { + return reply.code(400).send({ error: '实例名称为 1-30 个字符' }); + } + const type: AppType = APP_TYPES.includes(appType) ? appType : 'wechat'; + // 复用卷:必须以 woc-data- 开头,且不能被现存实例占用。后端先校验,避免坏名穿透到 docker run。 + let reuseVolumeName: string | undefined; + if (reuseVolume) { + if (typeof reuseVolume !== 'string' || !/^woc-data-[0-9a-zA-Z._-]{1,64}$/.test(reuseVolume)) { + return reply.code(400).send({ error: '复用卷名不合法' }); + } + if (listInstances().some((i) => i.volumeName === reuseVolume)) { + return reply.code(409).send({ error: '该数据卷已被另一个实例占用' }); + } + reuseVolumeName = reuseVolume; + } + const inst = createInstance(String(name), admin.id, allowedUserIds, reuseVolumeName, type); + appendPanelLog( + 'INFO', + `创建实例「${inst.name}」(${type}, id=${inst.id}) by ${admin.username}${reuseVolumeName ? ` · 复用卷 ${reuseVolumeName}` : ''} → 开始创建容器(镜像缺失会自动拉取,首次较慢)`, + ); + appendInstanceLog(inst.id, `实例创建(${type})by ${admin.username}`); + try { + await runInstance(inst); + } catch (e: any) { + removeInstanceRecord(inst.id); // 容器起不来则回滚登记 + appendPanelLog('ERROR', `创建实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`); + return reply.code(500).send({ error: '创建容器失败:' + (e?.message || e) }); + } + appendPanelLog('INFO', `创建实例「${inst.name}」(id=${inst.id}) 成功`); + return { instance: publicInstance(inst) }; +}); + +// 列出"未被任何实例引用的 woc-data-* 数据卷"。删除实例时默认保留卷(聊天记录),但 panel 里 +// 看不到这些孤儿卷;本接口让管理员在新建实例时复用旧卷(同微信号扫码可继承聊天记录), +// 或在不需要时彻底删除。 +app.get('/api/admin/orphan-volumes', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const referenced = new Set(listInstances().map((i) => i.volumeName)); + try { + const volumes = await listOrphanVolumes(referenced); + return { volumes }; + } catch (e: any) { + return reply.code(500).send({ error: e?.message || '读取数据卷失败' }); + } +}); + +// 列出"残留的 woc-wx-* 容器":docker 里存在但 store 没登记。多为 runInstance 启动失败遗留 +// 的 Created 容器,会占着 woc-data- 卷名让删卷报 409。提供给管理员一键清理。 +app.get('/api/admin/orphan-containers', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const known = new Set(listInstances().map((i) => i.containerName)); + try { + const containers = await listOrphanContainers(known); + return { containers }; + } catch (e: any) { + return reply.code(500).send({ error: e?.message || '读取容器失败' }); + } +}); + +// 强制删除一个残留容器。仅当它不在 store 的已知容器集中(防误删正在用的实例)。 +app.delete('/api/admin/orphan-containers/:idOrName', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const idOrName = (req.params as any).idOrName; + if (!idOrName || typeof idOrName !== 'string') return reply.code(400).send({ error: '参数不合法' }); + if (listInstances().some((i) => i.containerName === idOrName)) { + return reply.code(409).send({ error: '该容器属于现存实例,不能在此删除' }); + } + try { + await removeContainerById(idOrName); + return { ok: true }; + } catch (e: any) { + return reply.code(500).send({ error: e?.message || '删除容器失败' }); + } +}); + +// 显式删除一个未使用的数据卷。被现存实例占用时拒绝(避免误删聊天记录)。 +app.delete('/api/admin/orphan-volumes/:name', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const name = (req.params as any).name; + if (!name || typeof name !== 'string' || !name.startsWith('woc-data-')) { + return reply.code(400).send({ error: '卷名不合法' }); + } + if (listInstances().some((i) => i.volumeName === name)) { + return reply.code(409).send({ error: '该数据卷正被某个实例使用,不能删除' }); + } + try { + await removeVolume(name); + return { ok: true }; + } catch (e: any) { + return reply.code(500).send({ error: e?.message || '删除数据卷失败' }); + } +}); + +// 查/改单实例的内存安全阀(soft / hard)。前端"实例卡片 → 安全"弹窗用。 +// GET 返回 per-instance 当前覆盖值 + 全局默认 + 实时内存(用于弹窗里展示)。 +// PUT 接受 {soft, hard},每项可为正整数 / null(null = 恢复默认)。 +app.get('/api/admin/instances/:id/mem-limits', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const id = (req.params as any).id; + const inst = findInstance(id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + let currentMB = 0; + try { + if ((await instanceRuntime(inst)) === 'running') currentMB = await instanceMemoryMB(inst); + } catch { + /* ignore:未运行时为 0 */ + } + return { + soft: inst.memSoftLimitMB ?? null, + hard: inst.memHardLimitMB ?? null, + defaultSoft: DEFAULT_SOFT_MB, + defaultHard: DEFAULT_HARD_MB, + currentMB, + watchdogEnabled: WATCHDOG_ENABLED, + intervalSec: WATCHDOG_INTERVAL_SEC, + }; +}); +app.put('/api/admin/instances/:id/mem-limits', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const id = (req.params as any).id; + const inst = findInstance(id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + const body = (req.body as any) ?? {}; + // 允许 number / null;其它类型都视为"未提供"(保持原值) + const norm = (v: any): number | null | undefined => + v === null ? null : typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : undefined; + const s = norm(body.soft); + const h = norm(body.hard); + // 取最终生效值(写入前校验) + const finalSoft = s === undefined ? inst.memSoftLimitMB ?? null : s; + const finalHard = h === undefined ? inst.memHardLimitMB ?? null : h; + try { + const pub = setInstanceMemLimits( + id, + finalSoft, + finalHard, + ); + return { instance: pub }; + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '阈值不合法' }); + } +}); + +// 重置实例的设备 machine-id(仅管理员):滚一个全新的唯一设备身份并重启实例。 +// 用于某微信账号被腾讯按"设备风险"标记、登录即被踢时,像"换台新设备"一样恢复。会触发重新扫码登录。 +app.post('/api/admin/instances/:id/regen-machine-id', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const id = (req.params as any).id; + const inst = findInstance(id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + await regenInstanceMachineId(inst); + return { ok: true }; + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '重置设备 ID 失败' }); + } +}); + +// 删除实例(仅管理员):默认保留数据卷,?purge=1 才永久删聊天记录 +app.delete('/api/admin/instances/:id', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const id = (req.params as any).id; + const purge = (req.query as any)?.purge === '1' || (req.query as any)?.purge === 'true'; + const inst = findInstance(id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + appendPanelLog('INFO', `删除实例「${inst.name}」(id=${id})${purge ? ' · 同时清除数据卷' : ' · 保留数据卷'}`); + await removeInstanceContainer(inst, purge); + removeInstanceRecord(id); + controlHolders.delete(id); + return { ok: true }; +}); + +// 重命名实例(仅管理员):只改显示名,不动容器/卷。 +app.post('/api/admin/instances/:id/rename', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const { name } = (req.body as any) ?? {}; + try { + return { instance: renameInstance((req.params as any).id, String(name ?? '')) }; + } catch (e: any) { + return reply.code(400).send({ error: e.message }); + } +}); + +// 设置实例自定义图标(仅管理员):icon = builtin: / data:image 图片 / 空串(恢复默认)。 +app.post('/api/admin/instances/:id/icon', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const { icon } = (req.body as any) ?? {}; + try { + return { instance: setInstanceIcon((req.params as any).id, typeof icon === 'string' ? icon : null) }; + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '设置图标失败' }); + } +}); + +// 启动实例容器(仅管理员):容器停止或被删后,一键拉起(不重建数据卷)。 +app.post('/api/admin/instances/:id/start', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + await ensureRunning(inst); + appendPanelLog('INFO', `启动实例「${inst.name}」(id=${inst.id})`); + return { ok: true }; + } catch (e: any) { + appendPanelLog('ERROR', `启动实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`); + return reply.code(500).send({ error: '启动失败:' + (e?.message || e) }); + } +}); + +// 停止实例容器(仅管理员):保留容器与数据卷。 +app.post('/api/admin/instances/:id/stop', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + await stopInstance(inst); + appendPanelLog('INFO', `停止实例「${inst.name}」(id=${inst.id})`); + return { ok: true }; + } catch (e: any) { + appendPanelLog('ERROR', `停止实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`); + return reply.code(500).send({ error: '停止失败:' + (e?.message || e) }); + } +}); + +// 重启实例容器(仅管理员):按当前本地镜像重建(保留数据卷 → 登录态不丢;快速,不联网拉取)。 +app.post('/api/admin/instances/:id/restart', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + appendPanelLog('INFO', `重启实例「${inst.name}」(id=${inst.id})`); + await runInstance(inst); + return { ok: true }; + } catch (e: any) { + appendPanelLog('ERROR', `重启实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`); + return reply.code(500).send({ error: '重启失败:' + (e?.message || e) }); + } +}); + +// 升级实例(仅管理员):拉取最新微信镜像后重建(保留数据卷)。用于把旧实例更新到新版镜像 +// (如修复"最小化丢失"等),类似「更新微信」但更新的是实例容器镜像本身。 +app.post('/api/admin/instances/:id/upgrade', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}):拉取最新镜像后重建`); + await upgradeInstance(inst); + appendPanelLog('INFO', `升级实例「${inst.name}」(id=${inst.id}) 完成`); + return { ok: true }; + } catch (e: any) { + appendPanelLog('ERROR', `升级实例「${inst.name}」(id=${inst.id}) 失败:${e?.message || e}`); + return reply.code(500).send({ error: '升级失败:' + (e?.message || e) }); + } +}); + +// 实例侧:设置该实例可被哪些账户访问 +app.post('/api/admin/instances/:id/users', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const id = (req.params as any).id; + const userIds = Array.isArray((req.body as any)?.userIds) ? (req.body as any).userIds : []; + try { + setInstanceUsers(id, userIds); + return { ok: true }; + } catch (e: any) { + return reply.code(400).send({ error: e.message }); + } +}); + +// ---------- 文件中转(有访问权限即可用;走面板鉴权,不额外暴露) ---------- +// 上传:原始二进制直传,落到实例 ~/Desktop,微信文件选择器可直接选到。 +app.post('/api/instances/:id/upload', { bodyLimit: 512 * 1024 * 1024 }, async (req, reply) => { + const u = requireAuth(req, reply); + if (!u) return; + const id = (req.params as any).id; + if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' }); + const name = String((req.query as any)?.name || '').trim(); + const body = req.body as Buffer; + if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' }); + try { + await uploadToInstance(findInstance(id)!, name, body); + return { ok: true }; + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '上传失败' }); + } +}); + +// 列出可下载的中转文件 +app.get('/api/instances/:id/files', async (req, reply) => { + const u = requireAuth(req, reply); + if (!u) return; + const id = (req.params as any).id; + if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' }); + try { + return { files: await listInstanceFiles(findInstance(id)!) }; + } catch { + return { files: [] }; + } +}); + +// 删除某个中转文件(有访问权限即可) +app.delete('/api/instances/:id/files', async (req, reply) => { + const u = requireAuth(req, reply); + if (!u) return; + const id = (req.params as any).id; + if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' }); + const name = String((req.query as any)?.name || '').trim(); + try { + await deleteInstanceFile(findInstance(id)!, name); + return { ok: true }; + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '删除失败' }); + } +}); + +// 下载某个中转文件 +app.get('/api/instances/:id/download', async (req, reply) => { + const u = requireAuth(req, reply); + if (!u) return; + const id = (req.params as any).id; + if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' }); + const name = String((req.query as any)?.name || '').trim(); + try { + const buf = await downloadFromInstance(findInstance(id)!, name); + reply.header('content-type', 'application/octet-stream'); + reply.header('content-disposition', `attachment; filename*=UTF-8''${encodeURIComponent(name)}`); + return reply.send(buf); + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '下载失败' }); + } +}); + +// ---------- 多端协作:操作控制权(心跳软锁,避免多人同时操作打架) ---------- +// 同一实例被多个浏览器连的是同一会话,键鼠会互相打架。这里用"心跳持锁": +// 当前操作者每隔几秒 beat 续约;TTL 内他人只读(前端盖只读遮罩)。空闲超 TTL 自动释放。 +const CONTROL_TTL = 10_000; // ms:超过则视为已空闲,可被接管 +const controlHolders = new Map(); + +// 续约/认领:无人持有、已超时、或本来就是我 → 我成为操作者;否则返回当前操作者。 +app.post('/api/instances/:id/control/beat', async (req, reply) => { + const u = requireAuth(req, reply); + if (!u) return; + const id = (req.params as any).id; + if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' }); + const now = Date.now(); + const h = controlHolders.get(id); + if (!h || now - h.at > CONTROL_TTL || h.userId === u.id) { + controlHolders.set(id, { userId: u.id, username: u.username, at: now }); + return { mine: true, holder: u.username }; + } + return { mine: false, holder: h.username }; +}); + +// 只读查询当前操作者(前端轮询;不认领)。超 TTL 视为空闲。 +app.get('/api/instances/:id/control', async (req, reply) => { + const u = requireAuth(req, reply); + if (!u) return; + const id = (req.params as any).id; + if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' }); + const h = controlHolders.get(id); + if (!h || Date.now() - h.at > CONTROL_TTL) return { free: true, mine: false, holder: null }; + return { free: false, mine: h.userId === u.id, holder: h.username }; +}); + +// 主动接管("申请控制"):强制把操作权抢过来。 +app.post('/api/instances/:id/control/take', async (req, reply) => { + const u = requireAuth(req, reply); + if (!u) return; + const id = (req.params as any).id; + if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' }); + controlHolders.set(id, { userId: u.id, username: u.username, at: Date.now() }); + return { mine: true, holder: u.username }; +}); + +// 通过 xdotool 在实例容器内输入文字(绕过 VNC XKB keysym 容量限制,修复中文 IME 吞字) +app.post('/api/instances/:id/type', async (req, reply) => { + const u = requireAuth(req, reply); + if (!u) return; + const id = (req.params as any).id; + if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' }); + const { text } = (req.body as any) ?? {}; + if (!text || typeof text !== 'string' || text.length > 500) return reply.code(400).send({ error: '文字为空或过长' }); + try { + await typeInInstance(findInstance(id)!, text); + return { ok: true }; + } catch (e: any) { + return reply.code(500).send({ error: e?.message || '输入失败' }); + } +}); + +// 模拟单个按键(无感输入模式下按序送出被截下的回车/退格,保证与中文转发的顺序) +app.post('/api/instances/:id/key', async (req, reply) => { + const u = requireAuth(req, reply); + if (!u) return; + const id = (req.params as any).id; + if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' }); + const { key } = (req.body as any) ?? {}; + if (!key || typeof key !== 'string') return reply.code(400).send({ error: '按键名为空' }); + try { + await keyInInstance(findInstance(id)!, key); + return { ok: true }; + } catch (e: any) { + return reply.code(500).send({ error: e?.message || '按键失败' }); + } +}); + +// 查看实例容器日志(仅管理员):排查"无法进入/未安装/卡死"等。inline 文本,浏览器可直接看/另存。 +app.get('/api/admin/instances/:id/logs', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + reply.header('content-type', 'text/plain; charset=utf-8'); + // 持久化历史(重启原因 + 上一容器日志快照,跨重建保留)+ 本次容器实时日志。 + const history = readInstanceLog(inst.id).trimEnd(); + let live = ''; + try { + live = (await instanceLogs(inst)).trimEnd(); + } catch (e: any) { + live = '获取本次容器日志失败:' + (e?.message || e); + } + if (!history && !live) return reply.send('(暂无日志)'); + if (!history) return reply.send(live); + return reply.send( + `═══ 历史日志(持久化 · 跨重启保留)═══\n${history}\n\n═══ 本次容器日志(实时)═══\n${live || '(本次容器暂无日志)'}`, + ); +}); + +// ---------- 全局日志 / 诊断包(仅管理员)---------- +// 面板全局运维日志(创建/删除/升级/启停/镜像拉取/错误等跨实例事件),可按时间范围裁剪。 +app.get('/api/admin/panel-log', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + reply.header('content-type', 'text/plain; charset=utf-8'); + const since = Date.now() - rangeToMs((req.query as any)?.range); + const text = filterSince(readPanelLog(), since).trimEnd(); + return reply.send(text || '(暂无面板日志)'); +}); + +// 一键导出诊断包(tar.gz):系统信息 + 面板日志 + 各实例容器状态/持久日志/实时日志 + 全部容器清单。 +// 单实例日志只记录"实例内单次日志",这里把全局 + 全部实例 + 容器层面的信息打包,便于排查 +// 首个实例创建卡死 / 打开实例黑屏不可用 / 升级失败等问题。range:24h(默认)/7d/30d/1y。 +app.get('/api/admin/diagnostics', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const range = ((req.query as any)?.range as string) || '24h'; + if (!DIAG_RANGES[range]) return reply.code(400).send({ error: '时间范围非法(24h/7d/30d/1y)' }); + const since = Date.now() - rangeToMs(range); + try { + const buf = await buildDiagnostics(listInstances(), since, { range, 面板版本: CURRENT_VERSION }); + const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19); + reply.header('content-type', 'application/gzip'); + reply.header('content-disposition', `attachment; filename="woc-diag-${range}-${stamp}.tar.gz"`); + appendPanelLog('INFO', `导出诊断包(范围 ${range},${buf.length} 字节)`); + return reply.send(buf); + } catch (e: any) { + appendPanelLog('ERROR', `导出诊断包失败:${e?.message || e}`); + return reply.code(500).send({ error: '生成诊断包失败:' + (e?.message || e) }); + } +}); + +// ---------- 数据卷管理(仅管理员):浏览/上传/解压/下载/改名/移动/删除 + 整卷备份/恢复 ---------- +// 数据卷 = 容器 /config,含微信完整会话与加密聊天库 → 仅 admin 可见可用(admin 本就有 docker.sock=宿主 root, +// 不新增风险;子账号永不可达)。 +// 全程在「运行中」的实例上操作:浏览/改名/移动/删除靠 docker exec(需容器运行),上传/解压/下载/备份靠 +// getArchive/putArchive。不强制停止实例(exec 在停止容器无法运行)。整卷恢复会覆盖全部数据,前端强提示 +// 并建议恢复后重启实例以加载数据。 + +// 浏览目录(一层) +app.get('/api/admin/instances/:id/volume', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + return await listVolume(inst, String((req.query as any)?.path || '')); + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '读取目录失败' }); + } +}); + +// 新建文件夹 +app.post('/api/admin/instances/:id/volume/mkdir', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + await volMkdir(inst, String((req.body as any)?.path || '')); + return { ok: true }; + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '新建失败' }); + } +}); + +// 重命名 / 移动 +app.post('/api/admin/instances/:id/volume/move', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + const { from, to } = (req.body as any) ?? {}; + try { + await volMove(inst, String(from || ''), String(to || '')); + return { ok: true }; + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '移动失败' }); + } +}); + +// 删除文件 / 目录 +app.delete('/api/admin/instances/:id/volume', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + await volDelete(inst, String((req.query as any)?.path || '')); + return { ok: true }; + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '删除失败' }); + } +}); + +// 下载单个文件 +app.get('/api/admin/instances/:id/volume/download', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + const path = String((req.query as any)?.path || ''); + const name = path.split('/').filter(Boolean).pop() || 'file'; + try { + const buf = await volDownloadFile(inst, path); + reply.header('content-type', 'application/octet-stream'); + reply.header('content-disposition', `attachment; filename*=UTF-8''${encodeURIComponent(name)}`); + return reply.send(buf); + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '下载失败' }); + } +}); + +// 上传单个文件到当前目录(原始二进制;落地为 abc 属主) +app.post('/api/admin/instances/:id/volume/upload', { bodyLimit: 2 * 1024 * 1024 * 1024 }, async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + const path = String((req.query as any)?.path || ''); + const name = String((req.query as any)?.name || '').trim(); + const body = req.body as Buffer; + if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' }); + try { + await volUploadFile(inst, path, name, body); + return { ok: true }; + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '上传失败' }); + } +}); + +// 上传压缩包并解压到当前目录(.tar / .tar.gz;PC 微信数据迁移用) +app.post('/api/admin/instances/:id/volume/extract', { bodyLimit: 3 * 1024 * 1024 * 1024 }, async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + const body = req.body as Buffer; + if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' }); + try { + await volExtractArchive(inst, String((req.query as any)?.path || ''), body); + return { ok: true }; + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '解压失败(请确认是 .tar 或 .tar.gz)' }); + } +}); + +// 整卷备份:流式下载 /config 为 .tar.gz +app.get('/api/admin/instances/:id/volume/backup', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + const stream = await volBackupStream(inst); + reply.header('content-type', 'application/gzip'); + reply.header('content-disposition', `attachment; filename*=UTF-8''${encodeURIComponent(`woc-${inst.name}-backup.tar.gz`)}`); + return reply.send(stream); + } catch (e: any) { + return reply.code(500).send({ error: e?.message || '备份失败' }); + } +}); + +// 整卷恢复:上传本系统导出的 .tar.gz 备份(要求实例已停止) +app.post('/api/admin/instances/:id/volume/restore', { bodyLimit: 3 * 1024 * 1024 * 1024 }, async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = findInstance((req.params as any).id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + const body = req.body as Buffer; + if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件或格式错误' }); + try { + await volRestoreArchive(inst, body); + return { ok: true }; + } catch (e: any) { + return reply.code(400).send({ error: e?.message || '恢复失败' }); + } +}); + +// 该实例的微信安装状态(有访问权限即可看) +app.get('/api/instances/:id/wechat/status', async (req, reply) => { + const u = requireAuth(req, reply); + if (!u) return; + const id = (req.params as any).id; + if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' }); + return { status: await wechatStatus(findInstance(id)!) }; +}); + +// 触发该实例微信下载/更新(仅管理员) +async function triggerInstanceWechat(id: string, cmd: 'install' | 'update', reply: FastifyReply) { + const inst = findInstance(id); + if (!inst) return reply.code(404).send({ error: '实例不存在' }); + try { + await triggerWechat(inst, cmd); + appendPanelLog('INFO', `实例「${inst.name}」(id=${id}) 触发${cmd === 'install' ? '下载安装' : '更新'}应用`); + return { ok: true }; + } catch (e: any) { + appendPanelLog('ERROR', `实例「${inst.name}」(id=${id}) 触发${cmd === 'install' ? '安装' : '更新'}失败:${e?.message || e}`); + return reply.code(500).send({ error: '无法触发安装:' + (e?.message || e) }); + } +} + +app.post('/api/admin/instances/:id/wechat/install', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + return triggerInstanceWechat((req.params as any).id, 'install', reply); +}); + +app.post('/api/admin/instances/:id/wechat/update', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + return triggerInstanceWechat((req.params as any).id, 'update', reply); +}); + +// ---------- 桌面壁纸管理 ---------- +const bgHandler = (id: string, reply: FastifyReply): Instance | null => { + const inst = findInstance(id); + if (!inst) { + reply.code(404).send({ error: '实例不存在' }); + return null; // 关键:reply 是 truthy,不能 return 它,否则调用方 `if (!inst)` 拦不住 + } + return inst; +}; + +app.get('/api/admin/instances/:id/backgrounds', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = bgHandler((req.params as any).id, reply); + if (!inst) return; + try { return { backgrounds: await listBackgrounds(inst) }; } + catch (e: any) { return reply.code(500).send({ error: e?.message || '列出壁纸失败' }); } +}); + +app.post('/api/admin/instances/:id/backgrounds', { bodyLimit: 50 * 1024 * 1024 }, async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = bgHandler((req.params as any).id, reply); + if (!inst) return; + const name = String((req.query as any)?.name || '').trim(); + const body = req.body as Buffer; + if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件' }); + try { + await uploadBackground(inst, name, body); + return { ok: true }; + } catch (e: any) { return reply.code(400).send({ error: e?.message || '上传失败' }); } +}); + +app.get('/api/admin/instances/:id/backgrounds/current', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = bgHandler((req.params as any).id, reply); + if (!inst) return; + try { return { background: await getCurrentBackground(inst) }; } + catch (e: any) { return reply.code(500).send({ error: e?.message || '获取当前壁纸失败' }); } +}); + +app.get('/api/admin/instances/:id/backgrounds/:name/image', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = bgHandler((req.params as any).id, reply); + if (!inst) return; + try { + const buf = await getBackgroundImage(inst, (req.params as any).name, true); + const name = (req.params as any).name as string; + const ext = name.includes('.') ? name.split('.').pop()!.toLowerCase() : 'png'; + const mime: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp' }; + reply.header('Content-Type', mime[ext] || 'application/octet-stream'); + reply.header('Cache-Control', 'public, max-age=86400'); + return reply.send(buf); + } catch (e: any) { return reply.code(404).send({ error: '图片不存在' }); } +}); + +app.post('/api/admin/instances/:id/backgrounds/:name/apply', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = bgHandler((req.params as any).id, reply); + if (!inst) return; + try { + await applyBackground(inst, (req.params as any).name); + return { ok: true }; + } catch (e: any) { return reply.code(400).send({ error: e?.message || '应用壁纸失败' }); } +}); + +app.post('/api/admin/instances/:id/backgrounds/clear', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = bgHandler((req.params as any).id, reply); + if (!inst) return; + try { + await clearBackground(inst); + return { ok: true }; + } catch (e: any) { return reply.code(400).send({ error: e?.message || '清除壁纸失败' }); } +}); + +app.delete('/api/admin/instances/:id/backgrounds/:name', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = bgHandler((req.params as any).id, reply); + if (!inst) return; + try { + await deleteBackground(inst, (req.params as any).name); + return { ok: true }; + } catch (e: any) { return reply.code(400).send({ error: e?.message || '删除失败' }); } +}); + +// ---------- 字体管理 ---------- +app.get('/api/admin/instances/:id/fonts', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = bgHandler((req.params as any).id, reply); + if (!inst) return; + try { return { fonts: await listFonts(inst) }; } + catch (e: any) { return reply.code(500).send({ error: e?.message || '列出字体失败' }); } +}); + +app.post('/api/admin/instances/:id/fonts', { bodyLimit: 50 * 1024 * 1024 }, async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = bgHandler((req.params as any).id, reply); + if (!inst) return; + const name = String((req.query as any)?.name || '').trim(); + const body = req.body as Buffer; + if (!Buffer.isBuffer(body) || body.length === 0) return reply.code(400).send({ error: '空文件' }); + try { + await uploadFont(inst, name, body); + return { ok: true }; + } catch (e: any) { return reply.code(400).send({ error: e?.message || '上传失败' }); } +}); + +app.delete('/api/admin/instances/:id/fonts/:name', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = bgHandler((req.params as any).id, reply); + if (!inst) return; + try { + await deleteFont(inst, (req.params as any).name); + return { ok: true }; + } catch (e: any) { return reply.code(400).send({ error: e?.message || '删除失败' }); } +}); + +app.get('/api/admin/instances/:id/fonts/current', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = bgHandler((req.params as any).id, reply); + if (!inst) return; + try { return { fontFile: await getAppliedFont(inst) }; } + catch (e: any) { return reply.code(500).send({ error: e?.message || '获取当前字体失败' }); } +}); + +app.post('/api/admin/instances/:id/fonts/:name/apply', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = bgHandler((req.params as any).id, reply); + if (!inst) return; + try { + await applyFont(inst, (req.params as any).name); + return { ok: true }; + } catch (e: any) { return reply.code(400).send({ error: e?.message || '应用字体失败' }); } +}); + +app.post('/api/admin/instances/:id/fonts/default', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + const inst = bgHandler((req.params as any).id, reply); + if (!inst) return; + try { + await applyFont(inst, 'default'); + return { ok: true }; + } catch (e: any) { return reply.code(400).send({ error: e?.message || '重置字体失败' }); } +}); + +// ---------- 反向代理到内网 KasmVNC(按实例注入 Basic auth,会话 + 权限把守) ---------- +// 单个 proxy 实例,target 与凭据逐请求指定:凭据暂存在 req 上,proxyReq 时注入。 +const proxy = httpProxy.createProxyServer({ changeOrigin: true, ws: true }); +proxy.on('proxyReq', (proxyReq, req) => { + const auth = (req as any)._wocAuth; + if (auth) proxyReq.setHeader('authorization', auth); +}); +proxy.on('proxyReqWs', (proxyReq, req) => { + const auth = (req as any)._wocAuth; + if (auth) proxyReq.setHeader('authorization', auth); + // 上游(实例 nginx → KasmVNC websockify)回 101 = ws 接收器接受了连接,桌面真正连上。 + // 卡死时这条不会出现(接收器停止 accept),即可定位"卡在面板→实例之间还是实例内部"。 + const instId = (req as any)._wocInstId; + if (instId) proxyReq.on('upgrade', () => appendInstanceLog(instId, '[vnc] 上游已接受(101) · 桌面连接建立')); +}); +// 兜底:剥掉 KasmVNC 401 的 WWW-Authenticate 头,避免浏览器弹出原生 Basic Auth 登录框。 +// 正常路径下我们已注入正确凭据(不会 401);万一凭据失配,宁可桌面加载失败也绝不把登录弹窗暴露给用户。 +proxy.on('proxyRes', (proxyRes) => { + delete proxyRes.headers['www-authenticate']; +}); +// 上游(实例 Web)暂时连不上时,给浏览器导航请求回一个「自动重连」的友好页面,而不是死的纯文本。 +// 实例在 创建初始化 / 升级 / 重启 / 内存自愈软重启,以及面板自更新(代理短暂中断)时都会短暂 502, +// 几秒后即恢复;旧版回纯文本"桌面服务暂时不可用"且 iframe 一旦载入它就判 frameLoaded=true、不再重试, +// 用户就卡在黑屏死页(用户反馈的"新版黑屏 桌面服务暂不可用")。此页每 3s 自动重载,实例一就绪即自动连上; +// 连续约 30s 仍不行才转手动重试,并按 20s 间隔重置计数(区分新一轮故障)。 +const UPSTREAM_DOWN_HTML = + `` + + `桌面连接中…
桌面正在启动 / 重连中…
` + + `
实例重启或初始化时会短暂不可用,将自动重连,请稍候。
` + + `
`; +proxy.on('error', (_err, req, res) => { + try { + const r = res as any; + if (r && typeof r.writeHead === 'function') { + // 仅对浏览器导航(接受 text/html)回友好自动重连页;JS/CSS/XHR 等子资源回纯文本,避免把 HTML 喂给非页面请求。 + const accept = String((req as any)?.headers?.accept || ''); + if (accept.includes('text/html')) { + r.writeHead(502, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); + r.end(UPSTREAM_DOWN_HTML); + } else { + r.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }); + r.end('桌面服务暂时不可用'); + } + } else if (r && typeof r.destroy === 'function') { + r.destroy(); + } + } catch { + /* ignore */ + } +}); + +// /desktop/:id/rest → rest(剥掉前缀与实例段)。返回 null 表示 url 非法。 +function parseDesktopUrl(rawUrl: string): { id: string; rest: string } | null { + const m = rawUrl.match(/^\/desktop\/([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 }; +} + +const desktopHandler = (req: FastifyRequest, reply: FastifyReply) => { + const u = currentUser(req); + if (!u) { + reply.code(302).header('location', '/login').send(); + return; + } + const parsed = parseDesktopUrl(req.raw.url || ''); + if (!parsed || !userCanAccess(u, parsed.id)) { + reply.code(403).send({ error: '无权访问该实例' }); + return; + } + const inst = findInstance(parsed.id)!; + reply.hijack(); + req.raw.url = parsed.rest; + (req.raw as any)._wocAuth = basicAuth(inst); + proxy.web(req.raw, reply.raw, { target: instanceTarget(inst) }); +}; + +app.all('/desktop/:id', desktopHandler); +app.all('/desktop/:id/*', desktopHandler); + +// ---------- 反向代理到实例的 woc-bridge 业务 API ---------- +// /api/bridge/:id/* → http://woc-wx-:8088/* +// 仅管理员可访问(业务 API 等同微信会话凭据,可发消息/读消息/查联系人,不可暴露给普通用户) +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 }; +} + +const bridgeHandler = (req: FastifyRequest, reply: FastifyReply) => { + 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) => { + const url = req.raw.url || ''; + if (url.startsWith('/api') || url.startsWith('/desktop')) { + return reply.code(404).send({ error: 'not found' }); + } + return reply.sendFile('index.html'); +}); + +// ---------- 启动 + WebSocket 升级(同样校验会话) ---------- +function parseCookies(header?: string): Record { + const out: Record = {}; + if (!header) return out; + for (const part of header.split(';')) { + const idx = part.indexOf('='); + if (idx === -1) continue; + out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim()); + } + return out; +} + +await app.ready(); + +app.server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) => { + // DNS-rebinding gate for WebSocket upgrades (Fastify's onRequest hook does + // not run on raw upgrades). KasmVNC proxying goes through this path. + if (!isRequestHostAllowed(req.headers.host, req.headers['x-forwarded-host'], ALLOWED_HOSTS)) { + socket.destroy(); + return; + } + const parsed = req.url ? parseDesktopUrl(req.url) : null; + if (!parsed) { + socket.destroy(); + return; + } + const cookies = parseCookies(req.headers.cookie); + const s = getSession(cookies[COOKIE]); + const u = s && findById(s.userId); + if (!u || u.disabled || !userCanAccess(u, parsed.id)) { + socket.destroy(); + return; + } + const inst = findInstance(parsed.id)!; + req.url = parsed.rest; + (req as any)._wocAuth = basicAuth(inst); + (req as any)._wocInstId = inst.id; + // 远程桌面连接日志:记录每次 ws 连接尝试 / 上游接受(在 proxyReqWs 里) / 失败 / 关闭时长。 + // 与实例容器内 KasmVNC 的 "got client connection" 按时间对齐,即可看出卡在哪一段。 + const ip = (req.socket && req.socket.remoteAddress) || '?'; + const uname = (u as any).username || '?'; + appendInstanceLog(inst.id, `[vnc] 连接尝试 user=${uname} ip=${ip}`); + const t0 = Date.now(); + socket.on('close', () => appendInstanceLog(inst.id, `[vnc] 连接关闭(持续 ${Math.round((Date.now() - t0) / 1000)}s)`)); + proxy.ws(req, socket, head, { target: instanceTarget(inst) }, (err: any) => { + appendInstanceLog(inst.id, `[vnc] 连接失败:${err?.message || err}`); + }); +}); + +// 探测面板网络 + 重启后把已登记实例的容器拉起来 +await ensureNetwork().catch(() => {}); +for (const pub of listInstances()) { + try { + await ensureRunning(findInstance(pub.id)!); + } catch (e: any) { + app.log.warn(`[instance] 启动实例 ${pub.id} 失败: ${e?.message || e}`); + } +} + +// Watchdog:KasmVNC/Xvnc 长跑会泄漏(实测 24h 可达 ~9 GiB),小内存机器会被拖垮。 +// 两档阈值,按"是否有人在用"决定时机: +// soft:mem >= soft 且当前无活跃会话 → 主动重启(柔和自愈,不打扰) +// hard:mem >= hard → 无视会话强制重启(防止 OOM) +// 优先级 hard > soft。两档阈值可在面板"管理 → 实例卡片 → 安全"按钮里单实例覆盖;缺省走 env。 +// +// env 默认(可被 per-instance 覆盖): +// WOC_INSTANCE_MEM_SOFT_MB soft 阈值;默认 1500 +// WOC_INSTANCE_MEM_HARD_MB hard 阈值;默认 2500(也兼容旧名 WOC_INSTANCE_MEM_LIMIT_MB) +// WOC_WATCHDOG_INTERVAL_SEC 巡检间隔秒;默认 300(5 分钟),最小 60;0 关闭整个 watchdog +// WOC_WATCHDOG_HEALTH_FAILS VNC 响应性探测:连续无响应几次才重启;默认 0=关闭该探测(仅保留内存自愈) +const DEFAULT_SOFT_MB = Math.max(0, Number(process.env.WOC_INSTANCE_MEM_SOFT_MB ?? 1500)); +const DEFAULT_HARD_MB = Math.max( + 0, + Number(process.env.WOC_INSTANCE_MEM_HARD_MB ?? process.env.WOC_INSTANCE_MEM_LIMIT_MB ?? 2500), +); +const WATCHDOG_INTERVAL_SEC = Math.max(60, Number(process.env.WOC_WATCHDOG_INTERVAL_SEC ?? 300)); +// VNC 响应性探测默认关闭(=0)。实测健康实例 ~1ms 响应,但偶发宿主级 CPU/IO 争用(如同机重 docker build) +// 会让探测超时被误判为 stall 而重启正常实例,故默认不启用;需要时设为正整数 N(连续 N 次无响应才重启)开启。 +const HEALTH_FAIL_LIMIT = Math.max(0, Number(process.env.WOC_WATCHDOG_HEALTH_FAILS ?? 0)); +const WATCHDOG_ENABLED = WATCHDOG_INTERVAL_SEC > 0 && (DEFAULT_SOFT_MB > 0 || DEFAULT_HARD_MB > 0); + +// 单实例生效阈值:per-instance 覆盖优先;为 undefined 则用 env 默认。 +function effectiveLimits(inst: Instance): { soft: number; hard: number } { + return { + soft: inst.memSoftLimitMB ?? DEFAULT_SOFT_MB, + hard: inst.memHardLimitMB ?? DEFAULT_HARD_MB, + }; +} + +// "当前有人在远程会话" 启发式判定:复用控制权心跳。前端在用户鼠标/键盘/滚轮交互时 2.5s 节流 beat, +// 故 holder 在 TTL 内即视为"有人在主动操作"。只看屏(不交互)超过 TTL 后会被判为空闲——这是有意的, +// 软自愈宁愿在"看似空闲"时短暂打扰,也不要拖到 hard 强制重启。 +function hasActiveSession(id: string): boolean { + const h = controlHolders.get(id); + return !!h && Date.now() - h.at <= CONTROL_TTL; +} + +if (WATCHDOG_ENABLED) { + const recovering = new Set(); // 防重入:自愈期间跳过本实例 + const healthFails = new Map(); // id → 连续无响应次数(仅 HEALTH_FAIL_LIMIT>0 时启用) + + const recover = async (inst: Instance, reason: string, detail: string) => { + recovering.add(inst.id); + app.log.warn(`[watchdog] ${inst.containerName} ${detail}`); + appendInstanceLog(inst.id, `[看门狗] 自愈重启(${reason}):${detail}`); + appendPanelLog('WARN', `[看门狗] 实例「${inst.name}」(id=${inst.id}) 自愈重启(${reason}):${detail}`); + try { + await stopInstance(inst); + await runInstance(inst); + healthFails.delete(inst.id); + app.log.info(`[watchdog] ${inst.containerName} 自愈完成(${reason})`); + } catch (e: any) { + appendPanelLog('ERROR', `[看门狗] 实例「${inst.name}」(id=${inst.id}) 自愈失败(${reason}):${e?.message || e}`); + app.log.error(`[watchdog] ${inst.containerName} 自愈失败(${reason}): ${e?.message || e}`); + } finally { + recovering.delete(inst.id); + } + }; + + const tick = async () => { + for (const pub of listInstances()) { + const inst = findInstance(pub.id); + if (!inst || recovering.has(inst.id)) continue; + try { + if ((await instanceRuntime(inst)) !== 'running') { + healthFails.delete(inst.id); + continue; + } + // 1) 内存阈值自愈(既有):hard 强制 / soft 仅在无人会话时 + const mb = await instanceMemoryMB(inst); + if (mb > 0) { + const { soft, hard } = effectiveLimits(inst); + const active = hasActiveSession(inst.id); + if (hard > 0 && mb >= hard) { + await recover(inst, 'hard', `mem=${mb}MiB ≥ hard=${hard}MiB,强制重启(active=${active})`); + continue; + } + if (soft > 0 && mb >= soft && !active) { + await recover(inst, 'soft', `mem=${mb}MiB ≥ soft=${soft}MiB 且无活跃会话,柔和重启`); + continue; + } + if (soft > 0 && mb >= soft && active) { + app.log.info(`[watchdog] ${inst.containerName} mem=${mb}MiB ≥ soft=${soft}MiB 但用户在使用,延后`); + } + } + // 2) 响应性自愈:探测 VNC 是否还能提供页面;连续 N 次无响应 → 重启。 + // 应对"进程没死、显示在线,但 I/O/服务 stall 读不出 VNC 文件、永远卡在正在连接桌面"。 + // 默认关闭(HEALTH_FAIL_LIMIT=0):偶发宿主级争用会误判健康实例为 stall;需要时用 env 开启。 + if (HEALTH_FAIL_LIMIT > 0) { + const healthy = await instanceHttpHealthy(inst); + if (healthy) { + healthFails.delete(inst.id); + continue; + } + const fails = (healthFails.get(inst.id) || 0) + 1; + healthFails.set(inst.id, fails); + app.log.warn(`[watchdog] ${inst.containerName} VNC 无响应(连续 ${fails}/${HEALTH_FAIL_LIMIT})`); + if (fails >= HEALTH_FAIL_LIMIT) { + await recover(inst, 'unresponsive', `VNC 连续 ${fails} 次无响应(疑似 I/O/服务 stall),自愈重启`); + } + } + } catch (e: any) { + app.log.warn(`[watchdog] ${pub.id} 检查异常: ${e?.message || e}`); + } + } + }; + setInterval(() => void tick(), WATCHDOG_INTERVAL_SEC * 1000).unref(); + console.log( + `[watchdog] 已启用 · soft=${DEFAULT_SOFT_MB} MiB · hard=${DEFAULT_HARD_MB} MiB · 间隔=${WATCHDOG_INTERVAL_SEC}s · VNC响应性探测=${HEALTH_FAIL_LIMIT > 0 ? `连续${HEALTH_FAIL_LIMIT}次` : '关闭'}`, + ); +} + +await app.listen({ port: PORT, host: HOST }); +console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)· 版本 ${CURRENT_VERSION}`); +appendPanelLog('INFO', `面板启动 · 版本 ${CURRENT_VERSION} · 监听 ${HOST}:${PORT}`); +startUpdateChecker(); // 后台检测新版(best-effort,失败静默) +// 日志保留期清理:启动后跑一次 + 每 24h 一次,删除超过一年的日志行(unref 不阻止退出)。 +pruneOldLogs(); +setInterval(() => pruneOldLogs(), 24 * 60 * 60 * 1000).unref(); diff --git a/scripts/build-local.sh b/scripts/build-local.sh index 4066f7d..3e70242 100644 --- a/scripts/build-local.sh +++ b/scripts/build-local.sh @@ -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 "完成。本地镜像:"