Envoy 上游 TLS 会话缓存按 SNI 隔离修复:实现原理与运行时回退指南
【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy
Envoy 在本次变更中修复了上游 TLS 客户端会话缓存的一个跨 SNI 复用缺陷:现在会话将按建立连接时实际生效的 SNI(Server Name Indication)进行隔离缓存,从而避免把为某个上游 SNI 学到的会话错误地用于另一 SNI 的连接。本文将围绕该变更(对应 changelogs/current/bug_fixes/tls__scope-upstream-session-cache-by-sni.rst)展开,结合 source/common/tls/client_context_impl.cc 等源码,深入剖析 SNI 会话缓存的数据结构、生效 SNI 的确定逻辑、max_session_keys语义以及临时回退用的运行时开关,帮助你理解该修复的原理并掌握配置与验证方法。
问题背景:跨 SNI 复用 TLS 会话的隐患
TLS 会话恢复(session resumption)是降低上游 TLS 握手开销的关键机制:客户端缓存服务端签发的会话票据(Session Ticket,TLS 1.2 及更早版本)、会话 ID(Session ID),或 TLS 1.3 的 Pre-Shared Key(PSK),并在新连接上直接恢复会话,从而省去完整握手。
在 Envoy 的客户端 TLS 实现中,一个ClientContextImpl(上游 TLS 上下文)可以被多个上游逻辑主机共享,而这些主机可能携带不同的 SNI。修复前的会话缓存是**上下文级(context-wide)**的:会话被缓存在一个不区分 SNI 的全局队列里。其风险在于——某个连接在为 SNI A 学习到会话后,另一个使用 SNI B 的连接可能取出该会话进行恢复,导致 TLS 身份错配,甚至恢复出错误服务端的会话,产生握手异常或安全语义偏差。
从源码注释也可以印证这一点(source/common/tls/client_context_impl.cc):在未按 SNI 隔离缓存时,newSSL()安装的是仅以 SNI 为键的缓存会话,可能把为证书 A 建立的会话恢复给本应选择证书 B 的连接。这正是本次 bug fix 要根治的问题。
修复内容速览(changelog 原文解读)
本次变更的核心内容可概括为三点:
- 会话按“生效 SNI”隔离缓存:上游 TLS 客户端会话缓存改为以建立连接时实际生效的 SNI 作为作用域,为某个上游 SNI 学到的会话,绝不会被提供给使用不同 SNI 的连接。
max_session_keys语义保持不变:该配置项仍然用于限制当前客户端上下文缓存的会话总数上限,不会因为按 SNI 分桶而变成“每个 SNI 各 N 个”。- 提供临时回退开关:可通过运行时 guard
envoy.reloadable_features.scope_upstream_tls_session_cache_by_sni设为false临时恢复旧的上下文级缓存行为(仅用于过渡期,不推荐长期使用)。
源码级剖析:SNI 作用域会话缓存的实现
1. 核心数据结构
新缓存方案在 source/common/tls/client_context_impl.h 中定义了三层结构:
struct SniSessionCacheEntry { std::string sni; bssl::UniquePtr<SSL_SESSION> session; }; using SniSessionCacheList = std::list<SniSessionCacheEntry>; struct SniSessionBucket { // Iterators into sni_session_keys_lru_, newest first for this SNI. std::deque<SniSessionCacheList::iterator> sessions; };sni_session_keys_lru_(SniSessionCacheList):一个全局的 LRU 链表,每个条目记录会话所属的 SNI;session_keys_by_sni_(absl::flat_hash_map<std::string, SniSessionBucket>):以 SNI 字符串为键的哈希表,每个桶内用一个std::deque保存指向 LRU 链表节点的迭代器(按最新在前排序);- 两者均受
session_keys_mu_(absl::Mutex)保护,保证多线程并发下的安全性。
这种“哈希分桶 + 全局 LRU 链表”的组合,使得按 SNI 精确取用会话与跨 SNI 全局淘汰可以同时高效实现。
2. “生效 SNI”的确定顺序
所谓“生效 SNI”,指实际写入 ClientHello 的 SNI,它由 ClientContextImpl::effectiveSni() 按以下优先级确定:
TransportSocketOptions中的serverNameOverride(传输层套接字选项级覆盖);- 启用了
auto_host_sni时,上游主机的 hostname(host->hostname()); - 兜底使用配置中的
sni(UpstreamTlsContext.sni)。
在 ClientContextImpl::newSsl() 中,生效 SNI 既会被设置进 BoringSSL(SSL_set_tlsext_host_name),还会被存入 SSL 对象的 ex-data(sslEffectiveSniIndex),以便后续的“新会话回调”能够拿到与 ClientHello 完全一致的缓存键。即使连接不发送 SNI,也会用空字符串作为合法缓存键(源码注释)。
3. 会话的存入:newSessionKey
当 BoringSSL 为客户端学到一个新会话时,会触发 ClientContextImpl::newSessionKey():
- 若
max_session_keys_ == 0,直接释放会话、不缓存(等价于禁用会话恢复); - 若运行时 guard
scopeUpstreamTlsSessionCacheBySni关闭,则走旧逻辑:把会话压入上下文级session_keys_双端队列,超出上限时从队尾淘汰; - 若 guard 开启(默认),则先从 SSL 对象的 ex-data 恢复生效 SNI,把会话以
{sni, session}形式推入 LRU 链表前端,并在session_keys_by_sni_对应桶的前端登记该节点。
淘汰策略:当缓存总数超过max_session_keys_时,从 LRU 链表末尾淘汰全局最久未使用的会话(无论它属于哪个 SNI),并同步清理对应桶;若桶因此变空则删除该 SNI 的桶(源码)。这保证了max_session_keys_的原有语义——“整个客户端上下文缓存会话的总数上限”——不被破坏。
4. 会话的取用:setSessionForSni
建立新连接时,newSsl() 根据运行时 guard 决定走哪条路径:
- guard 开启:调用
setSessionForSni(ssl, server_name_indication),在 该函数 中只从当前 SNI 对应的桶里取用会话,且优先使用最新的那个; - guard 关闭:调用
setSessionFromContextCache(ssl)(源码),这是刻意忽略 SNI 的旧行为回退路径。
一个值得注意的细节是 TLS 1.3 的会话票据通常是单次使用(single-use)的:代码通过SSL_SESSION_should_be_single_use(session)判断,若为单次使用票据,则在安装到新 SSL 对象后立即从缓存中移除,避免重复使用同一票据;否则(如可复用会话)只将其提升到 LRU 前端以更新“最近使用”次序。
5. 运行时机:如何启用会话缓存
会话缓存并非默认关闭,其启用与max_session_keys的取值直接相关。在 ClientContextImpl 构造函数 中,仅当max_session_keys_ > 0时才设置SSL_CTX_set_session_cache_mode(..., SSL_SESS_CACHE_CLIENT)并注册新会话回调。
配置实践:max_session_keys 的语义与典型用法
max_session_keys定义在 api/envoy/extensions/transport_sockets/tls/v3/tls.proto:
Maximum number of session keys (Pre-Shared Keys for TLSv1.3+, Session IDs and Session Tickets for TLSv1.2 and older) to be stored for session resumption. Defaults to 1, setting this to 0 disables session resumption.
即:默认值为1,设为0则完全禁用会话恢复。参数解析位于 source/common/tls/context_config_impl.cc:PROTOBUF_GET_WRAPPED_OR_DEFAULT(config, max_session_keys, 1)。
一个典型的上游 TLS 配置示例(在UpstreamTlsContext中)大致如下:
transport_socket: name: envoy.transport_sockets.tls typed_config: "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext common_tls_context: validation_context: trusted_ca: filename: /etc/ssl/certs/ca-certificates.crt sni: api.example.com auto_host_sni: true max_session_keys: 128要点解读:
sni与auto_host_sni共同决定生效 SNI:启用auto_host_sni后,会优先使用上游主机的 hostname 作为 SNI,这正是“多个上游逻辑主机共享一个客户端上下文”时的典型场景,也是本次修复重点保护的场景;max_session_keys控制该客户端上下文可缓存的会话总数上限。按 SNI 隔离后,缓存依然受此总量约束,只是取用和淘汰都更为精确(按 SNI 取、全局 LRU 淘汰)。
运行时回退:scope_upstream_tls_session_cache_by_sni
该运行时 guard 在 source/common/runtime/runtime_features.cc 中注册:
RUNTIME_GUARD(envoy_reloadable_features_scope_upstream_tls_session_cache_by_sni);guard 的读取点位于 client_context_impl.cc 的scopeUpstreamTlsSessionCacheBySni(),通过Runtime::runtimeFeatureEnabled(...)判断,并在两个关键路径上影响行为:
- 存入会话(
newSessionKey):决定走上下文级队列还是 SNI 分桶缓存; - 取用会话(
newSsl):决定按 SNI 取用还是从上下文级缓存取用。
如需临时恢复旧行为(例如在升级窗口内做 A/B 对比或排查异常),可按照 Envoy 常规的运行时层(bootstrapruntime的 overlay/symlink 层)将envoy.reloadable_features.scope_upstream_tls_session_cache_by_sni置为false,也可通过 admin 接口的运行时管理能力动态调整。请务必注意:该开关只是临时回退手段,会重新引入跨 SNI 复用会话的风险,应尽快切回默认的按 SNI 隔离行为。
验证与排查建议
- 确认版本包含该修复:查看对应版本的 changelogs/current/bug_fixes/tls__scope-upstream-session-cache-by-sni.rst 是否存在于发布分支;
- 检查运行时 guard 生效状态:通过 admin 接口读取运行时键值,确认
envoy.reloadable_features.scope_upstream_tls_session_cache_by_sni未被覆盖为false; - 观察会话复用行为:在启用会话恢复的上游集群上,观察多 SNI 场景下的握手日志与 TLS 统计;修复后,同一客户端上下文内不同 SNI 的会话不会交叉复用,
max_session_keys仍作为缓存总量的硬上限生效。
小结
本次变更让 Envoy 的上游 TLS 会话缓存从“上下文级、不分 SNI”升级为“按生效 SNI 精确分桶、全局 LRU 淘汰”的机制,彻底消除了跨 SNI 复用会话带来的 TLS 身份错配隐患。理解effectiveSni()的确定顺序、newSessionKey/setSessionForSni的存取路径,以及运行时 guard 的回退位置,将帮助你在多主机、多 SNI 的上游 TLS 场景下正确地配置和运维会话恢复,并在需要时安全地完成行为回退与验证。
</|DSML|parameter> </|DSML|invoke> </|DSML|tool_calls>
【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考