十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ESP32搭建DNS服务器与NCSI探测响应:离线环境的联网状态模拟

ESP32搭建DNS服务器与NCSI探测响应:离线环境的联网状态模拟 前阵子调试一个室内定位项目设备连上我开的ESP32热点后手机一直提示“网络异常无法连接服务器”。明明热点正常设备也拿到了IP可系统就是认为没网。查到最后才发现问题根本不在数据传输而是系统有一套独立的“联网状态检测”机制先解析几个固定域名再去访问一个固定URL只有这些探针全部通过状态栏才会显示已联网。搞懂这个机制之后我冒出一个想法如果让ESP32自己监听53端口当DNS服务器同时在内置HTTP服务里把这些探测请求全部接管那设备眼里看到的网络状态不就完全由我说了算吗我把这套方案在ESP32上完整跑通用到了DNS协议解析、递归转发、NCSI探测响应和DNS定向重解析这几块技术。这篇文章就把整个实现过程、踩坑记录和经验细节都写出来给同样在做离线演示、固件调试、局域网内容过滤的朋友一个可以直接参考的样板。1. 先把项目拆明白ESP32、DNS服务器、NCSI、劫持之间是什么关系1.1 这四件事组合起来到底解决什么问题这个项目名字里有三个关键词很多人乍一看会觉得是三个独立功能其实它们是环环相扣的一条链路。ESP32当DNS服务器是整条链路的地基。它监听UDP 53端口接收局域网内所有设备发来的域名解析请求然后自己决定返回什么答案。NCSI是“Network Connectivity Status Indicator”的缩写是Windows、Android、iPhone等系统用来判断当前网络是否真的能上互联网的探测协议。而DNS劫持在这里并不是搞破坏而是指“由我们自己接管域名解析结果”某些域名解析到ESP自身IP某些域名解析到内网服务器某些域名直接屏蔽。这三件事串起来以后可以实现的效果很直接手机、电脑连上ESP32开的热点后系统状态栏会显示“已联网”即使ESP完全没接外网。设备上那些依赖网络探测的App可以正常进入业务页面不再卡在“无网络”或“网络未连接”的提示上。整套流程是可控可配置的想模拟断网、弱网、特定域名访问失败都可以通过规则表来切换。我在做离线展会展项的时候这个方案帮了大忙。现场设备不能依赖公网但演示App又必须表现出“在线”的样子总不能在展厅偷偷架一台路由器。后来就用一个ESP32完成模拟设备全部走这个“假网络”演示效果和真网几乎没区别。需要强调一点这套技术只建议在你自己控制的局域网、自有设备、开发和测试环境里使用。在别人网络里做DNS劫持属于越界行为这是底线。1.2 硬件选型与网络拓扑为什么选ESP32而不是树莓派或路由器先说结论ESP32是这个场景的甜点硬件。ESP8266也能跑但内存和并发能力都偏紧尤其是要在DNS服务之外再挂HTTP服务处理NCSI探测ESP8266在设备数量稍多的时候会明显卡顿抓包能看到大量重传。树莓派性能当然更强但成本和体积完全不在一个量级功耗也高适合放在机房里长期服务不适合做一个随身携带的“模拟网络盒子”。我实际用的是ESP32-S3开发板理由有几个双核240MHz跑两个任务很轻松Wi-Fi支持AP和STA双模式外设丰富后面如果想加个OLED屏显示连接设备数也方便。如果手头只有普通ESP32也完全够用代码不用改。网络拓扑有两种常见模式根据需求选纯AP模式ESP32只开热点终端设备连接热点。ESP32自身不开外网连接所有DNS请求都由它应答。适合离线演示、App模拟联网场景。STAAP模式ESP32同时连接上级路由器又对外开热点。这种模式下ESP32可以转发DNS请求到上级网络的真实DNS但要注意设备如果想通过这个热点真正上网ESP32还需要开启IP转发和NAT这涉及额外的网络配置后面会单独说。这个项目的核心是前一种模式在纯局域网环境里模拟出“互联网可用”的状态。所以下面的代码和调试都是基于纯AP模式。1.3 在动手之前先想清楚规则边界很多人一听到DNS劫持就以为要把所有域名全部接管。其实最稳的做法是有针对性地接管而不是无脑全收。我在第一版就犯过这个错误把所有未知域名都返回ESP自己的IP结果设备上有些App直接傻了因为它们的业务请求也全部打到了ESP上ESP只回了404App以为服务器异常。后来我总结出一条经验先分清楚哪些域名的解析需要被改变哪些域名必须走原样转发。需要劫持的是系统NCSI探测域名、演示App的业务域名、需要屏蔽的广告域名。需要原样放行的是DNS根服务器、时间同步服务器、以及一些安全检测域名。把这个边界理清楚后面的规则表就好设计多了。2. DNS服务端原理53端口上的协议细节与实现选型2.1 一条DNS报文到底长什么样要做DNS服务器绕不开DNS报文的二进制格式。抓包看多了以后会发现DNS报文的结构其实非常简单比HTTP那些乱七八糟的头要直观得多。一条标准的DNS报文分成三块头部固定12字节包含事务ID、标志位、Question数量、Answer数量、Authority数量、Additional数量。Question段包含查询的域名、查询类型、查询类别。Answer段包含返回的域名、类型、类别、TTL、数据长度、IP地址。头部里的标志位值得多说一句。第2字节的低4位是RCODE0表示正常3表示域名不存在NXDOMAIN。这个标志在后续做域名屏蔽的时候很有用后面具体讲。Question段里最核心的是域名编码。它不是普通字符串而是“长度前缀标签”的形式。比如www.example.com在报文里是03 77 77 77 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00每个标签前一个字节表示长度最后用0收尾。这个编码规则很基础但手写解析时最容易错。Answer段里那个“域名”字段也很有讲究正常情况下不会把完整域名再写一遍而是用压缩指针指向Question段里的域名位置。最典型的写法就是0xC00CC0表示高两位是11代表指针后面0C是偏移量意思是“指向报文开头偏移12字节的位置”正好就是Question段开始的地方。理解了这些手写DNS应答其实不复杂。2.2 实现方案选型自己开socket还是用别人的库我在Arduino和ESP-IDF两个环境里都试过。Arduino环境有个现成的DNSServer库用起来确实爽几行代码就能把特定域名解析到指定IP。但问题也很明显它把所有域名都解析到同一个IP不支持按域名规则区分处理。我想实现“NCSI域名返回ESP IP、广告域名返回黑洞、正常域名转发上游DNS”用这个库就完全做不到得自己改库内部实现等于白搭。ESP-IDF环境下我选择了最直接的方式自己创建UDP socket监听53端口收到请求后解析报文按规则表生成响应。这不是说ESP-IDF有多难而是这种方案完全透明每一步在干什么都清清楚楚。还有一种更底层的做法是改lwIP协议栈的回调但lwIP的DNS模块主要是客户端逻辑做服务端反而不顺手。用socket虽然会有一次内核态拷贝但在这个场景下性能完全够用。选型的核心原则是规则越复杂越要自己控制协议解析和响应构造。如果只是想把所有域名重定向到一个IP做扫码认证Arduino的DNSServer库就够了。2.3 一个最小可用的DNS应答函数先写一个最常用的函数解析请求里的QNAME然后构造一条A记录响应。#include lwip/sockets.h #include string.h #define DNS_PORT 53 #define MAX_DNS_SIZE 1024 // 从DNS请求中解析QNAME返回Question段总长度含QTYPE/QCLASS static int extract_qname(const uint8_t *p, int remain, char *out, int out_size) { int pos 0; int out_len 0; while (pos remain) { uint8_t label_len p[pos]; if (label_len 0) { pos; break; } // 如果遇到压缩指针说明问题段里引用了其他位置 // 查询包里很少出现这里跳过2字节即可 if ((label_len 0xC0) 0xC0) { pos 2; break; } if (out_len 0 out_len out_size - 1) { out[out_len] .; } for (int i 0; i label_len out_len out_size - 1; i) { out[out_len] (char)p[pos 1 i]; } pos label_len 1; } out[out_len] \0; // pos此时指向QNAME结束后的0字节之后跳过QTYPE(2字节)和QCLASS(2字节) return pos 4; }然后是构造响应。响应里最关键的是把请求里的ID原样带回并且把Question段原样复制一份。不要自己重新编码域名直接拷贝最省事。static uint16_t build_dns_response(const uint8_t *request, int req_len, const char *qname, uint32_t resp_ip, int nxdomain, uint8_t *out) { uint16_t id (request[0] 8) | request[1]; int question_len req_len - 12; int offset 0; out[offset] id 8; out[offset] id 0xff; if (nxdomain) { out[offset] 0x81; // QR RD out[offset] 0x83; // RCODE3域名不存在 } else { out[offset] 0x81; // QR RD out[offset] 0x80; // RA, NOERROR } out[offset] request[4]; out[offset] request[5]; // QDCOUNT正常是1 out[offset] 0x00; out[offset] nxdomain ? 0x00 : 0x01; // ANCOUNT out[offset] 0x00; out[offset] 0x00; // NSCOUNT out[offset] 0x00; out[offset] 0x00; // ARCOUNT memcpy(out offset, request 12, question_len); offset question_len; if (!nxdomain) { out[offset] 0xC0; out[offset] 0x0C; // 名称指针指向Question段起始 out[offset] 0x00; out[offset] 0x01; // QTYPE A out[offset] 0x00; out[offset] 0x01; // QCLASS IN out[offset] 0x00; out[offset] 0x00; out[offset] 0x00; out[offset] 0x3C; // TTL60秒 out[offset] 0x00; out[offset] 0x04; // RDLENGTH4 out[offset] (resp_ip 24) 0xFF; out[offset] (resp_ip 16) 0xFF; out[offset] (resp_ip 8) 0xFF; out[offset] resp_ip 0xFF; } return offset; }TTL设成60秒是有讲究的。设太短会增加解析请求量设太长会导致修改规则后设备端缓存迟迟不失效。60秒算是一个均衡值测试阶段改完规则最多等一分钟就能生效。3. NCSI探测欺骗三大厂系统的联网检测规则3.1 Windows的NCSI检测到底查了什么Windows的NCSI是这套系统里最容易踩坑的地方。它默认分两步探测网络连通性。第一步是DNS探测系统会去解析dns.msftncsi.com这个域名并且期望得到的IP是131.107.255.255。你没看错它要求的是一个固定IP不是随便一个A记录就行的。如果解析出来的IP不对Windows直接认为DNS解析异常网络状态会变成“无Internet”。第二步是HTTP探测系统会访问http://www.msftconnecttest.com/connecttest.txt期望返回的正文是纯文本Microsoft Connect Test不能带多余的空格、换行、HTML标签。所以在做NCSI欺骗时DNS规则里必须把dns.msftncsi.com解析到131.107.255.255而不是ESP自己的IP。我第一版就栽在这个坑里把NCSI域名全部解析到了ESP IP结果Windows一直转圈最后才知道Windows要的不是一个“能用的IP”而是一个“特定的值”。各主要系统的探测规则我整理成了表格系统探测域名探测路径期望响应Windowsdns.msftncsi.com仅DNS解析A记录返回131.107.255.255Windowswww.msftconnecttest.com/connecttest.txt纯文本 Microsoft Connect TestAndroidconnectivitycheck.gstatic.com/generate_204HTTP 204 No ContentAndroid旧版connectivitycheck.android.com/generate_204HTTP 204 No ContentiPhonecaptive.apple.com/hotspot-detect.html特定Success HTML页面3.2 Android和iPhone的检测路径Android的逻辑比Windows简单直接访问一个固定的generate_204地址如果HTTP响应码是204就认为网络是通的。204响应本身不应该带任何正文所以ESP在处理这个请求时要特别小心不要顺手返回一堆日志文本。iPhone用的是苹果自己的一套captive portal检测访问captive.apple.com/hotspot-detect.html期望返回一个特定的HTML页面。内容大概是HTMLHEADTITLESuccess/TITLE/HEADBODYSuccess/BODY/HTML。注意大小写和标签完整度我测试的时候发现iPhone对这个页面的校验比较严格少一个引号都可能判定失败。还有一个细节不同系统的检测域名是在不断演进的。Android新版可能还会访问safebrowsing.googleapis.com、play.googleapis.com等域名做一些额外检测。这些域名虽然不直接影响“是否联网”的判断但会影响部分App的启动流程。如果要做完整的离线模拟建议在规则表里把所有探测相关域名都列出来然后统一解析到ESP。3.3 ESP32上的HTTP探测服务实现ESP-IDF自带的esp_http_server组件足够完成这个任务。在app_main里注册三个URL handler分别处理Windows、Android、iPhone的探测请求。#include esp_http_server.h static esp_err_t handle_connecttest(httpd_req_t *req) { const char *resp Microsoft Connect Test; httpd_resp_set_type(req, text/plain); httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN); return ESP_OK; } static esp_err_t handle_generate_204(httpd_req_t *req) { httpd_resp_set_status(req, 204 No Content); httpd_resp_send(req, NULL, 0); return ESP_OK; } static esp_err_t handle_hotspot_detect(httpd_req_t *req) { const char *resp HTMLHEADTITLESuccess/TITLE/HEADBODYSuccess/BODY/HTML; httpd_resp_set_type(req, text/html); httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN); return ESP_OK; } void start_probe_server(void) { httpd_handle_t server NULL; httpd_config_t config HTTPD_DEFAULT_CONFIG(); config.server_port 80; if (httpd_start(server, config) ESP_OK) { httpd_uri_t connecttest { .uri /connecttest.txt, .method HTTP_GET, .handler handle_connecttest, }; httpd_register_uri_handler(server, connecttest); httpd_uri_t gen204 { .uri /generate_204, .method HTTP_GET, .handler handle_generate_204, }; httpd_register_uri_handler(server, gen204); httpd_uri_t apple { .uri /hotspot-detect.html, .method HTTP_GET, .handler handle_hotspot_detect, }; httpd_register_uri_handler(server, apple); } }这里有几个细节要注意。connecttest.txt的Content-Type要设成text/plain内容里不能有换行。generate_204绝对不能调httpd_resp_send传正文否则某些Android版本会判断异常。hotspot-detect.html的Content-Type是text/html响应体要完整。4. 劫持规则与转发逻辑从一次DNS请求到自定义响应4.1 规则表设计三类动作一个数组搞定有了前面的基础现在可以设计规则引擎了。我用的数据结构非常简单就是一个数组加三个动作常量typedef enum { DNS_ACTION_NORMAL 0, // 原样转发上游DNS DNS_ACTION_RESPOND_ESP, // 返回ESP自己的IP DNS_ACTION_RESPOND_IP, // 返回自定义IP DNS_ACTION_BLACKHOLE, // 返回0.0.0.0相当于屏蔽 } dns_action_type_t; typedef struct { const char *domain; dns_action_type_t action; uint32_t ip; // 当action为RESPOND_IP时使用 } dns_rule_t;实际工程里的规则表大概是这样的#define ESP_IP 0xC0A80401 // 192.168.4.1 #define MSNCSI_IP 0x836BFFFF // 131.107.255.255 const dns_rule_t dns_rules[] { { dns.msftncsi.com, DNS_ACTION_RESPOND_IP, MSNCSI_IP }, { www.msftconnecttest.com, DNS_ACTION_RESPOND_ESP, 0 }, { connectivitycheck.gstatic.com, DNS_ACTION_RESPOND_ESP, 0 }, { connectivitycheck.android.com, DNS_ACTION_RESPOND_ESP, 0 }, { captive.apple.com, DNS_ACTION_RESPOND_ESP, 0 }, { www.apple.com, DNS_ACTION_RESPOND_ESP, 0 }, { *.ads.example.com, DNS_ACTION_BLACKHOLE, 0 }, };DNS_ACTION_NORMAL的意思是这条域名不在劫持列表里转入上游DNS转发流程。这个动作适合那些希望“不影响正常解析”的域名。但在纯AP离线模式下这条规则不会让设备真正上网因为ESP不会把数据包路由到公网。所以NORMAL模式通常在STAAPNAT的环境下才有意义。4.2 请求处理的主循环DNS服务的主循环逻辑很清晰我梳理成几个步骤从socket接收UDP数据包。检查长度是否够12字节头部不够直接丢弃。解析QNAME和QTYPE。在规则表里做精确匹配如果是通配符规则就做后缀匹配。命中规则时按动作构造响应包。未命中规则时调用上游转发函数把查询发给上级DNS。把响应发回给客户端。主循环用阻塞式recvfrom就能跑但必须setsockopt设一个超时时间否则遇到上游DNS不响应整个线程会卡死。我设置的超时是1000ms超过就返回SERVFAIL。需要说明的是单线程串行处理在这个场景够用。局域网设备一般几十台DNS请求频率不高。如果要做高并发设备接入可以改成双线程或加一个简单的请求队列。4.3 转发到上游DNS让ESP成为真正意义上的DNS服务器转发功能的实现思路是再开一个UDP socket把原始的DNS查询内容重新发送给上游DNS服务器然后等待响应结果再把响应内容透传给客户端。具体来说构造一个新的UDP包目标地址是上游DNS的IP端口53。上游DNS返回的报文不要做任何修改直接把整包丢回给客户端。这里有一个坑如果直接把上游DNS的响应转发响应包里的ID和客户端请求ID是一致的因为转发的是原始请求内容所以没有问题。但要注意上游DNS返回的包长度可能超过512字节尤其是接了EDNS0的情况下所以接收缓冲区开大一点我用的1024字节。上游DNS的选择取决于ESP32当前连接的网络。在STAAP模式下可以直接用esp_netif_get_dns_info从Wi-Fi接口拿到网关下发的DNS地址。在纯AP离线模式下上游DNS地址配置成啥都无所谓因为本来就没外网转发也无法成功。5. 完整落地ESP32工程结构、核心代码和烧录验证5.1 工程目录与依赖组件我用的是ESP-IDF v5.x工程结构可以保持最简esp_dns_sim/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ ├── app_main.c │ ├── dns_server.c │ ├── dns_server.h │ ├── probe_server.c │ ├── probe_server.h │ └── rules.hCMakeLists里需要包含的主要组件是esp_wifi、esp_netif、nvs_flash、esp_http_server。在main/CMakeLists.txt里正常用SRCS列出源文件即可不需要额外找第三方库全部用IDF自带组件。5.2 核心代码串讲Wi-Fi初始化、DNS任务、规则匹配app_main里做三件事初始化NVS和Wi-Fi驱动、启动AP热点、创建DNS任务和HTTP服务。#include nvs_flash.h #include esp_wifi.h #include esp_netif.h #include esp_event.h void app_main(void) { nvs_flash_init(); esp_netif_init(); esp_event_loop_create_default(); // 创建AP热点 esp_netif_create_default_wifi_ap(); wifi_init_config_t cfg WIFI_INIT_CONFIG_DEFAULT(); esp_wifi_init(cfg); wifi_config_t wifi_config { .ap { .ssid EspSimNet, .ssid_len strlen(EspSimNet), .password 12345678, .max_connection 8, .authmode WIFI_AUTH_WPA2_PSK, }, }; esp_wifi_set_mode(WIFI_MODE_AP); esp_wifi_set_config(WIFI_IF_AP, wifi_config); esp_wifi_start(); // 启动DNS服务 start_dns_server(); // 启动HTTP探测响应服务 start_probe_server(); }DNS服务主线程的核心循环是这样的static void dns_server_task(void *arg) { int sock socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); struct sockaddr_in addr {0}; addr.sin_family AF_INET; addr.sin_port htons(DNS_PORT); addr.sin_addr.s_addr INADDR_ANY; bind(sock, (struct sockaddr *)addr, sizeof(addr)); // 设置1秒接收超时 struct timeval tv { .tv_sec 1, .tv_usec 0 }; setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); uint8_t req[MAX_DNS_SIZE]; uint8_t resp[MAX_DNS_SIZE]; while (1) { struct sockaddr_in from; socklen_t fromlen sizeof(from); int len recvfrom(sock, req, sizeof(req), 0, (struct sockaddr *)from, fromlen); if (len 12) continue; char qname[256]; int question_len extract_qname(req 12, len - 12, qname, sizeof(qname)); if (question_len 4) continue; // 规则匹配 const dns_rule_t *rule find_rule(qname); uint8_t qtype req[12 question_len - 4]; if (qtype ! 1 qtype ! 5) { // 只处理A和CNAME其他类型返回空应答 uint16_t resp_len build_empty_response(req, len, resp); sendto(sock, resp, resp_len, 0, (struct sockaddr *)from, fromlen); continue; } if (rule) { if (rule-action DNS_ACTION_BLACKHOLE) { uint16_t resp_len build_dns_response(req, len, qname, 0, 1, resp); sendto(sock, resp, resp_len, 0, (struct sockaddr *)from, fromlen); } else if (rule-action DNS_ACTION_RESPOND_IP) { uint16_t resp_len build_dns_response(req, len, qname, rule-ip, 0, resp); sendto(sock, resp, resp_len, 0, (struct sockaddr *)from, fromlen); } else { uint16_t resp_len build_dns_response(req, len, qname, ESP_IP, 0, resp); sendto(sock, resp, resp_len, 0, (struct sockaddr *)from, fromlen); } } else { // 未命中规则转发到上游DNS forward_to_upstream(req, len, from, fromlen, resp, sizeof(resp)); } } }5.3 编译烧录与首次启动验证编译烧录的过程就不展开说了和普通ESP-IDF工程一样先idf.py set-target esp32s3再idf.py build flash monitor。烧录完成后用手机搜索名为EspSimNet的热点密码12345678连接成功后观察状态栏。正常情况下手机会在大约3秒内从“正在获取IP地址”变成“已连接”状态栏显示已联网。如果没有先检查串口日志里有没有DNS查询请求打印出来再按后面的排查表逐项核对。在电脑上验证更直观。连接热点后打开终端nslookup www.msftconnecttest.com 192.168.4.1如果返回Address: 192.168.4.1说明DNS劫持生效。再用浏览器访问http://192.168.4.1/connecttest.txt如果能显示Microsoft Connect Test说明HTTP探测服务正常。5.4 把业务域名也纳入演示链路如果要在离线环境下演示某个App还需要把App实际访问的业务域名加入规则表。方法很简单看App的报错日志或者抓包记录找到它访问的域名然后加一条规则指向ESP或者内网服务器。比如某个App启动时会请求api.demo.com那么在规则表里加一条{ api.demo.com, DNS_ACTION_RESPOND_ESP, 0 },然后在HTTP服务里注册对应的路径返回App需要的假数据。这样整个演示链路就完整了App发现“网络已连接”发起业务请求ESP返回预期数据所有逻辑闭环。6. 测试排查那些让你怀疑人生的坑和对应解法6.1 三板斧验证nslookup、curl、状态栏每次改了规则或者配置之后我都会用三个动作快速确认系统是否正常。第一步在连上热点的电脑上执行nslookup确认目标域名是否解析到了预期IP。这一步验证的是DNS层。第二步用curl直接访问ESP的HTTP服务确认探测响应内容是否正确。这一步验证的是HTTP层。注意curl会自动处理一些重定向所以要看完整响应体。第三步看手机状态栏和系统网络设置。这一步验证的是系统层的综合判断结果。如果三层都通过了基本可以确定模拟联网环境没问题。如果某一层失败排查范围就可以收窄很多。6.2 常见问题速查表我在这个项目里前前后后遇到不少问题列成表方便大家对照现象可能原因解决办法Windows一直显示“正在检测网络”dns.msftncsi.com解析结果不是131.107.255.255检查DNS规则表确认返回IP是0x836BFFFFAndroid显示已连接但无法上网generate_204返回了非204状态码检查HTTP handler确保响应是204且没有正文iPhone显示“已连接”但没有互联网hotspot-detect.html内容不完整使用标准Success HTML注意标签闭合刚改的规则不生效客户端DNS缓存还留着旧结果Windows运行ipconfig /flushdns手机开关一次Wi-Fi部分域名解析返回超时上游DNS转发阻塞超过1秒缩短SO_RCVTIMEO或未命中规则时返回SERVFAILESP32日志疯狂打印请求有设备在做网络探测循环将频繁探测的域名加入规则表减少日志量设备一多DNS响应变慢单线程socket串行处理转发会阻塞用双socket或升级到ESP32-S3双核并任务隔离访问内网网关地址失败将网关域名也劫持了在规则表里排除本地域名不要劫持路由器地址6.3 DoH和网络缓存的坑现在很多系统默认开启了DNS-over-HTTPS尤其是Windows 11和部分Android手机。开启DoH后设备会绕开UDP 53端口直接向公共DNS服务器发起加密查询这时候ESP上的DNS劫持就完全失效了NCSI探测自然也会失败。排查方法很简单连上热点后如果发现DNS查询数量为0而设备的网络状态还是不对基本上就是DoH在作怪。临时解决办法是手动把设备网络的DNS设为192.168.4.1同时关闭该网络的“自动DNS”或“私密DNS”选项。另一个容易忽略的是系统DNS缓存。我修改规则后经常要过很久才能看到效果就是因为Windows和Android都缓存了之前的DNS解析结果。测试阶段建议TTL设短一点比如60秒然后每次改规则后主动清缓存。6.4 稳定性优化建议ESP32长期开机做这个服务有几件事需要提前处理。53端口的UDP socket如果长时间运行建议在任务里加一个看门狗计数如果连续几分钟没有收到任何包就重启socket防止系统挂起。这个场景我实际遇到过开着串口调试器一切正常拔掉串口跑两天后发现DNS不再响应最后定位是某个内存泄漏导致的。HTTP服务那边esp_http_server默认最大连接数有限如果测试设备多打开menuconfig里的HTTPD_MAX_OPEN_SOCKETS适当调大。另外日志级别在正式演示时调到ESP_LOG_WARN否则串口打印大量DNS查询日志会占掉不少CPU时间在设备多的时候会造成响应延迟。最后分享一点个人操作心得这套系统我从最开始只是想解决一个“设备显示无网络”的问题最后做到可以完全掌控局域网里的网络感知状态整个过程收获最大的一点就是做这类局域网模拟不要把“联网检测”当成一个模糊的整体它其实就是DNS加HTTP两条小链路先把每条链路拆清楚后面所有功能都变得特别好加。如果你也在做离线演示、固件测试或者局域网内容过滤建议先不要急着写完整工程拿一个最小Demo把DNS响应函数跑通再逐步加NCSI规则。等规则表和HTTP服务都稳定了再考虑接入更多设备。这样每一步都有明确的验收标准不会一上来就被一堆问题淹没。我现在已经把这个方案固定用在展会展项的离线演示环境里了后续还打算加一个简单的网页配置界面用手机连上热点就能直接增删劫持规则连串口都不用开。这套玩法本身不复杂但实际用起来非常省事值得动手试一次。
返回列表