网络体检
看看这台机器是走局域网还是走隧道。
还没测过。
diff --git a/_build/check_headers.sh b/_build/check_headers.sh
new file mode 100644
index 0000000..9326ee5
--- /dev/null
+++ b/_build/check_headers.sh
@@ -0,0 +1,43 @@
+#!/bin/bash
+# 验证响应头 —— 上次改完只看了状态码和字节数,没看 Content-Type,
+# 结果页面在浏览器里是"一屏源码"。
+set -u
+
+BASE=http://127.0.0.1:8080
+PASS=$(cat /root/lanremote-relay/data/admin_password.txt 2>/dev/null)
+
+curl -s -c /tmp/ck.txt -o /dev/null --max-time 15 \
+ -d "username=xmz7891&password=$PASS" "$BASE/admin/login"
+
+echo " 管理页响应头:"
+curl -sI -b /tmp/ck.txt --max-time 15 "$BASE/zhongzhuanzhan" \
+ | head -4 | sed 's/^/ /'
+
+echo
+echo " 官网响应头:"
+curl -sI --max-time 15 "$BASE/" | head -3 | sed 's/^/ /'
+
+echo
+echo " 产品截图响应头:"
+curl -sI --max-time 15 "$BASE/shot/pc-main.png" | head -3 | sed 's/^/ /'
+
+echo
+echo " 登录页响应头:"
+curl -sI --max-time 15 "$BASE/zhongzhuanzhan" | head -3 | sed 's/^/ /'
+
+echo
+echo " 判断:"
+admin_ct=$(curl -sI -b /tmp/ck.txt --max-time 15 "$BASE/zhongzhuanzhan" \
+ | grep -i '^content-type' | tr -d '\r')
+if echo "$admin_ct" | grep -qi 'text/html'; then
+ echo " ✓ 管理页是 text/html"
+else
+ echo " ✗ 管理页 Content-Type 不对:$admin_ct"
+fi
+
+site_ct=$(curl -sI --max-time 15 "$BASE/" | grep -i '^content-type' | tr -d '\r')
+if echo "$site_ct" | grep -qi 'text/html'; then
+ echo " ✓ 官网是 text/html"
+else
+ echo " ✗ 官网 Content-Type 不对:$site_ct"
+fi
diff --git a/_build/test_response_headers.py b/_build/test_response_headers.py
new file mode 100644
index 0000000..4ef5273
--- /dev/null
+++ b/_build/test_response_headers.py
@@ -0,0 +1,178 @@
+# -*- 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(" 三、其它资源的类型")
+ 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())
diff --git a/_tests/shots/1-屏幕中间.png b/_tests/shots/1-屏幕中间.png
index 04bcb0b..4d89526 100644
Binary files a/_tests/shots/1-屏幕中间.png and b/_tests/shots/1-屏幕中间.png differ
diff --git a/_tests/verify_all.ps1 b/_tests/verify_all.ps1
index 6ab3c8d..2f05bd9 100644
--- a/_tests/verify_all.ps1
+++ b/_tests/verify_all.ps1
@@ -422,6 +422,17 @@ $stageOut = & python (Join-Path $root "_build\test_installer_cycle.py") 2>&1
Show-Fails $stageOut
Record "安装包完整周期" ($LASTEXITCODE -eq 0)
+# ---------------------------------------------------------------- 3b6
+Banner "3b6/7 网页响应头(Content-Type / HEAD)"
+# 踩过的坑:重做管理页时 _send() 没传 content_type,
+# 默认成了 application/json —— 浏览器把整页 HTML 当纯文本显示,
+# 用户看到的是一屏源码。
+# 更糟的是当时没发现:只验了 HTTP 200 和字节数,两个都对。
+# **200 不等于页面能看。**
+$stageOut = & python (Join-Path $root "_build\test_response_headers.py") 2>&1
+ Show-Fails $stageOut
+Record "网页响应头" ($LASTEXITCODE -eq 0)
+
# ---------------------------------------------------------------- 3c
Banner "3c/7 中转管理端批量删除"
$stageOut = & python (Join-Path $root "_tests\test_admin_batch_delete.py") 2>&1
diff --git a/relay/relay.py b/relay/relay.py
index da3bd32..2677e34 100644
--- a/relay/relay.py
+++ b/relay/relay.py
@@ -1026,6 +1026,52 @@ class Handler(BaseHTTPRequestHandler):
# ---------------- 路由 ----------------
+ def do_HEAD(self): # noqa: N802
+ """HEAD 请求只回头,不回体。
+
+ 之前没实现,HEAD 一律 501 Unsupported method。
+ 浏览器用 GET 所以页面照开,但:
+ * Cloudflare 的健康检查、很多监控/代理探测会用 HEAD
+ * curl -I 也是 HEAD,排查时看不到响应头很别扭
+ 这里按 GET 算好状态和长度,把体丢掉就行。
+ """
+ RELAY.total_requests += 1
+ path = self.path.split("?")[0]
+ try:
+ body, ctype = self._head_info(path)
+ except Exception: # noqa: BLE001
+ body, ctype = b"", "text/plain; charset=utf-8"
+ self.send_response(200 if body is not None else 404)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body or b"")))
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+
+ def _head_info(self, path: str) -> tuple:
+ """给 HEAD 用:算这个路径的响应类型和长度(不真的发体)。"""
+ if path == "/":
+ return _site_page().encode("utf-8"), "text/html; charset=utf-8"
+ if path == "/v1/health":
+ return b"{}", "application/json"
+ if path in ("/url", "/v1/url"):
+ return (public_url() or "").encode("utf-8"), \
+ "text/plain; charset=utf-8"
+ if path == "/favicon.ico":
+ icon = Path(__file__).resolve().parent / "favicon.ico"
+ return (icon.read_bytes() if icon.is_file() else b""), \
+ "image/x-icon"
+ if path == ADMIN_PATH:
+ return b"", "text/html; charset=utf-8"
+ if path.startswith("/shot/"):
+ name = path[len("/shot/"):]
+ if ".." in name or "/" in name or "\\" in name:
+ return b"", "text/plain; charset=utf-8"
+ target = Path(__file__).resolve().parent / "site_shots" / name
+ if target.is_file() and name.lower().endswith(
+ (".png", ".jpg", ".jpeg", ".webp")):
+ return target.read_bytes(), "image/png"
+ return b"", "text/plain; charset=utf-8"
+
def do_GET(self): # noqa: N802
RELAY.total_requests += 1
path = self.path.split("?")[0]
@@ -1510,6 +1556,12 @@ class Handler(BaseHTTPRequestHandler):
实际渲染在 relay/admin.py —— 原来几百行 HTML/CSS/JS
内联在这里的 f-string 里,改一处样式要在一堆转义花括号里翻,
没法维护。拆出去之后这里只负责把数据递过去。
+
+ ⚠ Content-Type 必须显式写 text/html。
+ _send() 默认是 application/json;不传的话浏览器会把整页
+ HTML 当纯文本显示出来 —— 用户看到的是一屏源码。
+ (这个坑真踩了:改完只测了 HTTP 200 和字节数,
+ 没看响应头,结果页面在浏览器里是白的。)
"""
from admin import render_admin
snap = RELAY.snapshot()
@@ -1519,7 +1571,7 @@ class Handler(BaseHTTPRequestHandler):
admin_path=ADMIN_PATH,
codes=len(RELAY.codes),
downloads_fn=_list_downloads,
- ).encode("utf-8"))
+ ).encode("utf-8"), "text/html; charset=utf-8")
diff --git a/relay/site_shots/pc-main.png b/relay/site_shots/pc-main.png
new file mode 100644
index 0000000..9e487e6
Binary files /dev/null and b/relay/site_shots/pc-main.png differ