- 后端
- 网络
- 通信
【免费下载链接】libwebsockets
canonical libwebsockets.org networking library
导读
api-test-tls-renew是 libwebsockets 仓库中的一个 API 自测程序(selftest),它在一个进程内同时扮演 lws 客户端与 lws TLS 服务器,用来验证一个关键行为:服务器 vhost 经由lws_tls_cert_updated()重建出来的 TLS ctx(SSL_CTX),与 vhost 首次创建时得到的 ctx 具备完全一致的配置——因为 OpenSSL、mbedTLS 等 TLS 后端把 ALPN 协议协商、客户端证书(mTLS)策略等状态保存在 ctx 上,而不是 vhost 上,证书热更新后如果只换证书不重设 ctx,就会丢掉 h2 协商能力甚至不再索要客户端证书。读完本文,你将掌握该自测程序的运行方式、两个测试用例的判定逻辑、lws_tls_cert_updated()的完整调用链,以及它在 ACME 自动证书场景中的工程价值。
这个测试程序在验证什么
按照 api-test-tls-renew 的 README 的说明,本程序模拟的是一个正在等待首个 ACME 证书的 vhost:服务器 vhost 创建时使用LWS_SERVER_OPTION_IGNORE_MISSING_CERT,并把证书路径指向不存在的位置,因此 vhost 以「完全没有 ctx」的状态启动。随后程序按顺序执行两个 case:
| case | 预期结果 |
|---|---|
证书"到达":lws_tls_cert_updated()为 vhost 创建第一个 ctx | 向该 vhost 发出的 h2 请求由 h2 协议提供服务 |
证书续期:lws_tls_cert_updated()创建替换用的新 ctx | 再次向该 vhost 发出的 h2 请求仍然由 h2 提供服务 |
第二个 case 正是本测试的核心价值:如果后端在续期时只重载证书而丢掉 ctx 上的其他配置,那么 h2 将无法再协商成功,测试就会失败。在 api-tests/README.md 的总览表中,这一测试被概括为:"一个没有证书就启动的服务器 vhost(IGNORE_MISSING_CERT)通过lws_tls_cert_updated()先后获得第一个 ctx 和续期 ctx,且每次都与 vhost 首次 ctx 配置一致:h2 依然能够协商"。
为什么 ALPN 和客户端证书策略必须挂在 ctx 上
要理解这个测试,需要先弄清 lws 的层次划分:一个struct lws_context下可以有多个struct lws_vhost,每个 vhost 对应一个监听端口 + 服务名组合;而 TLS 后端(OpenSSL 的SSL_CTX、mbedTLS 的mbedtls_ssl_config)则是 vhost 持有的具体会话配置对象。
在 lib/tls/tls-server.c 中,lws_tls_server_vhost_ctx_setup()的函数注释直接点明了设计约束:
"What a vhost's server tls ctx needs on top of its certs: the client-cert policy, whatever extra verification certs user code loads into it, and the alpn. The backends keep all of it on the ctx, so every ctx the vhost gets needs it... without it, on openssl, an mTLS vhost would ask no client for a cert, and h2 would no longer be negotiated."
也就是说,后端把三类状态都存在 ctx 上:
- 客户端证书验证策略(authmode / verify 配置);
- 用户代码通过回调额外加载的验证证书;
- ALPN 协议列表(如
"h2,http/1.1")。
因此 vhost 拿到的每一个ctx 都必须补齐这些配置——无论是首次创建时的 ctx,还是lws_tls_cert_updated()重建的 ctx。否则在 OpenSSL 上,一个 mTLS vhost 续期后就再也不会向客户端索要证书,h2 也不再被协商出来。
mbedTLS 后端同样如此。在 lib/tls/mbedtls/mbedtls-server.c 的lws_tls_vhost_backend_create_ctx()中,cipher 列表、SNI 回调、mbedtls_ssl_conf_authmode()(客户端证书模式)、lws_mbedtls_set_alpn()(ALPN)全部在"创建 ctx"这一步统一应用,注释明确说明:
"Everything the vhost's config asks of its ctx is applied here rather than in ... init, so a ctx remade later by
lws_tls_cert_updated()also gets it: a renewed ctx without the SNI callback would serve every name this vhost's cert and never refuse an unknown one (C-424), and one without the authmode or ALPN would stop asking for client certs or offering h2."
运行方式与命令行参数
前置条件:构建时启用的特性
从 CMakeLists.txt 可以看到该测试的构建依赖,编译 libwebsockets 时需要满足以下配置项:
LWS_ROLE_H1(HTTP/1.1 角色)LWS_WITH_HTTP2(HTTP/2 支持,测试的判定依赖 h2)LWS_WITH_NETWORKLWS_WITH_CLIENT- 服务器角色(
LWS_WITHOUT_SERVER必须为 0) LWS_WITH_TLSLWS_WITH_BEARSSL必须为 0(原因见下文"内存交付证书"一节)
启动命令
在原文档给定的命令基础上,默认端口为 7681:
$ lws-api-test-tls-renew -p 7681 --certs <this dir>参数含义如下表:
| 选项 | 含义 |
|---|---|
-p <port> | TLS 服务器 vhost 的监听端口(默认7681) |
-s <address> | 客户端连接的目标地址(默认localhost) |
--certs <dir> | 存放证书与密钥的目录(默认.) |
程序启动后会依次打印两个 case 的结果,例如case 0 (the first cert arrived): PASS,最终汇总为Completed: PASS (0 of 2 cases failed),并以返回值 0/1 表达整体成败。每个 case 有 20 秒的超时保护(源码中#define CASE_TIMEOUT_S 20),超时未完成会判定为失败;同时它也是注册在 CTest 下的正式测试(add_test(NAME api-test-tls-renew ...),超时上限 60 秒,端口由lws_get_free_port()动态分配),可通过ctest -R api-test-tls-renew一键运行。
测试用 PKI 与目录结构
目录minimal-examples-lowlevel/api-tests/api-test-tls-renew/下共有 5 个文件:
| 文件 | 用途 |
|---|---|
| main.c | 测试程序源码(客户端 + 服务器在同一进程) |
| README.md | 本文所依据的说明文档 |
| CMakeLists.txt | 构建与 CTest 注册 |
| ca.crt | 一次性(throwaway)CA 证书,私钥未保留,客户端信任该 CA |
| server.crt / server.key | short.example的服务器证书与私钥,由上面的 CA 签发 |
从证书内容可以看到:server.crt的 CN 与 SAN 为short.example(SAN 中还包含一组*.example通配域名),由ca.crt(CN 为lws tls sessions test CA)签发;而ca.crt是自签名的测试根证书。客户端 vhost 通过info.client_ssl_ca_filepath = <certs>/ca.crt信任它,从而在测试中完成对short.example服务端证书的校验。
源码级拆解:程序是如何工作的
1. 初始化:先建 context,再建两个 vhost
在 main.c 的main()中:
- 用
lws_context_info_defaults()初始化info,并通过lws_cmdline_option()解析-p、-s、--certs三个选项; load_pem("server.crt", ...)与load_pem("server.key", ...)把证书和私钥提前读入内存(缓冲区上限PEM_MAX 8192字节),这是后续"内存交付证书"的前提;- context 以
LWS_SERVER_OPTION_EXPLICIT_VHOSTS | LWS_SERVER_OPTION_DO_SSL_GLOBAL_INIT创建,其中EXPLICIT_VHOSTS表示 vhost 全部由应用显式创建(context 不自动建默认 vhost),DO_SSL_GLOBAL_INIT触发 TLS 全局初始化; - 服务器 vhost:
info.ssl_cert_filepath = <certs>/not-yet/server.crt、info.ssl_private_key_filepath = <certs>/not-yet/server.key——这两个路径下什么都没有,配合info.options |= LWS_SERVER_OPTION_IGNORE_MISSING_CERT,vhost 得以无证书启动、没有任何 ctx; - 客户端 vhost:清掉服务器相关字段后,
info.port = CONTEXT_PORT_NO_LISTEN(不监听)、info.client_ssl_ca_filepath = <certs>/ca.crt,创建用于发起连接的clivhost。
LWS_SERVER_OPTION_IGNORE_MISSING_CERT的官方语义见 include/libwebsockets/lws-context-vhost.h:"证书或密钥缺失时不报错继续运行;vhost 此时无法提供任何服务,但例如 ACME 插件配置了取证书时,这个选项允许你先从一个没有证书的 vhost 启动"。在 lib/tls/tls-network.c 的lws_tls_generic_cert_checks()中,证书缺失且设置了该选项时返回LWS_TLS_EXTANT_NO并记录vhost->tls.skipped_certs = 1,随后后端释放 ctx 并将其置 NULL——这正是"no ctx at all"的来源。
2. 每个 case:先热更新证书,再发起 h2 请求
next_case()(main.c)是每个 case 的驱动器:
- 打印
=== case N: ... ===,重置case_over与on_h2,并安排一个 20 秒 watchdog; - 调用核心 API:
if (lws_tls_cert_updated(context, cert_path, key_path, pem_cert, pem_cert_len, pem_key, pem_key_len)) { case_finish("lws_tls_cert_updated() failed"); return; }其中cert_path/key_path是"not-yet"的路径(只用来定位使用该路径的 vhost),pem_cert/pem_key是第一步读入内存的真实证书与私钥;
- 然后以
i.alpn = "h2"、i.ssl_connection = LCCSCF_USE_SSL、i.host = i.origin = "short.example"发起 HTTPS 连接,i.opaque_user_data = cur + 1用于区分当前 case 的连接。
3. 判定逻辑:请求必须由 h2 服务且应答 200
客户端回调callback_cli()(main.c)负责判定:
- 在
LWS_CALLBACK_ESTABLISHED_CLIENT_HTTP中执行on_h2 = lws_get_network_wsi(wsi) != wsi;——在 h2 下,一次请求是某个网络连接上的一条流,因此"流 wsi"不同于"网络 wsi",据此判断是否真的协商上了 h2;若服务器没有提供 ALPN,连接会退化为 h1,on_h2为假; - 在
LWS_CALLBACK_COMPLETED_CLIENT_HTTP中检查响应码是否为 200,同时确认on_h2为真,两者都满足才算 PASS; - 连接错误、提前关闭、超时均判定失败。
服务器回调callback_srv()(main.c)则简单返回 HTTP 200、text/plain、正文"ok"。两次 case 中请求能持续以 h2 被服务,就证明了续期后的新 ctx 完整继承了 ALPN 配置。
lws_tls_cert_updated()的底层实现
该 API 的声明位于 include/libwebsockets/lws-x509.h,六个参数依次是 context、证书路径、私钥路径、内存证书副本及其长度、内存私钥副本及其长度,语义为"为所有使用给定证书路径的 vhost 更新证书"。
其实现位于 lib/tls/tls-network.c:
int lws_tls_cert_updated(struct lws_context *context, const char *certpath, const char *keypath, const char *mem_cert, size_t len_mem_cert, const char *mem_privkey, size_t len_mem_privkey) { lws_start_foreach_vhost(v, context) { if (v->tls.cfg_alloc_cert_path && v->tls.cfg_key_path && !strcmp(v->tls.cfg_alloc_cert_path, certpath) && !strcmp(v->tls.cfg_key_path, keypath)) lws_tls_vhost_cert_reload(v, mem_cert, len_mem_cert, mem_privkey, len_mem_privkey); } lws_end_foreach_vhost(v); return 0; }它遍历 context 下的全部 vhost,用路径匹配找出所有使用该证书的 vhost 并逐一重载。这正是测试中"路径只用于选择 vhost、证书走内存"的设计由来。
真正的重载逻辑在lws_tls_vhost_cert_reload()(lib/tls/tls-network.c),其流程是:
- 记录旧 ctx
old_ctx与旧的active_ctx_ref; - 构造一个"假 wsi"(只填充 context 与 vhost 字段),用于向应用协议回调传递上下文;
- 调用
lws_tls_vhost_backend_create_ctx(v)创建全新 ctx——这一步把 cipher、SNI、authmode、ALPN 等 vhost 配置全部应用到新 ctx 上; - 创建新 ctx 的引用
new_ref; - 调用
lws_tls_server_certs_load()加载证书——优先使用传入的内存副本(mem_cert/mem_privkey),文件路径仅作兜底; - 调用
lws_tls_server_vhost_ctx_setup(v, &wsi)补齐客户端证书验证策略、额外验证证书与 ALPN(见上文 tls-server.c 的注释); - 提交新 ctx:
v->tls.active_ctx_ref = new_ref;旧 ref 被加入v->tls.retired_ctx_list(private-network.h 定义了这个"退休 ctx 链表")并解除一次引用,由仍然持有旧连接的应用方继续使用,直到最后一个连接关闭(对应 C-669 的引用计数语义); - 若任一步失败,则释放新 ctx、把
v->tls.ssl_ctx恢复为old_ctx,保证失败的更新不会破坏当前服务。
内存交付证书与 BearSSL 的限制
原文档特别强调:证书是直接以内存形式交给lws_tls_cert_updated()的,路径只用于选择 vhost;且该测试不基于 BearSSL 构建。原因有两层:
- 测试场景中服务器 vhost 配置的证书路径
not-yet/server.crt根本不存在,后端在加载证书时如果坚持"先读文件",就无从获取证书——只有内存副本可依赖; - BearSSL 的证书加载实现"只在给定了路径时才去读文件",无法从内存加载,因此在 CMakeLists.txt 中明确要求
require_lws_config(LWS_WITH_BEARSSL 0 requirements)。
这也从侧面说明:lws_tls_cert_updated()的"内存优先"参数设计,正是为了配合 ACME 自动续期这类"证书由代码生成、未必落盘"的流程。
在真实项目中的工程启示
ACME 引导(bootstrap)模式
LWS_SERVER_OPTION_IGNORE_MISSING_CERT+lws_tls_cert_updated()组合正是官方为 ACME 场景设计的"从无证书到有证书"路径:vhost 先以无 ctx 状态启动,ACME 插件拿到首个证书后调用lws_tls_cert_updated()补齐第一个 ctx,此后每次续期再调用同一 API 替换 ctx。本次测试的 case 0 验证的就是这段"补第一个 ctx"的过程。
无中断续期
从retired_ctx_list的设计可以推断:旧 ctx 不会立刻销毁,而是保留到仍在使用它的连接全部结束,从而支持证书滚动期间旧连接不中断;而新连接从一开始就使用新证书。
磁盘监视的另一种做法
值得一提的是,仓库还提供了磁盘监视方案:在 lib/tls/private-lib-tls.h 附近定义了LWS_TLS_CERT_WATCH,lws_tls_vhost_cert_reload()在加载成功后记录证书文件的指纹(lws_tls_cert_file_id_get),后台定期比对发现文件变化即重新加载。与之配套的另一个测试 api-test-tls-cert-rotate 专门验证"证书在磁盘上被替换、lws 主动发现"的路径;而api-test-tls-renew则聚焦"应用主动告知"的路径,两者互补。
迁移到你的 mTLS + h2 服务时的检查清单
- 确认后端把 ALPN / 客户端证书策略放在 ctx 上(OpenSSL、mbedTLS、GnuTLS、Schannel 均如此),因此续期后必须重设这些配置——lws 在
lws_tls_server_vhost_ctx_setup()中统一处理,用户无需手动干预; - 如果服务配置了
info.alpn(如"h2,http/1.1"),续期后应像本测试一样实际验证 h2 是否仍能协商,避免静默退化到 h1; - 若使用 BearSSL 后端,内存交付路径不可用,应确保证书文件真实存在且路径可读;
- 证书更新失败时 lws 会自动回退到旧 ctx,业务侧无需自行做"保留旧配置"的兜底。
总结
api-test-tls-renew以最精简的形态(单进程、双 vhost、两轮 case)覆盖了 libwebsockets TLS 证书热更新的关键契约:由lws_tls_cert_updated()重建的 ctx,必须与 vhost 首次创建时的 ctx 配置完全一致,ALPN 协商与客户端证书策略都不能丢失。理解它的运行方式与实现链路,能帮助你在实际部署中安全地落地 ACME 自动续期与 mTLS/h2 服务,并借助 CTest 注册的自动化测试持续回归验证这一行为。
- 后端
- 网络
- 通信
【免费下载链接】libwebsockets
canonical libwebsockets.org networking library
相关推荐
BFE 基于 TLS 客户端证书认证(mTLS)的 HTTPS 双向认证配置实战
BFE 基于 TLS 客户端证书认证(mTLS)的 HTTPS 双向认证配置实战 BFE 是一款基于 Go 实现的现代化七层负载均衡器,支持在 TLS 握手阶段
后端网络/通信云原生3步完成:将老旧电视盒子变身全能Linux服务器的终极指南
3步完成:将老旧电视盒子变身全能Linux服务器的终极指南 你是否曾想过,那些被遗忘在角落的电视盒子其实蕴藏着强大的潜力?今天我要告诉你一个秘密:只需几个简单的
嵌入式开发工具构建工具操作系统ingress-nginx 客户端证书认证(mTLS)实战:基于 auth-tls 注解实现双向 TLS 身份验证
ingress nginx 客户端证书认证(mTLS)实战:基于 auth tls 注解实现双向 TLS 身份验证 导读 本文以 ingress nginx 官
后端API网关负载均衡云原生
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考