#!/usr/bin/env python3 # -*- coding: utf-8 -*- """test_lan_probe.py — 设备发现的离线自测(不需要真板子,可 CI 跑)。 覆盖三件事,缺一不可: 1) 判据本身有灵敏度:同一套代码必须把"板子的 lite 响应"和"路由器的 HTML/ 缺字段 JSON"分开,也要把"旧固件(无 gd32_features)"收下而不是当反证扔掉。 2) 真 socket 路径走真代码:假板服务绑在**本机 WLAN 网卡地址的高端口**上 (不是 127.0.0.1 —— 用回环测不到 selectors/截止/refused 的真实语义)。 3) 不自证:起/关假板服务,同一请求的判决必须翻转;种子故意指错地址, 真在那儿的假板仍要被找到。 用法: python scripts/test_lan_probe.py 退出码 0=PASS 1=FAIL """ import io import json import os import re import socket import sys import threading import urllib.request from http.server import BaseHTTPRequestHandler, HTTPServer ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, ROOT) import lan_probe as lp # noqa: E402 FAILS = [] PASSES = [] def check(name, cond, detail=""): if cond: PASSES.append(name) print(" PASS " + name) else: FAILS.append(name) print(" FAIL " + name + (" :: " + str(detail) if detail else "")) def lite_body(mode="STA", ip="192.168.31.127", connected=True, with_feat=True): b = {"wifi": {"mode": mode, "ip": ip, "connected": connected}} if with_feat: b["gd32_features"] = {"known": True, "flags": 15, "can_ch0": True, "can_ch1": True, "can_ch2": True, "can_ch_mask": 7} return json.dumps(b) # ───────────────────────── 1) 判决表(含反向对照) ───────────────────────── def test_classify(): print("[1] classify 判决表") ip = "192.168.31.127" r = lp.classify(ip, 200, "application/json", lite_body(ip=ip)) check("正对照:真 lite 响应 → board/full", r["verdict"] == "board" and r["schema"] == "full" and r["kind"] == "sta", r) # 反向对照 1:路由器登录页 r = lp.classify("192.168.31.1", 200, "text/html; charset=utf-8", "
miwifi
", "text/html") closed = _free_port(ip) # 实测被拒的端口 check("拿到一个确实没人监听的端口", closed is not None, closed) if closed is None: return httpd, _p = __import__("esp32_server")._bind_server(0) port = httpd.server_address[1] threading.Thread(target=httpd.serve_forever, daemon=True).start() def call(body, timeout=20): req = urllib.request.Request( "http://127.0.0.1:%d/api/lan/probe" % port, data=json.dumps(body).encode("utf-8"), headers={"Content-Type": "application/json"}) return json.loads(urllib.request.urlopen(req, timeout=timeout).read().decode("utf-8")) r = call({"ips": [ip], "ports": [bp, rp, closed], "local": True, "timeout_ms": 900}) by = {(x["ip"], x["port"]): x for x in r["results"]} check("假板(真 socket,绑本机网卡)→ board", by[(ip, bp)]["verdict"] == "board", by[(ip, bp)]) check("假路由器 → rejected/not-json", by[(ip, rp)]["verdict"] == "rejected" and by[(ip, rp)]["reason"] == "not-json", by[(ip, rp)]) check("没人监听的端口 → unknown(不是 rejected:没有反证,只是没答)", by[(ip, closed)]["verdict"] == "unknown", by[(ip, closed)]) check("tcpOpen 不把 refused 算成开着", r["scan"]["tcpOpen"] == 2, r["scan"]) check("local 段随响应返回", bool(r.get("local", {}).get("cidr")), r.get("local")) # 灵敏度对照:关掉假板,同一请求的判决必须翻 board.shutdown() r2 = call({"ips": [ip], "ports": [bp], "timeout_ms": 600}) check("灵敏度对照:假板下电后 board → unknown", r2["results"][0]["verdict"] != "board", r2["results"][0]) # 配置自证对照:种子故意指一个错地址,真在那儿的假路由器地址仍要如实分类 r3 = call({"ips": [ip], "ports": [rp], "timeout_ms": 600}) check("种子指错也不会把非板子说成板子", r3["results"][0]["verdict"] == "rejected", r3["results"][0]) # 非法 IP / 路径白名单 r4 = call({"ips": ["bogus", "1.2.3.4.5"], "ports": [80]}) check("非法 IP 被剔且带原因", r4["scan"]["candidates"] == 0 and len(r4["scan"]["skipped"]) == 2, r4["scan"]) r5 = call({"ips": [ip], "ports": [rp], "path": "/logs"}) check("危险路径不进白名单(/logs 会排空固件日志环)", "/system/status?lite=1" == r5["path"] and any( "path-not-allowed" in e for e in r5["errors"]), r5) # 连续 10 批不泄漏状态:每批结果数必须齐(3 个相同 IP 去重 × 2 个端口 = 2 条) ok, detail = True, "" for _ in range(10): rr = call({"ips": [ip] * 3, "ports": [rp, closed], "timeout_ms": 400}) if len(rr["results"]) != 2: ok = False detail = "期望 2 条,实际 %d 条" % len(rr["results"]) break check("连续多批结果数稳定(批间无残留状态、去重按 ip:port)", ok, detail) httpd.shutdown() router.shutdown() # ───────────────────────── 4) 结构回归 ───────────────────────── def test_structure(): print("[4] 结构回归(把已经踩过的坑钉住)") src = io.open(os.path.join(ROOT, "lan_probe.py"), encoding="utf-8").read() check("lan_probe.py 不写 stdout/stderr(--window 下子进程 stderr 是父进程管道)", "print(" not in src and "sys.stderr" not in src) m = re.search(r"^ALLOWED_PATHS = \((.*?)\)", src, re.M) allowed = m.group(1) if m else "" check("探测白名单只有安全路径(/logs 会排空固件日志环,/system/probe 会发真实 SPI 事务)", m is not None and "/logs" not in allowed and "/system/probe" not in allowed and "PROBE_PATH" in allowed and '"/system/status"' in allowed, allowed) srv = io.open(os.path.join(ROOT, "esp32_server.py"), encoding="utf-8").read() check("路由挂在 do_POST(GET 会被无扩展名当 HTML 的分支吃掉)", srv.count('"/api/lan/probe"') >= 1 and "_handle_lan_probe" in srv) check("QuietServer.handle_error 仍静默(异常灌进父进程 4KB 管道会冻住服务)", re.search(r"def handle_error\(self[^)]*\):\s*\n\s+pass", srv) is not None) js = {} for rel in ["js/common/base-url.js", "js/common/device-link.js", "js/can.js", "js/can/dbc.js", "js/common/lan-picker.js", "js/app.js"]: js[rel] = io.open(os.path.join(ROOT, rel), encoding="utf-8").read() writers = [k for k, v in js.items() if "saveDeviceIp(" in v.replace("function saveDeviceIp(", "")] check("写端点的只有 device-link(+ base-url 自身实现)", set(writers) <= {"js/common/base-url.js", "js/common/device-link.js"}, writers) check("can.js 不再把设备自报 IP 回写成端点(0.0.0.0 污染源)", "setDeviceIp" not in js["js/can.js"] and "setDeviceIp" not in js["js/can/dbc.js"]) check("can.js 改为只提示不符", "与当前端点" in js["js/can.js"]) check("device-link 里端点唯一写手 + 刷写门", "selectDevice: function" in js["js/common/device-link.js"] and "flashBusyNow()" in js["js/common/device-link.js"]) check("自动补扫门齐(failStreak + 刷写 + ECAT 环跑 + 冷却)", "maybeAutoScan" in js["js/common/device-link.js"] and "snap.links.ecat.loop" in js["js/common/device-link.js"]) check("面板不重算判据(只取服务侧 verdict 字段,不自己解析 gd32_features)", re.search(r"gd32_features\s*[\[.]", js["js/common/lan-picker.js"]) is None and ".verdict" in js["js/common/lan-picker.js"]) check("当前端点不进候选表(不重复占设备 httpd 的 socket)", "当前端点" in js["js/common/lan-picker.js"]) def main(): print("== lan_probe 自测 ==") test_classify() test_ip_validation() test_probe_route() test_structure() print("\n合计 PASS=%d FAIL=%d" % (len(PASSES), len(FAILS))) if FAILS: print("FAILED: " + "; ".join(FAILS)) return 1 print("ALL PASS") return 0 if __name__ == "__main__": sys.exit(main())