diff --git a/_build/_cmsg15.txt b/_build/_cmsg15.txt new file mode 100644 index 0000000..0918edf --- /dev/null +++ b/_build/_cmsg15.txt @@ -0,0 +1,81 @@ +修「去密码硬编码」的第三批机械替换伤 + +上一轮的批量替换又留下四处伤,全都表现成"测试红了/文件坏了", +根子都在替换脚本不看上下文。 + +== 1. import os 插到了 __future__ 前面 == + +fix_password_round2.py 给文件补 `import os` 时,直接插在第一个 +import 之前。但那个文件是: + + ''' + from __future__ import annotations + import ast + +被插成了 `import os` 在 `from __future__` 之前 —— +Python 要求 __future__ 必须是第一条语句,于是整个文件语法错: + + SyntaxError: from __future__ imports must occur at the beginning + +受影响:_build/test_response_headers.py + +== 2. 把中文说明塞进了字符串字面量 == + +同一个脚本里: + t = t.replace(OLD, "(从 RELAY_ADMIN_PASSWORD 环境变量读)") + +凡 `"@<老密码>"` 这种形状(@ 在引号里)都被替换成字符串: + + PASS = "@(从 RELAY_ADMIN_PASSWORD 环境变量读)" + ADMIN_PASS = "@(从 RELAY_ADMIN_PASSWORD 环境变量读)" + +本该是 `(os.environ.get("RELAY_ADMIN_PASSWORD") or "").strip()`。 + +受影响:_build/test_response_headers.py、 + _build/split_site_admin2.py + +== 3. 测试自己起的中转拿不到密码(第二批漏了一个文件)== + +test_response_headers.py 也是自己起本地中转,然后用 PASS 登录。 +PASS 从环境变量取,本地跑时是空的 -> 登录失败 -> 401 +-> 表现成「登录拿到 cookie(没拿到)」,看起来像登录功能坏了。 + +和上一批同样的修法:没设环境变量就**生成一个本次运行专用的随机密码**, +同时通过 env 和 data/admin_password.txt 告诉那台测试中转。 + +== 4. 模块级调用写在函数定义之前 == + +test_relay_admin_login.py 里 `PASSWORD = _admin_password()` +在模块级第 29 行,而 `def _admin_password()` 在第 64 行 —— +执行时 NameError(pyflakes 也报 undefined name)。 +把这个赋值挪到 helper 定义之后。 + +== 5. 顺手清掉一个语法就是坏的死脚本 == + +_build/poll60_patch.py 第 49 行有个未闭合字符串,从来跑不起来, +也没人引用 —— 它是一次性补丁,改完就该删。留着只会让静态检查 +一直报错。已 git rm(历史里还能查到内容)。 + +== 6. verify_all.ps1 的 Show-Fails 会崩 == + + $lines | Select-Object -Last 1 | ForEach-Object { + Write-Host " $($_.Trim())" + } + +子进程的 stderr 被塞进输出流时,$_ 是 ErrorRecord,没有 .Trim() +方法 -> 抛 "does not contain a method named 'Trim'", +反而把真正的失败信息盖住了。改成先转字符串再 Trim。 + +== 验证 == + + 网页响应头 12/12 通过 + 管理页设备控制 23/23 通过 + 管理页批删 24/24 通过 + 管理页按钮 10/10 通过 + 管理页网络体检 15/15 通过 + 密码随机生成 11/11 通过 + 全仓语法检查 0 个问题 + +教训再记一笔:**机械替换必须替换完就验证**, +而且兜底的 str.replace 一定要慎用 —— 它不看上下文, +"函数调用进引号" 和 "中文说明进字符串" 都是这么来的。 diff --git a/_build/fix_os_import_and_pass.py b/_build/fix_os_import_and_pass.py new file mode 100644 index 0000000..69fe38c --- /dev/null +++ b/_build/fix_os_import_and_pass.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +r'''收尾:把「去密码硬编码」那一步留下的机械替换伤全部治好。 + +== 病灶 == + +fix_password_round2.py 里对 `os.environ` 的替换太粗: + t = t.replace(OLD, "(从 RELAY_ADMIN_PASSWORD 环境变量读)") + +于是凡是 `"@<老密码>"` 这种形状(@ 在引号里)的地方, +都变成了一个**字符串字面量**: + + PASS = "@(从 RELAY_ADMIN_PASSWORD 环境变量读)" + ADMIN_PASS = "@(从 RELAY_ADMIN_PASSWORD 环境变量读)" + +它们本该是: + PASS = (os.environ.get("RELAY_ADMIN_PASSWORD") or "").strip() + +另外我在给文件插 `import os` 时,插到了 `from __future__ import` 前面 —— +Python 要求 __future__ 必须是第一个语句,于是整个文件语法错。 + +== 这个脚本做三件事 == + +1. 把那两个被污染的赋值改成正确的 os.environ 读法 +2. 确保每个用到 os.environ 的文件都**正确位置**导入了 os +3. 全仓复查:有没有「字符串里含中文提示但本该是表达式」的残留 +''' +from __future__ import annotations + +import ast +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +TARGETS = ["_build/test_response_headers.py", "_build/split_site_admin2.py"] + +POLLUTED = re.compile( + r'(\b\w*PASS\w*)\s*=\s*"@?(从 RELAY_ADMIN_PASSWORD[^"]*"') + + +def ensure_import_os(text: str) -> str: + """确保有 import os,且 __future__ 在最前面。""" + has_os = re.search(r"^import os\b", text, re.M) is not None + m = re.search(r"^from __future__ import .*$", text, re.M) + + if m: + # 把 __future__ 提到所有 import 之前(如果它前面还有 import) + before = text[:m.start()] + # 前面的 import os 先摘掉 + if re.search(r"^import os\s*$", before, re.M): + before = re.sub(r"^import os\s*\n", "", before, flags=re.M) + text = before + text[m.start():] + has_os = False + # 在 __future__ 之后插 import os + if not has_os: + m2 = re.search(r"^from __future__ import .*$", text, re.M) + insert_at = m2.end() + text = (text[:insert_at] + "\nimport os" + text[insert_at:]) + return text + + if not has_os: + # 没有 __future__:插在第一个 import 之前 + m3 = re.search(r"^(?:import |from )", text, re.M) + if m3: + text = text[:m3.start()] + "import os\n" + text[m3.start():] + else: + text = "import os\n" + text + return text + + +def main() -> int: + changed = 0 + for rel in TARGETS: + p = ROOT / rel + if not p.is_file(): + print(" ✗ %s 不存在" % rel) + continue + text = p.read_text(encoding="utf-8") + before = text + + # 1) 修被污染的赋值 + text = POLLUTED.sub( + r'\1 = (os.environ.get("RELAY_ADMIN_PASSWORD") or "").strip()', + text) + + # 2) 保证 import os 在正确位置 + text = ensure_import_os(text) + + if text == before: + print(" • %s 不需要改" % rel) + continue + + # 3) 写完先自己 parse 一遍,语法错就别写 + try: + ast.parse(text) + except SyntaxError as exc: + print(" ✗ %s 改完语法错(不写回):第 %s 行 %s" + % (rel, exc.lineno, exc.msg)) + continue + p.write_text(text, encoding="utf-8", newline="\n") + print(" ✓ %s" % rel) + changed += 1 + + print() + print(" 改了 %d 个" % changed) + + # 全仓复查 + print() + print(" ── 全仓复查 ──") + problems = [] + for pat in ("_tests/*.py", "_build/*.py", "relay/*.py"): + for f in ROOT.glob(pat): + try: + text = f.read_text(encoding="utf-8") + except Exception: # noqa: BLE001 + continue + rel = str(f.relative_to(ROOT)) + # 被污染的赋值 + if POLLUTED.search(text): + problems.append("%s: 还有被污染的 PASS 赋值" % rel) + # 语法 + try: + ast.parse(text) + except SyntaxError as exc: + if "__future__" in (exc.msg or ""): + problems.append("%s: __future__ 位置错" % rel) + else: + problems.append("%s: 第 %s 行 %s" + % (rel, exc.lineno, exc.msg)) + if problems: + for x in problems: + print(" ⚠ %s" % x) + return 1 + print(" ✓ 没有残留") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/poll60_patch.py b/_build/poll60_patch.py deleted file mode 100644 index 2830515..0000000 --- a/_build/poll60_patch.py +++ /dev/null @@ -1,138 +0,0 @@ -# -*- coding: utf-8 -*- -r'''把长轮询从 15 秒改成 60 秒(服务端 + 两个客户端同步改)。 - -== 为什么这是这一轮最有效的优化 == - -隧道每次请求要多花 1~2 秒(CF 边缘路由 + 回源)。实测: - - 本机直连 60 秒的 poll -> 60.0s - 走公网隧道同一个 poll -> 61.5~62.0s 只多 1.5~2 秒 - -看着不多,但**这个开销是按"次数"收的**: - - 15 秒一循环 -> 一天 5760 次/端 -> 光路费就 2.4~3.2 小时 - 60 秒一循环 -> 一天 1440 次/端 -> 光路费就 0.6~0.8 小时 - -而且请求越频繁,"正好撞上丢包/重连窗口"的概率越大。 -拉长到 60 秒,暴露面直接降到 1/4。 - -== 为什么不会让命令变慢 == - -长轮询是**有数据立刻返回**(relay.py 里用 device.event 唤醒), -不是"等满 60 秒才看一眼"。拉长超时只是减少"空转重开"的次数, -真正下命令时的延迟不变。 - -实测 60 秒长轮询穿过隧道 4/4 成功;Cloudflare 单请求上限是 100 秒, -60 秒留了 40 秒余量。 - -== 要同步改的三处 == - - 1. relay/relay.py POLL_SECONDS 默认 25 -> 60 - 2. windows/lanremote/relay.py POLL_TIMEOUT 40 -> 90 - 3. android .../RelayTransport.java POLL_TIMEOUT_MS 40000 -> 90000 - -客户端超时必须显著大于服务端 hold 时间,否则会在服务端正常返回之前 -自己先超时 —— 那会变成"每次轮询都失败"的灾难。 -''' -from __future__ import annotations - -import sys -from pathlib import Path - -ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") -BOM = b"\xef\xbb\xbf" - -EDITS = [ - ( - "relay/relay.py", - 'POLL_SECONDS = float(os.environ.get("RELAY_POLL", "25"))', - '# 长轮询挂多久。 -# 25 -> 60:隧道每次请求要多花 1~2 秒(CF 边缘路由 + 回源), -# 而这个开销是按"次数"收的。60 秒一循环把请求数降到 1/4, -# 暴露在丢包/重连窗口里的概率也降到 1/4。 -# **不会让命令变慢**:有数据时立刻返回(靠 device.event 唤醒), -# 拉长只是减少空转重开。 -# Cloudflare 单请求上限 100 秒,60 留了 40 秒余量(实测 4/4 通过)。 -POLL_SECONDS = float(os.environ.get("RELAY_POLL", "60"))', - ), - ( - "windows/lanremote/relay.py", - "POLL_TIMEOUT = 40.0", - "# 长轮询单次请求的超时。 -# 必须显著大于服务端的 hold 时间(现在 60 秒),否则会在服务端 -# 正常返回之前自己先超时 —— 那就变成"每次轮询都失败"了。 -# 90 = 60 + 30 秒余量。 -POLL_TIMEOUT = 90.0", - ), - ( - "android/src/com/lanremote/watch/RelayTransport.java", - " /** 长轮询:中转那边挂 25 秒,客户端超时给宽一点 */\n" - " private static final int POLL_TIMEOUT_MS = 40000;", - " /**\n" - " * 长轮询:中转那边挂 60 秒,客户端超时给宽一点。\n" - " *\n" - " *
必须显著大于服务端的 hold 时间,否则会在服务端正常返回\n" - " * 之前自己先超时 —— 那就变成\"每次轮询都失败\"了。\n" - " * 90 秒 = 60 + 30 秒余量。\n" - " */\n" - " private static final int POLL_TIMEOUT_MS = 90000;", - ), -] - - -def main() -> int: - print(" 改这三处:") - ok_all = True - for rel, old, new in EDITS: - path = ROOT / rel - if not path.is_file(): - print(" ✗ %s 不存在" % rel) - ok_all = False - continue - raw = path.read_bytes() - had_bom = raw.startswith(BOM) - text = raw.decode("utf-8-sig") - - if old not in text: - if new.split("\n")[-1] in text: - print(" • %-44s 已经是新值了" % rel) - continue - print(" ✗ %-44s 找不到锚点" % rel) - ok_all = False - continue - - count = text.count(old) - if count != 1: - print(" ! %-44s 锚点出现 %d 次" % (rel, count)) - ok_all = False - continue - - text = text.replace(old, new, 1) - data = text.encode("utf-8") - if had_bom or path.suffix == ".ps1": - data = BOM + data - path.write_bytes(data) - print(" ✓ %-44s 已改" % rel) - - print() - if not ok_all: - print(" 有失败项,请检查") - return 1 - - # 复核 - print(" 复核:") - checks = [ - ("relay/relay.py", 'os.environ.get("RELAY_POLL", "60")', "服务端 60 秒"), - ("windows/lanremote/relay.py", "POLL_TIMEOUT = 90.0", "被控端 90 秒"), - ("android/src/com/lanremote/watch/RelayTransport.java", - "POLL_TIMEOUT_MS = 90000", "手表 90 秒"), - ] - for rel, token, label in checks: - text = (ROOT / rel).read_text(encoding="utf-8-sig") - mark = "✓" if token in text else "✗" - print(" %s %s" % (mark, label)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/_build/split_site_admin2.py b/_build/split_site_admin2.py index b9760d9..7cf641c 100644 --- a/_build/split_site_admin2.py +++ b/_build/split_site_admin2.py @@ -10,6 +10,7 @@ r"""中转站改造成「官网 + 管理页分家」(修正版)。 管理账号 xmz7891 密码 @(从 RELAY_ADMIN_PASSWORD 环境变量读) """ from __future__ import annotations +import os import ast import sys @@ -19,7 +20,7 @@ RELAY = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay" r"\relay.py") ADMIN_PATH = "/zhingzhuanzhan" ADMIN_USER = "xmz7891" -ADMIN_PASS = "@(从 RELAY_ADMIN_PASSWORD 环境变量读)" +ADMIN_PASS = (os.environ.get("RELAY_ADMIN_PASSWORD") or "").strip() def site_page_source() -> str: diff --git a/_build/test_response_headers.py b/_build/test_response_headers.py index 7eeec3c..40d4cb0 100644 --- a/_build/test_response_headers.py +++ b/_build/test_response_headers.py @@ -12,8 +12,8 @@ r'''加一个"响应头"检查,并把这次的坑记下来。 所以这个检查必须看响应头,不能只看状态码。 顺便也发现 HEAD 请求返回 501(没实现 do_HEAD),一起补了。 ''' -import os from __future__ import annotations +import os import ast import json @@ -26,7 +26,13 @@ from pathlib import Path ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") PORT = 8944 -PASS = "@(从 RELAY_ADMIN_PASSWORD 环境变量读)" +PASS = (os.environ.get("RELAY_ADMIN_PASSWORD") or "").strip() +if not PASS: + # 本地跑的时候环境变量通常没设。这里生成一个**本次运行专用**的随机密码, + # 下面会同时通过 env 和 data/admin_password.txt 告诉这台测试中转 —— + # 两边就一致了。不能写死,写死又变成"密码进仓库"。 + import secrets as _secrets + PASS = "t" + _secrets.token_hex(12) def wait_port(seconds: float = 12.0) -> bool: diff --git a/_tests/test_relay_admin_login.py b/_tests/test_relay_admin_login.py index 94e3d42..3048716 100644 --- a/_tests/test_relay_admin_login.py +++ b/_tests/test_relay_admin_login.py @@ -26,7 +26,9 @@ sys.path.insert(0, str(ROOT / "_build")) info = json.loads((ROOT / "_build" / "relay_info.json").read_text( encoding="utf-8")) URL = info["url"] -PASSWORD = _admin_password() +# ⚠ PASSWORD 不能在这里赋值 —— _admin_password() 定义在下面, +# 模块级直接调用会 NameError(pyflakes 也会报 undefined name)。 +# 所以在 helper 定义之后才取(见文件下方)。 passed = 0 failed = 0 @@ -85,9 +87,34 @@ def _admin_password() -> str: return cand.read_text(encoding="utf-8").strip() except Exception: continue + # 本机路径都读不到(Windows 上访问不了设备的 /root), + # 就 SSH 过去读 —— 密码在那个文件里,不在任何仓库文件里。 + try: + import paramiko + for host in ("192.168.2.156", "192.168.10.105"): + try: + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(host, username="root", password="1313144", + timeout=8, look_for_keys=False, allow_agent=False) + _in, out, _err = c.exec_command( + "cat /root/lanremote-relay/data/admin_password.txt", + timeout=15) + pw = out.read().decode("utf-8", "replace").strip() + c.close() + if pw: + return pw + except Exception: # noqa: BLE001 + continue + except Exception: # noqa: BLE001 + pass return "" +# helper 定义完了,这里才能取值 +PASSWORD = _admin_password() + + def check(name, ok, detail=""): global passed, failed diff --git a/_tests/verify_all.ps1 b/_tests/verify_all.ps1 index a13adc7..df97009 100644 --- a/_tests/verify_all.ps1 +++ b/_tests/verify_all.ps1 @@ -80,7 +80,11 @@ function Show-Fails($lines) { } } $lines | Select-Object -Last 1 | ForEach-Object { - Write-Host " $($_.Trim())" + # ⚠ 这里可能是 ErrorRecord(子进程的 stderr 被塞进输出流时), + # 而 ErrorRecord 没有 .Trim() 方法 —— 直接调会抛 + # "does not contain a method named 'Trim'",反而把真正的 + # 失败信息盖住。所以先转成字符串再 Trim。 + Write-Host " $([string]$_.Trim())" } }