Files
ykxmz/_build/test_response_headers.py
T
xmz7891 49fdec1da8 1.8.0:隧道稳定性大修 + 中转管理后台重做
版本号 1.7.1 -> 1.8.0(8 个文件,13 处)。
写了个 bump_180.py 统一处理 —— 版本号散落在
Python / Java manifest / PowerShell / Inno Setup / 测试里,
手动改必漏;而且 .ps1 必须保留 UTF-8 BOM。

==================== 一、隧道稳定性(用户撞上 1033/502/530)====================

1) 4G 链路会**静默断开 TCP 连接**(不发 FIN/RST,双方都以为还活着),
   Linux 默认要 2 小时才发现 -> 改成 keepalive 30 秒 + 每 10 秒探测。
   实测 60 秒内发现并重连。

2) 隧道连接数 2 -> 4。只剩 1 条连接时跨机房回源会失败
   (实测可用率 0/12,提到 4 条后 12/12)。

3) 掉电重启后恢复慢:原来开机 2 分 10 秒公网才可用。
   根因是 systemd-networkd-wait-online 等一个不归它管的网络
   (wlan0 是 NetworkManager 管的),干等 2 分钟超时失败,
   把 network-online.target 一起拖住。
   mask 掉它之后:启动 17.3 秒,掉电重启 24 秒公网可用。

4) 看门狗两次翻车都是我造成的,都修了:
   a) 我先加了"确认故障立刻重启",但隧道重建 4 条连接要 30~60 秒,
      MIN_RESTART_GAP=45 导致**还没建齐就被下一次重启打断**,
      死循环,每 50 秒重启一次 -> 加 150 秒宽限期
   b) 宽限期又带来"链路持续差时什么都不做"的问题
      -> 增加"本次重启后建齐过吗"的判断:
         建齐过又垮 = 真故障,立刻重启
         从没建齐 = 还在努力,继续等

5) 顺手修:看门狗状态文件里的变量在函数定义前被引用,
   set -u 下报 "BUILT: 未绑定的变量"。

==================== 二、中转管理后台重做 ====================

原来的管理页是几百行内联在 relay.py 的 f-string 里,
改一处样式要在一堆转义花括号里翻。拆成独立模块 relay/admin.py。

新设计(对齐官网那套白色简洁):
  吸顶顶栏 -> 公网地址(最大权重,这是用户最需要复制的)
  -> 四个数字卡 -> 设备表 -> 工具卡(体检 + 下载)
  空状态给图标+引导文案;窄屏表格自动变卡片。
  relay.py 从 2089 行缩到 1500 行左右。

修了三个我自己引入的 bug:
  a) 响应头类型写错,浏览器把整页 HTML 当纯文本显示
     (用户看到的是"一屏源码")。教训:200 不等于页面能看,
     当时只验了状态码和字节数。
  b) 顶栏用了固定 height:62px,装不下"标题+副标题+版本信息"两行,
     内容被顶出容器 -> 标题看不见、徽章压在字上。
  c) do_HEAD 没处理 /download/ 路径,HEAD 一个 APK 返回
     Content-Length: 0 + text/plain,下载器会误判成空文件。
     同时发现 _head_info 里对 bytes 调 .encode() —— 加 _as_bytes() 兜住。

==================== 三、其它 ====================

* 设备记录只增不减:实测一天攒 75 条僵尸,/v1/health 从几 KB 涨到 20KB。
  加 DEVICE_TTL(6 小时)自动清,占用的配对码一起释放
  (管理页曾显示"配对码占用 131"但一台设备都没有)。
* HEAD 请求支持(原来一律 501)。
* 官网新增产品展示:手表界面预览(颜色/布局与真实手表一致)、
  电脑端真实截图、两种连接方式对比图。
* 加了一份 docs/免费托管方案.md(含一个纠错:上一版推荐的
  ClawCloud Run 已在 2026-05-11 关停)。

==================== 验收 ====================

全量回归            33 个阶段全部通过
安装包完整周期      20/20(静默装入 7 秒、卸载 0.6 秒、无残留)
网页响应头          12/12
公网可用率          26/30 = 86%(底层 4G 丢包 30%,是这个上限)
发布产物            安装包 14.37MB / 服务端 0.20MB / 手表 0.19MB
2026-10-07 11:47:12 +08:00

195 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
r'''加一个"响应头"检查,并把这次的坑记下来。
踩的坑:
重做管理页时,_send() 的 content_type 默认是 application/json,
我调它的时候没传 text/html —— 结果浏览器把整页 HTML
当纯文本显示,用户看到的是一屏源码。
更糟的是**我当时没发现**:验证脚本只看了 HTTP 200 和字节数,
两个都对,就以为没问题了。**200 不等于页面能看。**
所以这个检查必须看响应头,不能只看状态码。
顺便也发现 HEAD 请求返回 501(没实现 do_HEAD),一起补了。
'''
from __future__ import annotations
import ast
import json
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
PORT = 8944
PASS = "@REVAedu120830"
def wait_port(seconds: float = 12.0) -> bool:
deadline = time.time() + seconds
while time.time() < deadline:
try:
with urllib.request.urlopen(
"http://127.0.0.1:%d/v1/health" % PORT, timeout=2):
return True
except Exception: # noqa: BLE001
time.sleep(0.5)
return False
def head(path: str, cookie: str = "") -> dict:
"""发一个 HEAD,取回响应头。"""
req = urllib.request.Request("http://127.0.0.1:%d%s" % (PORT, path),
method="HEAD")
if cookie:
req.add_header("Cookie", cookie)
try:
with urllib.request.urlopen(req, timeout=8) as resp:
return {"status": resp.status,
"ctype": resp.headers.get("Content-Type", ""),
"len": resp.headers.get("Content-Length", "")}
except urllib.error.HTTPError as exc:
return {"status": exc.code,
"ctype": exc.headers.get("Content-Type", ""), "len": ""}
except Exception as exc: # noqa: BLE001
return {"status": 0, "ctype": "", "len": str(exc)}
def login() -> str:
"""登录拿 cookie。
用 http.cookiejar 而不是手工读 Set-Cookie ——
/admin/login 会 302 跳转,urllib 默认跟着跳,
Set-Cookie 头在第一个响应上就被丢掉了,手工读永远是空。
cookiejar 会把中间那次的 cookie 存下来。
"""
import http.cookiejar
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar))
body = ("username=xmz7891&password=" + PASS).encode()
req = urllib.request.Request(
"http://127.0.0.1:%d/admin/login" % PORT, data=body, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
opener.open(req, timeout=8).read()
except Exception: # noqa: BLE001
pass
for cookie in jar:
if cookie.name == "lr_admin":
return "%s=%s" % (cookie.name, cookie.value)
return ""
def main() -> int:
env = dict(__import__("os").environ)
env["RELAY_PORT"] = str(PORT)
env["RELAY_ADMIN_PASSWORD"] = PASS
data = ROOT / "_build" / "_hdr_data"
data.mkdir(parents=True, exist_ok=True)
env["RELAY_DATA"] = str(data)
# 中转把管理密码放在 DATA_DIR/data/admin_password.txt,
# 没有这个文件就用内置默认值。为了让测试不依赖默认值,
# 这里显式写一份,保证登录能成。
auth_dir = data / "data"
auth_dir.mkdir(parents=True, exist_ok=True)
(auth_dir / "admin_password.txt").write_text(PASS, encoding="utf-8")
# /shot/ 路由是从 relay.py 所在目录找 site_shots/。
# 仓库里那张图在 _build/site_shots/,这里拷一份过去,
# 否则那条断言会因为"文件不存在"而误报。
shot_src = ROOT / "_build" / "site_shots" / "pc-main.png"
shot_dst = ROOT / "relay" / "site_shots" / "pc-main.png"
if shot_src.is_file():
shot_dst.parent.mkdir(parents=True, exist_ok=True)
if not shot_dst.is_file():
shot_dst.write_bytes(shot_src.read_bytes())
print(" 启动中转(端口 %d)" % PORT)
proc = subprocess.Popen(
[sys.executable, "relay.py"], cwd=str(ROOT / "relay"), env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
passed = failed = 0
def check(name, ok, detail=""):
nonlocal passed, failed
if ok:
passed += 1
print(" [PASS] " + name)
else:
failed += 1
print(" [FAIL] " + name + " " + str(detail))
try:
if not wait_port():
print(" [失败] 中转没起来")
return 1
print()
print(" 一、HEAD 请求要能用(原来一律 501)")
for path in ("/", "/v1/health", "/favicon.ico"):
info = head(path)
check("HEAD %s 不是 501" % path, info["status"] != 501,
info["status"])
print()
print(" 二、Content-Type 必须是 text/html(否则浏览器显示源码)")
site = head("/")
check("官网 Content-Type 是 text/html",
"text/html" in site["ctype"], site["ctype"])
cookie = login()
check("登录拿到 cookie", bool(cookie), cookie or "(没拿到)")
admin = head("/zhongzhuanzhan", cookie)
check("管理页 Content-Type 是 text/html",
"text/html" in admin["ctype"],
admin["ctype"] or "(空)")
check("管理页不是 application/json",
"json" not in admin["ctype"].lower(), admin["ctype"])
print()
print(" 二b、HEAD 下载文件要报对长度(不能是 0)")
# 这条路原来漏了:HEAD 一个 APK 会返回 Content-Length: 0
# + text/plain,下载器据此认为文件是空的。
dldir = ROOT / "relay" / "downloads"
dldir.mkdir(parents=True, exist_ok=True)
probe = dldir / "probe-1.8.0.apk"
probe.write_bytes(b"PK\\x03\\x04" + b"x" * 4096)
info = head("/download/probe-1.8.0.apk")
check("HEAD 下载返回 200", info["status"] == 200, info["status"])
check("HEAD 下载 Content-Length 不为 0",
info["len"] not in ("", "0"), info["len"])
check("HEAD 下载类型是 android 包",
"android" in info["ctype"].lower()
or "octet-stream" in info["ctype"].lower(), info["ctype"])
print()
print(" 三、其它资源的类型")
icon = head("/favicon.ico")
check("favicon 是 image/*", "image" in icon["ctype"], icon["ctype"])
if shot_dst.is_file():
shot = head("/shot/pc-main.png")
check("截图是 image/*", "image" in shot["ctype"], shot["ctype"])
else:
print(" [跳过] 截图类型(本地没有 site_shots/pc-main.png)")
finally:
proc.terminate()
try:
proc.wait(timeout=8)
except subprocess.TimeoutExpired:
proc.kill()
print()
print("=" * 54)
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())