#!/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") check("反对照1:HTML → rejected/not-json", r["verdict"] == "rejected" and r["reason"] == "not-json", r) # 反向对照 2:形状不对的 JSON r = lp.classify("192.168.31.9", 200, "application/json", '{"error":"unauthorized"}') check("反对照2:无 wifi 对象 → rejected/no-wifi-object", r["verdict"] == "rejected" and r["reason"] == "no-wifi-object", r) # 反向对照 3:STA 自称在别处(端口转发/捕获门户) r = lp.classify(ip, 200, "application/json", lite_body(ip="10.0.0.5")) check("反对照3:STA 自报 IP 不符 → rejected/ip-mismatch", r["verdict"] == "rejected" and r["reason"].startswith("ip-mismatch"), r) # 反向对照 4:HTTP 不是 200 r = lp.classify(ip, 404, "text/plain", "not found") check("反对照4:404 → rejected/http-404", r["verdict"] == "rejected" and r["reason"] == "http-404", r) # 旧固件:有 wifi 无 gd32_features 必须**收下**(标 core-only),不能当反证丢 r = lp.classify(ip, 200, "application/json", lite_body(ip=ip, with_feat=False)) check("旧固件无 gd32_features → board/core-only(不丢弃)", r["verdict"] == "board" and r["schema"] == "core-only", r) # 关键坑:lite 在 NOT(STA&&connected) 时一律报 AP,所以 AP+connected:false # 多半是"STA 掉租约的板子",既不能当在线 AP 板,也不能当反证 → unknown r = lp.classify(ip, 200, "application/json", lite_body(mode="AP", ip="0.0.0.0", connected=False)) check("AP+connected:false → unknown/ap-not-connected", r["verdict"] == "unknown" and r["reason"] == "ap-not-connected", r) # 真 AP 回退(PC 连了板子热点):connected=true 才认 r = lp.classify("192.168.31.1", 200, "application/json", lite_body(mode="AP", ip="0.0.0.0", connected=True)) check("AP+connected:true → board/kind=ap", r["verdict"] == "board" and r["kind"] == "ap", r) # busy/degraded 桩:完整 status 抢不到 mutex 时只回 version+ota.sys_state r = lp.classify(ip, 200, "application/json", '{"version":"0.5.0","ota":{"sys_state":"busy"},"cpu":{}}') check("busy 桩(无 wifi) → rejected/no-wifi-object( lite 不受此影响)", r["verdict"] == "rejected", r) # 兜底不许有未定义分支:mode 值不认识 → unknown r = lp.classify(ip, 200, "application/json", '{"wifi":{"mode":"NA","ip":null}}') check("mode 非法值 → unknown(兜底,不落 rejected)", r["verdict"] == "unknown", r) # ───────────────────────── 2) IP 校验 ───────────────────────── def test_ip_validation(): print("[2] IPv4 校验(候选表与端点写入都靠它,判据在服务侧)") for s in ["192.168.31.127", "10.0.0.1", "1.2.3.4"]: check("收合法地址 " + s, lp.valid_ipv4(s)) for s in ["not-an-ip", "1.2.3.4.5", "256.1.1.1", "", "192.168.31", "1.2.3.04x"]: check("拒 " + repr(s), not lp.valid_ipv4(s)) # ───────────────────────── 3) 真 socket + 真路由 ───────────────────────── class _Fake(BaseHTTPRequestHandler): BODY = "" CTYPE = "application/json" CODE = 200 def do_GET(self): b = self.BODY.encode("utf-8") self.send_response(self.CODE) self.send_header("Content-Type", self.CTYPE) self.send_header("Content-Length", str(len(b))) self.end_headers() self.wfile.write(b) def log_message(self, *a): pass def _serve(ip, body, ctype="application/json", code=200): cls = type("F", (_Fake,), {"BODY": body, "CTYPE": ctype, "CODE": code}) srv = HTTPServer((ip, 0), cls) threading.Thread(target=srv.serve_forever, daemon=True).start() return srv, srv.server_address[1] def local_ip(): n = lp.local_net() return n.get("default_src_ip"), n def _free_port(host_ip): """要一个"确实没人监听"的端口:绑 0 拿到号→关掉→再 connect 确认被拒。 别拿 bp+1 猜:Windows 会把 ephemeral 端口连着发,猜中的就是隔壁那个服务。""" for _ in range(20): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.bind((host_ip, 0)) p = s.getsockname()[1] s.close() t = socket.socket(socket.AF_INET, socket.SOCK_STREAM) t.settimeout(0.3) try: refused = t.connect_ex((host_ip, p)) != 0 finally: t.close() if refused: return p return None def test_probe_route(): print("[3] 真 socket:/api/lan/probe 走真代码路径") ip, net = local_ip() check("本机默认路由取到 WLAN 地址(期望 192.168.31.x)", bool(ip) and ip.startswith("192.168.31."), net) if not ip: return board_body = lite_body(ip=ip) # 自报 IP 必须等于被探地址 board, bp = _serve(ip, board_body) router, rp = _serve(ip, "

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())