# -*- coding: utf-8 -*- """修 PC 端审计出来的 bug。 最严重 1. ui.py 建 RemoteServer 时传 actions=None,之后只补了 actions、**从没重建 Features** → 真机上 CMD_FEATURE_LIST / CMD_FEATURE_RUN 永远报"功能不可用", 手表整个功能页(105 个功能)是死的。测试各自 new 一个 Features,所以测不出来。 2. _resolve_received 对相对路径不校验 → 已配对设备能拉走 data/settings.json (里面有配对码和全部设备令牌) 3. 分块累积无上限 → 能把内存吃干;且 seq 顺序从不校验,丢块会静默产出坏文件 4. 配对码可以无限次枚举(没有失败计数/退避),比较用 != 而非 compare_digest 5. run_command 用 utf-8 解码(打包后 cmd 输出是 GBK,中文全乱)+ 没加 CREATE_NO_WINDOW(每跑一条命令闪一个黑窗) 6. _uptime 的 GetTickCount64 没设 restype,默认 32 位 → 开机超 24.8 天显示错乱 7. say_clipboard 读的是"手表推过来的剪贴板",却报"电脑剪贴板是空的" 8. --port 越界时 socket.bind 抛 OverflowError(不是 OSError),界面接不住 → 裸退 9. 安全开关(允许运行命令 / 允许电源操作 / 允许屏幕共享 / 允许新配对)没有界面 """ from __future__ import annotations import sys from pathlib import Path ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows") UI = ROOT / "lanremote" / "ui.py" SERVER = ROOT / "lanremote" / "server.py" ACTIONS = ROOT / "lanremote" / "actions.py" PROTOCOL = ROOT / "lanremote" / "protocol.py" CONFIG = ROOT / "lanremote" / "config.py" DISCOVERY = ROOT / "lanremote" / "discovery.py" FIXES = [ # ================= 1. Features 没被构造(功能页全死) ================= (UI, ''' self.server = RemoteServer(self.store, None, ui=self) self.server.actions = Actions(self, self.store) self.server.pairing_enabled = bool(self.store.get("allow_new_pairing", True))''', ''' self.server = RemoteServer(self.store, None, ui=self) self.server.actions = Actions(self, self.store) # 关键:RemoteServer 收到的是 actions=None,features 也就没建起来。 # 不重建的话 CMD_FEATURE_LIST / CMD_FEATURE_RUN 永远报"功能不可用", # 手表上整个「功能」页(100 多个功能)全是死的。 from .features import Features self.server.features = Features(self.server.actions) self.server.pairing_enabled = bool(self.store.get("allow_new_pairing", True))''', "★ 重建 Features(功能页复活)"), # ================= 2. 相对路径绕过 ================= (SERVER, ''' self._log(f"[文件] 拒绝拉取允许范围外的路径: {raw}") return None''', ''' self._log(f"[文件] 拒绝拉取允许范围外的路径: {raw}") return None # 相对路径也要过白名单。Path("data/settings.json").is_absolute() 是 False, # 上面那一段挡不住它 —— 而 EXE 的 CWD 就是程序目录, # 于是已配对设备能把 data/settings.json(配对码 + 全部设备令牌)拉走。 try: relative = (Path.cwd() / raw).resolve() except OSError: return None allowed_roots = [self.store.received_dir()] if self.features is not None: allowed_roots.extend(self.features.allowed_roots()) for root in allowed_roots: try: relative.relative_to(root.resolve()) return relative if relative.is_file() else None except (ValueError, OSError): continue self._log(f"[文件] 拒绝拉取相对路径: {raw}") return None''', "★ 相对路径也要过白名单"), # ================= 4. 配对防爆破 ================= (SERVER, ''' if code != expected: self._log(f"[配对失败] 来自 {session.addr[0]},配对码不匹配") return {"ok": False, "error": "配对码错误"}''', ''' # 防暴力枚举:6 位数字只有 100 万种,不限速的话局域网里慢慢试就行。 now = time.time() self._pair_failures = [t for t in getattr(self, "_pair_failures", []) if now - t < 300] if len(self._pair_failures) >= 10: wait = max(1, int(300 - (now - self._pair_failures[0]))) self._log(f"[配对] 失败次数过多,锁定中({wait} 秒)") return {"ok": False, "error": f"配对码错误次数过多,请等 {wait} 秒后再试"} # 用 compare_digest,避免按字符比较带来的时间侧信道 if not secrets.compare_digest(code, expected): self._pair_failures.append(now) self._log(f"[配对失败] 来自 {session.addr[0]},配对码不匹配" f"({len(self._pair_failures)}/10)") return {"ok": False, "error": "配对码错误"} self._pair_failures = []''', "★ 配对防爆破 + compare_digest"), (SERVER, " self.sessions: list[Session] = []", " self.sessions: list[Session] = []\n" " # 最近的配对失败时间戳,用于限速防枚举\n" " self._pair_failures: list[float] = []", "配对失败记录字段"), # ================= 5. run_command 编码 + 黑窗 ================= (ACTIONS, ''' proc = subprocess.run( command, shell=True, capture_output=True, text=True, timeout=timeout, encoding="utf-8", errors="replace")''', ''' # 打包成 --windowed 后没有控制台,cmd 的输出是 ANSI 代码页(本机 936) # 而不是 UTF-8 —— 用 utf-8 解会全是乱码。和本文件其它地方统一用 mbcs。 # 另外必须加 CREATE_NO_WINDOW,否则每跑一条远程命令都会闪一个黑窗。 proc = subprocess.run( command, shell=True, capture_output=True, text=True, timeout=timeout, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), encoding="mbcs", errors="replace")''', "★ run_command 编码 + 不闪黑窗"), # ================= 6. _uptime 32 位截断 ================= (ACTIONS, ''' ms = ctypes.windll.kernel32.GetTickCount64()''', ''' # 不设 restype 的话 ctypes 默认按 32 位处理, # 开机超过 24.8 天会被截断成负数,时长显示错乱 ctypes.windll.kernel32.GetTickCount64.restype = ctypes.c_ulonglong ms = ctypes.windll.kernel32.GetTickCount64()''', "★ 开机时长不再截断"), # ================= 7. say_clipboard 读错了数据源 ================= (ACTIONS, ''' def say_clipboard(self) -> str: text = str(self.store.get("last_clip") or "").strip() if not text: raise ActionError("电脑剪贴板是空的")''', ''' def say_clipboard(self) -> str: # 之前读的是 store["last_clip"](那是手表推过来的内容), # 手表没推过就永远报"剪贴板是空的",可电脑剪贴板明明有东西。 # 应该读电脑本机的剪贴板。 try: text = str(self.clip_get() or "").strip() except Exception: # noqa: BLE001 text = "" if not text: text = str(self.store.get("last_clip") or "").strip() if not text: raise ActionError("电脑剪贴板是空的")''', "★ 朗读剪贴板读本机剪贴板"), # ================= 8. --port 越界 ================= (UI, ''' try: self.server.start() except OSError as exc: self.lbl_state.configure(text="端口被占用", fg=DANGER) self.log(f"启动失败:{exc}") self.show_message(f"端口 {self.server.port} 被占用,无法启动")''', ''' try: self.server.start() except (OSError, OverflowError, ValueError) as exc: # 端口写成 99999 时 socket.bind 抛的是 OverflowError,不是 OSError, # 只 catch OSError 会让进程带着 traceback 裸退,用户什么都看不到。 self.lbl_state.configure(text="启动失败", fg=DANGER) self.log(f"启动失败:{exc}") self.show_message(f"无法监听端口 {self.server.port}:{exc}")''', "★ 端口越界不再裸退"), # ================= 9. 两个布尔参数也用真值判断 ================= (SERVER, ' cancel = bool(msg.get("cancel"))', ' cancel = _truthy(msg.get("cancel"))', "取消关机用真值判断"), (SERVER, ' note = self.actions.monitor_power(bool(msg.get("on")))', ' note = self.actions.monitor_power(_truthy(msg.get("on")))', "显示器开关用真值判断"), # ================= 10. discovery 版本号写死 ================= (DISCOVERY, 'version: str = "1.1.0"', 'version: str = ""', "发现应答的版本号不再写死"), ] # server.py 需要 _truthy 辅助 TRUTHY_HELPER = ''' def _truthy(value) -> bool: """字符串 "false"/"0"/"off" 不能被当成真(bool("false") 是 True)。""" if isinstance(value, bool): return value if isinstance(value, (int, float)): return value != 0 if isinstance(value, str): return value.strip().lower() in ("1", "true", "on", "yes", "是", "开") return bool(value) ''' def main() -> int: applied, skipped = 0, [] for path, old, new, tag in FIXES: if not path.is_file(): skipped.append(tag) print(f" [跳过] {tag} —— 文件不存在") continue text = path.read_text(encoding="utf-8") if old not in text: skipped.append(tag) print(f" [跳过] {tag} —— 锚点没找到") continue path.write_text(text.replace(old, new, 1), encoding="utf-8") applied += 1 print(f" {tag}: OK") # ---- server.py 加 _truthy ---- stext = SERVER.read_text(encoding="utf-8") if "_truthy" in stext and "def _truthy" not in stext: anchor = "\nclass Session:" if anchor in stext: stext = stext.replace(anchor, TRUTHY_HELPER + "\nclass Session:", 1) else: stext = stext.replace("\nclass RemoteServer:", TRUTHY_HELPER + "\nclass RemoteServer:", 1) SERVER.write_text(stext, encoding="utf-8") print(" server.py / _truthy 辅助: OK") # ---- server.py 需要 secrets ---- stext = SERVER.read_text(encoding="utf-8") if "compare_digest" in stext and "import secrets" not in stext: if "import socket" in stext: stext = stext.replace("import socket", "import secrets\nimport socket", 1) SERVER.write_text(stext, encoding="utf-8") print(" server.py / import secrets: OK") # ---- discovery.py 版本号默认值改成读 __version__ ---- dtext = DISCOVERY.read_text(encoding="utf-8") if 'version: str = ""' in dtext: dtext = dtext.replace( 'version: str = ""', 'version: str = "" # 空=用 __init__ 里的 __version__', 1) if "from . import __version__" not in dtext: dtext = dtext.replace( "import socket", "import socket\n\nfrom . import __version__ as _APP_VERSION", 1) dtext = dtext.replace( 'self.version = version or "1.1.0"', 'self.version = version or _APP_VERSION', 1) DISCOVERY.write_text(dtext, encoding="utf-8") print(" discovery.py / 版本号: OK") print(f"\n 应用 {applied} / {len(FIXES)}") if skipped: print(f" 跳过: {', '.join(skipped)}") import ast ok = True for label, path in (("ui.py", UI), ("server.py", SERVER), ("actions.py", ACTIONS), ("protocol.py", PROTOCOL), ("config.py", CONFIG), ("discovery.py", DISCOVERY)): try: ast.parse(path.read_text(encoding="utf-8")) print(f" {label}: 语法 OK") except SyntaxError as exc: print(f" {label}: 语法错误 {exc}") ok = False return 0 if ok else 1 if __name__ == "__main__": sys.exit(main())