ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

libwebsockets 实战:用 lws_system 状态机集成 Captive Portal(门户劫持)检测

libwebsockets 实战:用 lws_system 状态机集成 Captive Portal(门户劫持)检测
  • 后端
  • 网络
  • 通信

【免费下载链接】libwebsockets

canonical libwebsockets.org networking library

项目地址:https://gitcode.com/gh_mirrors/li/libwebsockets
点击查看免费下载

本文以 libwebsockets 官方示例minimal-examples-lowlevel/http-client/minimal-http-client-captive-portal为主体,详解如何把 captive portal(强制门户)检测接入lws_system系统状态机:在 DHCP 配网完成后触发一个 HTTP 探测,根据响应码区分「真实互联网 / 被门户劫持 / 完全无网络」三种结局,并在确认网络可用后自动放行应用进入LWS_SYSTATE_OPERATIONAL状态。读完本文,你将掌握lws_system_ops_t.captive_portal_detect_request、lws_system_cpd_start/set/state_get这套 API 的完整用法、LWS_SYSTATE_CPD_PRE_TIME/LWS_SYSTATE_CPD_POST_TIME两个触发时机的差异,以及应用侧如何用状态通知器(notifier)钩住整个状态推进过程。

1. 背景:设备为什么需要 captive portal 检测

无线设备(IoT 网关、智能家居设备等)接入的 Wi-Fi 网络常被中间拦截:典型的咖啡厅、酒店、机场热点会在你真正上网之前弹出一个登录或「点击同意」页面。从设备视角看,此时 DHCP 租约已经到手、DNS 也「正常」应答,但所有 HTTP 请求都会被重定向到门户页——也就是说,网络层看起来一切正常,应用层却被拦截了。

设备需要一种手段判断自己是否处于这种状态。业界常见方案(见 READMEs/README.captive-portal-detection.md):

  • Android 方案(使用最广):HTTP GEThttp://connectivitycheck.android.com/generate_204,真实互联网会返回204 No Content;被劫持时通常收到 3xx 重定向(可能跳转到 https 门户页),或直接返回 200 并携带门户页 HTML——总之收不到 204;
  • 变体方案:期望特定 body 内容的 200、DNS 解析设备已知的固定 IP 并比对结果、发起 https 连接并检测服务端证书是否「对不上」(有歧义);
  • 门户状态对一个连接是「粘性」的:只要不打算登录门户,DHCP 获取之后检测一次即可;如果应用会去完成门户登录,则登录后应再次检测。

libwebsockets 将这件事抽象为一组通用 API:平台/用户代码自己实现检测策略,lws 核心只负责在正确的系统状态时机触发检测、保存结果、并在结果出来后推进状态机。本文示例正是这套机制的完整参考实现。

2. 示例总览:检测与 lws_system 状态的协作流程

示例的 README(README.md)描述了整体流程:

  1. lws_system状态机推进到 DHCP 之后的LWS_SYSTATE_CPD_PRE_TIME状态时,应用注册的状态通知器被触发,调用lws_system_cpd_start(cx);
  2. lws 转而调用应用提供的captive_portal_detect_request实现,示例向http://connectivitycheck.android.com/generate_204发起 HTTP GET;
  3. 如果收到204,回调中调用lws_system_cpd_set(cx, LWS_CPD_INTERNET_OK)——lws 保存该结果并推动状态机向OPERATIONAL前进;
  4. 进入LWS_SYSTATE_OPERATIONAL后,应用确认 CPD 结果为LWS_CPD_INTERNET_OK,随后发起一次对https://warmcat.com/的正式 HTTPS 请求作为「主业务」演示;
  5. 如果检测结果不是INTERNET_OK,应用直接退出,不再尝试读取 warmcat.com。

CPD 结果只有四种取值,定义于 include/libwebsockets/lws-system.h:

常量含义
LWS_CPD_UNKNOWN上次 DHCP 获取以来还没有做过测试
LWS_CPD_INTERNET_OK没有门户:CPD 测试通过,可以正常上网
LWS_CPD_CAPTIVE_PORTAL推断出处于强制门户之后(被重定向/拦截)
LWS_CPD_NO_INTERNET什么都连不上

3. 构建与运行(README 原始内容)

构建:

$ cmake . && make

运行:

$ ./bin/lws-minimal-http-client-captive-portal [2020/03/11 13:07:07:4519] U: LWS minimal http client captive portal detect [2020/03/11 13:07:07:4519] N: lws_create_context: using ss proxy bind '(null)', port 0, ads '(null)' [2020/03/11 13:07:07:5022] U: callback_cpd_http: established with resp 204 [2020/03/11 13:07:07:5023] U: app_system_state_nf: OPERATIONAL, cpd 1 [2020/03/11 13:07:07:5896] U: Connected to 46.105.127.147, http response: 200 [2020/03/11 13:07:07:5931] U: RECEIVE_CLIENT_HTTP_READ: read 4087 [2020/03/11 13:07:07:5931] U: RECEIVE_CLIENT_HTTP_READ: read 4096 [2020/03/11 13:07:07:6092] U: RECEIVE_CLIENT_HTTP_READ: read 4087 [2020/03/11 13:07:07:6092] U: RECEIVE_CLIENT_HTTP_READ: read 4096 [2020/03/11 13:07:07:6112] U: RECEIVE_CLIENT_HTTP_READ: read 4087 [2020/03/11 13:07:07:6113] U: RECEIVE_CLIENT_HTTP_READ: read 4096 [2020/03/11 13:07:07:6113] U: RECEIVE_CLIENT_HTTP_READ: read 2657 [2020/03/11 13:07:07:6113] U: LWS_CALLBACK_COMPLETED_CLIENT_HTTP [2020/03/11 13:07:07:6119] U: main: finished OK

输出日志逐行印证了流程:callback_cpd_http: established with resp 204是 CPD 探测拿到 204;app_system_state_nf: OPERATIONAL, cpd 1表明系统状态已推进到 OPERATIONAL 且 CPD 结果为 1(即LWS_CPD_INTERNET_OK);随后是对 warmcat.com 的 HTTPS 请求(响应 200)与若干块响应体读取,最后finished OK。

命令行支持-d设置日志级别(如-d 15)与--help,由 lws-switches 机制(lws_cmdline_option_handle_builtin)统一处理。

该示例的 CMakeLists.txt 明确了它对 libwebsockets 编译开关的硬性要求,这四点在移植时要对照检查:

require_lws_config(LWS_ROLE_H1 1 requirements) # HTTP/1.1 角色(CPD 探测用) require_lws_config(LWS_WITH_TLS 1 requirements) # TLS(warmcat.com 的 HTTPS 请求用) require_lws_config(LWS_WITH_CLIENT 1 requirements) # 客户端连接 require_lws_config(LWS_WITH_SYS_STATE 1 requirements) # lws_system 系统状态机

4. 源码走读:三个角色各司其职

示例源码(minimal-http-client-captive-portal.c)虽然只有 300 多行,但把 CPD 集成拆成了三个清晰的角色:CPD 专用协议回调、主业务协议回调、系统状态通知器。

4.1 双协议表:把 CPD 探测与主业务隔离

static const struct lws_protocols protocols[] = { { "http", callback_http, /* 主业务:读 warmcat.com */ 0, 0, 0, NULL, 0 }, { "lws-cpd-http", callback_cpd_http, /* CPD 专用:判定 204 / 重定向 / 失败 */ 0, 0, 0, NULL, 0 }, LWS_PROTOCOL_LIST_TERM };

两个客户端连接(CPD 探测与 warmcat.com 主请求)通过lws_client_connect_info.protocol字段绑定到不同协议,从而使用各自的回调。CPD 的判定逻辑独立成协议,正是 README 强调的:「captive portal detection scheme 在用户代码里实现,可按需修改策略」。

4.2 触发检测:实现captive_portal_detect_request

平台实现的挂载点是lws_system_ops_t的函数指针:

static int captive_portal_detect_request(struct lws_context *cx) { struct lws_client_connect_info i; memset(&i, 0, sizeof i); i.context = cx; i.port = 80; i.address = "connectivitycheck.android.com"; i.path = "/generate_204"; i.host = i.address; i.origin = i.address; i.method = "GET"; i.protocol = "lws-cpd-http"; return !lws_client_connect_via_info(&i); } static lws_system_ops_t ops = { .captive_portal_detect_request = captive_portal_detect_request };

要点:

  • 这个函数只负责发起异步探测,不阻塞等待结果——结果稍后由callback_cpd_http回报给 lws;
  • 返回值语义是「启动是否成功」,return !lws_client_connect_via_info(&i)把 lws 的 0(成功)转成 0,失败转成非 0;
  • i.protocol = "lws-cpd-http"让这条连接落到 CPD 专用回调;
  • 换成其他策略(自有探测域名、DNS 比对、特定 body 校验等)只需替换这个函数体和对应回调的判定逻辑,lws 侧代码零改动。

在 lws-system.h 中,该成员的定义与注释明确了它的契约:

int (*captive_portal_detect_request)(struct lws_context *context); /**< Check if we can go out on the internet cleanly, or if we are being * redirected or intercepted by a captive portal. * Start the check that proceeds asynchronously, and report the results * by calling lws_captive_portal_detect_result() api */

注意:头文件注释里提到的结果回报 API 在 READMEs/README.captive-portal-detection.md 中写作lws_system_cpd_result();当前仓库头文件的正式签名是lws_system_cpd_set(struct lws_context *context, lws_cpd_result_t result),本文及示例代码均以当前仓库签名lws_system_cpd_set()为准。

4.3 回报结果:callback_cpd_http的三分支判定

case LWS_CALLBACK_ESTABLISHED_CLIENT_HTTP: resp = (int)lws_http_client_http_response(wsi); if (!resp) break; lwsl_user("%s: established with resp %d\n", __func__, resp); switch (resp) { case HTTP_STATUS_NO_CONTENT: /* 收到 204,说明是真互联网端点 */ lws_system_cpd_set(lws_get_context(wsi), LWS_CPD_INTERNET_OK); return 0; /* also case HTTP_STATUS_OK: ... */ default: break; } /* fallthru */ case LWS_CALLBACK_CLIENT_HTTP_REDIRECT: lws_system_cpd_set(lws_get_context(wsi), LWS_CPD_CAPTIVE_PORTAL); /* don't follow it, just report it */ return 1; case LWS_CALLBACK_CLIENT_CONNECTION_ERROR: case LWS_CALLBACK_CLOSED_CLIENT_HTTP: /* only the first result counts */ lws_system_cpd_set(lws_get_context(wsi), LWS_CPD_NO_INTERNET); break;

判定矩阵:

事件判定结果
连接建立且响应码为204真实互联网LWS_CPD_INTERNET_OK
收到3xx 重定向(LWS_CALLBACK_CLIENT_HTTP_REDIRECT)被门户拦截LWS_CPD_CAPTIVE_PORTAL
连接错误 / 连接关闭(含门户直接回 200 门户页导致探测流终止的场景)无法触达LWS_CPD_NO_INTERNET

两个细节值得注意:

  • 重定向分支return 1——不跟随跳转,只上报「存在门户」结论。是否跟随、如何解析门户 URL 完全留给用户代码;
  • 注释only the first result counts与核心实现呼应:lws_system_cpd_set()内部对已有非 UNKNOWN 结果会直接忽略重复写入(见下文第 5 节),所以「第一次结果说了算」。

4.4 状态通知器:在CPD_PRE_TIME触发检测、在OPERATIONAL干正事

static int app_system_state_nf(lws_state_manager_t *mgr, lws_state_notify_link_t *link, int current, int target) { struct lws_context *cx = lws_system_context_from_system_mgr(mgr); switch (target) { case LWS_SYSTATE_CPD_PRE_TIME: if (lws_system_cpd_state_get(cx)) return 0; /* allow it */ lwsl_info("%s: LWS_SYSTATE_CPD_PRE_TIME\n", __func__); lws_system_cpd_start(cx); /* we'll move the state on when we get a result */ return 1; case LWS_SYSTATE_OPERATIONAL: if (current == LWS_SYSTATE_OPERATIONAL) { lwsl_user("%s: OPERATIONAL, cpd %d\n", __func__, lws_system_cpd_state_get(cx)); if (lws_system_cpd_state_get(cx) != LWS_CPD_INTERNET_OK) { lwsl_warn("%s: There's no internet...\n", __func__); lws_default_loop_exit(context); break; } memset(&i, 0, sizeof i); i.context = context; i.ssl_connection = LCCSCF_USE_SSL; i.ssl_connection |= LCCSCF_H2_QUIRK_OVERFLOWS_TXCR | LCCSCF_H2_QUIRK_NGHTTP2_END_STREAM; i.port = 443; i.address = "warmcat.com"; i.path = "/"; i.host = i.address; i.origin = i.address; i.method = "GET"; i.protocol = protocols[0].name; lws_client_connect_via_info(&i); break; } default: break; } return 0; }

这里体现了 lws 状态通知器(notifier)的两个关键能力,定义见 include/libwebsockets/lws-state.h:

  1. 拦截(veto):通知器返回 1 表示「本次状态迁移暂停」,由通知器自己发起依赖操作,等依赖完成后再由 lws 内部或回调把状态推下去。CPD_PRE_TIME分支在 CPD 尚未出结果时返回 1,把状态机卡在时间校验之前,直到lws_system_cpd_set()收到结果后由 lws 核心调用lws_state_transition_steps()继续前进;
  2. 接管(takeover):通知器可以发起「迁移目标之后的正事」——进入OPERATIONAL且current == target时(即状态已经稳定落地),应用才发起 warmcat.com 请求。

注册方式是在 context 创建信息中挂 notifier 列表:

static lws_state_notify_link_t nl; /* 全局 */ ... nl.name = "app"; nl.notify_cb = app_system_state_nf; info.register_notifier_list = app_notifier_list; /* &nl, NULL 结尾 */ context = lws_create_context(&info);

lws_state_notify_link_t只有三个成员(list链接、notify_cb回调、name名称),在lws_create_context()内部由register_notifier_list统一注册到系统状态管理器。

4.5 主业务回调与退出控制

callback_http(warmcat.com 请求)是标准的客户端 HTTP 读取模式:

  • LWS_CALLBACK_ESTABLISHED_CLIENT_HTTP:用lws_get_peer_simple()打印对端,lws_http_client_http_response()取响应码存入全局status;
  • LWS_CALLBACK_RECEIVE_CLIENT_HTTP:未解释的原始内容,用lws_http_client_read(wsi, &px, &lenx)让 lws 解析并转发;
  • LWS_CALLBACK_RECEIVE_CLIENT_HTTP_READ:每块(chunked 内容去头后)到达,示例只打印长度,源码中预留了#if 0的 HTML dump 开关;
  • LWS_CALLBACK_COMPLETED_CLIENT_HTTP/LWS_CALLBACK_CLOSED_CLIENT_HTTP:请求完成,bad = status != 200记录成败,lws_default_loop_exit(context)结束主循环,lws_cancel_service()打断 poll 等待。

main()的收尾用return bad把 HTTP 成败映射为进程退出码,SIGINT也通过lws_default_loop_exit()干净退出。context 配置要点:

lws_context_info_defaults(&info, NULL); lws_cmdline_option_handle_builtin(argc, argv, &info); info.port = CONTEXT_PORT_NO_LISTEN; /* 纯客户端,不监听 */ info.options = LWS_SERVER_OPTION_DO_SSL_GLOBAL_INIT; info.system_ops = &ops; /* 挂 CPD 实现 */ info.protocols = protocols;

5. lws 核心侧实现:CPD 三件套是怎么工作的

理解示例行为,只需看清 lib/core/context.c 里三个核心函数:

lws_system_cpd_start(cx)(发起)

int lws_system_cpd_start(struct lws_context *cx) { if (cx->options & LWS_SERVER_OPTION_CPD_BYPASS) { lws_system_cpd_set(cx, LWS_CPD_INTERNET_OK); return 0; } cx->captive_portal_detect = LWS_CPD_UNKNOWN; /* if there's a platform implementation, use it */ if (lws_system_get_ops(cx) && lws_system_get_ops(cx)->captive_portal_detect_request) return lws_system_get_ops(cx)->captive_portal_detect_request(cx); #if defined(LWS_WITH_SECURE_STREAMS) /* 否则尝试 SS "captive_portal_detect" 流(如果启用) */ return lws_ss_sys_cpd(cx); #else return 0; #endif }

可以看出优先级链:

  1. 若 context options 带有LWS_SERVER_OPTION_CPD_BYPASS(定义于 include/libwebsockets/lws-context-vhost.h),直接置为INTERNET_OK跳过检测——这是给可信环境(如内网自测)用的旁路开关;
  2. 把状态复位为LWS_CPD_UNKNOWN;
  3. 若用户提供了captive_portal_detect_request(本示例的情况),调用它;
  4. 否则回退到 Secure Streams 内置的captive_portal_detect流类型(见 lib/secure-streams/system/captive-portal-detect/captive-portal-detect.c):成功建流并拿到远端 QOS ACK 即LWS_CPD_INTERNET_OK,超时/重试耗尽/断开则LWS_CPD_NO_INTERNET。

lws_system_cpd_set(cx, result)(回报)

void lws_system_cpd_set(struct lws_context *cx, lws_cpd_result_t result) { if (cx->captive_portal_detect != LWS_CPD_UNKNOWN) return; ... cx->captive_portal_detect = (uint8_t)result; #if defined(LWS_WITH_SYS_STATE) #if defined(LWS_WITH_SYS_SMD) lws_smd_msg_printf(cx, LWSSMDCL_NETWORK, "{\"type\":\"cpd\",\"result\":\"%s\"}", cname[cx->captive_portal_detect]); #endif /* if nothing is there to intercept anything, go all the way */ if (cx->mgr_system.state != LWS_SYSTATE_POLICY_INVALID) lws_state_transition_steps(&cx->mgr_system, LWS_SYSTATE_OPERATIONAL); #endif }

三个行为要点:

  • 首次结果粘性:结果一旦不是UNKNOWN就不再被覆盖,这解释了示例注释「only the first result counts」;
  • SMD 广播:结果会作为{"type":"cpd","result":"OK"|"Captive"|"No internet"}消息广播到LWSSMDCL_NETWORK类,其他组件(如 lib/secure-streams/protocols/ss-h1.c 中的消费方)可订阅;
  • 自动推进状态机:结果落定后立即lws_state_transition_steps()向OPERATIONAL推进——也就是说示例通知器里return 1卡住的迁移,就是在这里被解锁的。

lws_system_cpd_state_get(cx)(查询):简单返回cx->captive_portal_detect当前值,供应用(如示例的OPERATIONAL分支)或 SS 等子系统查询。

另有一个延迟版接口lws_system_cpd_start_defer(cx, defer_us)(context.c),基于 sorted usec list 在指定微秒后重发检测,用于「稍后再查」的场景;lib/io/client/connect2.c 中保留了 DNS 不可达时「1 秒后复查 CPD」的调用点(当前处于#if 0中,从源码结构看属于预留的重检钩子)。

此外,SMD 消息也可以从外部触发/影响 CPD(context.c 的lws_system_smd_cb):收到{"trigger":"cpdcheck"}会重跑检测;收到平台栈上报的{"type":"ipacq"}(表示平台自己完成了 DHCP 并拿到了 IP)则置ipacq_seen并立即启动 CPD——此时 CPD 结果只是「参考信息」,状态机不再因等待 CPD 而卡死。这个行为有专门的自测验证:minimal-examples-lowlevel/api-tests/api-test-system-cpd 通过两阶段断言「无 IP 时不得进入 OPERATIONAL / 收到合成 ipacq 后必须在无 CPD 策略的情况下进入 OPERATIONAL」。

6. 触发时机:LWS_SYSTATE_CPD_PRE_TIME与LWS_SYSTATE_CPD_POST_TIME

lws 系统状态序列中为 CPD 预留了两个位置(lws-system.h):

LWS_SYSTATE_DHCP, /* at least one net iface configured */ LWS_SYSTATE_CPD_PRE_TIME, /* DHCP 之后、ntpclient 之前的 CPD, 适合不依赖时间(非 https)的方案 */ LWS_SYSTATE_TIME_VALID, /* 时间有效后 TLS 才能工作 */ LWS_SYSTATE_CPD_POST_TIME, /* ntpclient 完成后的 CPD,适合 https 方案 */

选择依据是方案是否需要可信时间:

  • Android 式的明文 HTTP 204 探测不校验证书,时间无关,放CPD_PRE_TIME更早拿到结论(本示例即用此时机);
  • 基于 https 证书校验的探测需要先让 ntpclient 把系统时间校准(TIME_VALID),应挂到CPD_POST_TIME。

通知器的写法完全对称:把app_system_state_nf的switch (target)中LWS_SYSTATE_CPD_PRE_TIME改成LWS_SYSTATE_CPD_POST_TIME即可切换时机,无需改动 lws 侧任何代码。

从源码结构看还有一条「软门槛」逻辑:在启用 Secure Streams + 驱动(drivers)的配置下,若平台栈已通过 SMD 报告过 IP 获取(ipacq_seen),CPD_PRE_TIME的检查直接放行、不再阻塞状态机(context.c)——即 CPD 结论对状态推进是「advisory(参考性)」的,但结果仍可通过 SMD 与lws_system_cpd_state_get()查询,应用可以像本示例一样在OPERATIONAL后自行把关。

7. 把这套集成搬进你的项目:清单

  1. 编译开关:LWS_ROLE_H1、LWS_WITH_TLS(若主业务用 HTTPS)、LWS_WITH_CLIENT、LWS_WITH_SYS_STATE;
  2. 写探测函数:实现captive_portal_detect_request(),返回非 0 表示发起失败;策略可任意(204 探测、DNS 比对、自有服务均可);
  3. 写判定回调:用一个独立lws_protocols协议承接探测连接,在LWS_CALLBACK_ESTABLISHED_CLIENT_HTTP/LWS_CALLBACK_CLIENT_HTTP_REDIRECT/LWS_CALLBACK_CLIENT_CONNECTION_ERROR/LWS_CALLBACK_CLOSED_CLIENT_HTTP中按策略调用lws_system_cpd_set(),三个结果分别对应LWS_CPD_INTERNET_OK/LWS_CPD_CAPTIVE_PORTAL/LWS_CPD_NO_INTERNET;
  4. 注册状态通知器:通过register_notifier_list挂lws_state_notify_link_t,在LWS_SYSTATE_CPD_PRE_TIME(或POST_TIME)中lws_system_cpd_start()并返回 1 拦截迁移;在LWS_SYSTATE_OPERATIONAL(且current == target)中用lws_system_cpd_state_get()把关后执行主业务;
  5. context 配置:info.system_ops = &ops;可信测试环境可用LWS_SERVER_OPTION_CPD_BYPASS跳过;
  6. 理解行为边界:首次结果粘滞;结果通过 SMD{"type":"cpd",...}广播;平台栈自行 DHCP 时 CPD 对状态推进转为参考性(见 api-test-system-cpd 自测)。

8. 小结

libwebsockets 把 captive portal 检测从「一段散落的网络代码」升级为系统状态机中的一等公民:lws_system_ops_t.captive_portal_detect_request让平台代码注入任意检测策略,lws_system_cpd_start/set/state_get三件套负责触发、结果粘滞记录与自动推进状态,LWS_SYSTATE_CPD_PRE_TIME / CPD_POST_TIME两个状态位点分别适配非时间依赖与基于 https 的方案。官方示例 minimal-http-client-captive-portal 以 300 余行完整演示了「DHCP 后自动检测 → 204 判定真互联网 → 状态放行 → OPERATIONAL 后发起 HTTPS 主请求」的闭环,是移植该机制到设备固件时的最佳起点。

  • 后端
  • 网络
  • 通信

【免费下载链接】libwebsockets

canonical libwebsockets.org networking library

项目地址:https://gitcode.com/gh_mirrors/li/libwebsockets
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进