夜雨倚琴 2019-07-01
如果你用 OpenResty 做过 SSL session reuse,你可能会用到其中的异步获取 SSL session 的特性,比如在 ssl_session_fetch_by_lua*
里面发起非阻塞的网络请求,从 memcache 或别的什么存储服务器上读取 SSL session。ssl_session_fetch_by_lua*
的实现原理,就是在 OpenSSL 的 session get callback 里执行 Lua 代码。然而 OpenSSL 的 session get callback 并不支持 yield,所以只靠原生 OpenSSL 是没办法支持在该阶段发起非阻塞的网络请求的。因此,Cloudflare 的工程师贡献了一个 OpenSSL 的补丁给 OpenResty,让 OpenSSL 的 session get callback 支持 yield。
要想解释这个补丁的实现原理,我们得先说明下什么是“支持 yield”。在 OpenResty 的语境里,支持 yield 就是指某个函数(或者阶段)可以被中断,然后在合适的时间继续执行。这样的函数需要至少有三种返回结果:OK
,ERROR
和 AGAIN
。如果函数返回 AGAIN
,表示它的工作被中断了,比如发起了一个网络请求,需要等待对端的响应。同时该函数还会把自己的状态保存到一个 ctx
里。当合适的机会来临时,它会被重新调用,读取 ctx
里面的状态继续执行下去,直到返回 OK
或 ERROR
为止。
让 session get callback 支持 yield 的补丁最早是针对 OpenSSL 1.0 开发的。后来 OpenSSL 1.1.0 版本大改了握手流程,于是 Cloudflare 的工程师写了第二版补丁。之后 OpenSSL 1.1.0 的小版本又有些许小变化,我便帮着润色了下。
针对 OpenSSL 1.1.0 版本的补丁,主要做了两个改动:
READ_STATE_PROCESS
。这个状态里,OpenSSL 会尝试 session resumption。该过程有可能会返回一个表示重试的错误码,在这种情况下,如果重新调用 SSL_handshake
, OpenSSL 会重新进入这一状态。补丁的代码可以在这里看到:https://github.com/openresty/...
除了要给 OpenSSL 打补丁外,该功能还依赖于一个 Nginx 补丁。Nginx 的 ngx_ssl_handshake
只有在 SSL_ERROR_WANT_READ
和 SSL_ERROR_WANT_WRITE
下才会重试 SSL_handshake
。 所以 OpenResty 给 Nginx 打了补丁,让它在 SSL_ERROR_PENDING_SESSION
下也能重试。
SSL_ERROR_PENDING_SESSION
这个名字来源于 BoringSSL。BoringSSL 是 Google 和 Cloudflare 一起开发的,在这个 SSL 库里面,session fetch 是可以 yield 的。该特性的名字就叫做 PENDING SESSION。ssl_session_fetch_by_lua*
就是先在 Cloudflare 内部使用一段时间,然后再开源出来。自然,这个 Nginx 补丁也是针对 BoringSSL 设计的。为此,OpenSSL 补丁也给自己的错误类型起了个 SSL_ERROR_PENDING_SESSION
的别名。
补丁的代码可以在这里看到:https://github.com/openresty/...
OpenSSL 1.1.1 版本对握手流程又做了新的变化,变化之大,以致于现有的补丁没法再缝缝补补凑合用下去了。由于这次没有天降 Cloudflare 工程师来帮忙,我就接下了这一任务,开始针对新的握手流程修改当前的补丁。
看了下 OpenSSL 1.1.1 涉及的函数,最大的变化是它把 session resumption 从原来的 READ_STATE_BODY
向后挪到了 READ_STATE_POST_PROCESS
。补丁里之所以要引入 READ_STATE_PROCESS
把 READ_STATE_BODY
劈成两半,是因为 READ_STATE_BODY
里包含了读取 TLS 数据块的这一不可重试的操作。但是 READ_STATE_POST_PROCESS
里就没这样的顾忌了。所以我们可以把 READ_STATE_PROCESS
这个阶段去掉。
一番调整后,我发现 ssl_session_fetch_by_lua*
现在只要一 yield,就会报 Fatal 错误。一路追查下来发现,OpenSSL 有些函数的返回码改变了。如果照原来的错误码,会走到一个被 SSLfatal
挡路的地方。于是调整了下返回码。
现在 ssl_session_fetch_by_lua*
已经能 yield 了,只是 resume 的时候会报 no cipher
的错误。可是我再三确认,握手时是有 cipher 传递的。我拿着 OpenSSL 1.1.0 和 OpenSSL 1.1.1 的源码,就 server 处理 ClientHello 的 post process 部分的流程对比了下,发现还有一处不同。OpenSSL 1.1.0 里面,关于 cipher 处理部分是在 session resumption 之后进行的。而 OpenSSL 1.1.1 为了遵循 TLS 1.3,把 cipher 的处理挪到 session resumption 之前完成。cipher 处理过程中会把 cipher 给消耗掉,导致重入 session resumption 逻辑的时候,报 no cipher
错误。解决方法也很简单,就是在处理 cipher 前把它保存起来,如果要 yield,就恢复它。
这么改之后,OpenResty 所有关于 SSL session fetch 的测试在 OpenSSL 1.1.1 下终于能跑通了。
补丁的代码可以在这里看到:https://github.com/spacewande...
看上去本文到这里应该完了,但是,还没有呢! 在开发 patch 的过程中,我发现有一个 CLIENT_HELLO_CB,好像在我每次改动的地方都能看到它。这种感觉,就像一路上发现有人一直跟你同路。这种情况下,我不由得对这个 CB 感兴趣起来。查了下发现,OpenSSL 1.1.1 起引入了一个 SSL_CTX_set_client_hello_cb
的函数。这个 callback 在 server 处理 ClientHello 时调用,而且支持 yield。这不就是打完 patch 之后的 session get callback 么! 当然,我们仍然需要通过 session get callback 来设置 session,但已经不需要要求 session get callback 支持 yield 了。我们可以在支持 yield 的 ClientHello callback 中发起非阻塞的网络请求,然后待到 session get callback 再把准备好的 session 丢出去。这么一来,就不需要给 OpenSSL 打 patch 了。
于是我着手修改 OpenResty 的代码,如果当前 OpenSSL 支持 ClientHello callback,就改由 ClientHello callback 执行 ssl_session_fetch_by_lua*
里的 Lua 代码。我们依然需要注册 session get callback,但如果 ClientHello callback 已经执行了 ssl_session_fetch_by_lua*
,那么就该 callback 就只需要返回准备好的 session 了。
ClientHello callback 还有一个作用,就是你可以在这里面修改 preferred cipher。在没有 ClientHello callback 的时代,你得自己造一个。其余的 callback 还是有覆盖不到的路径。当然,由于只在有 session id 的情况下才会执行 ssl_session_fetch_by_lua*
的逻辑,直接在该阶段里修改 preferred cipher 是行不通的。
最后,还需要修改下 Nginx 的那个补丁,让它在 SSL_ERROR_WANT_CLIENT_HELLO_CB
下也重试。
你可能会奇怪,为什么我会在开发完对 OpenSSL 1.1.1 的 patch 之后,还要另起炉灶开发 ClientHello callback?因为一直对 OpenSSL 修修补补也不是什么好办法。毕竟我不是专业的 OpenSSL 开发者,改 OpenSSL 可能会改出 bug。虽然目前还需要在 Nginx 端维护一个补丁,但是维护 Nginx 的补丁相对来说简单一些,毕竟 OpenResty 都维护了那么多 Nginx 的补丁,也不缺能改 Nginx 的人。