一个遇到的问题

LeanCloud 的实时通信服务能实现类似客服机器人的功能,用户能自己提供一个 Web Hook 地址,有消息发给机器人的时候实时通信服务会将消息发到这个 Web Hook 上,用户从 Web Hook 收到消息之后能对消息进行解析和处理,构造出客服机器人的回答,再通过 REST API 发还给用户。从而用户能实现客服机器人自动应答功能。

前些天,有个用户反馈说 Web Hook 失效了,一直没有消息发过去。查看之下发现报类似这个样子的错误:

1
2
javax.net.ssl.SSLException: hostname in certificate didn't match: <expectedhost> != <defaulthost>
at org.apache.http.conn.ssl.AbstractVerifier.verify(AbstractVerifier.java:220)

看上去就是用户的 SSL 证书限定的 Host Name 和用户填的 Web Hook 域名不符,导致 SSL 握手的时候我们这边对证书校验失败。

于是我去检查用户证书:

1
openssl s_client -connect target.host.name:443

执行下来显示:

1
2
3
4
5
6
7
8
9
CONNECTED(00000003)
depth=2 C = US, O = "VeriSign, Inc.", OU = VeriSign Trust Network, OU = "(c) 2006 VeriSign, Inc. - For authorized use only", CN = VeriSign Class 3 Public Primary Certification Authority - G5
verify return:1
depth=1 C = US, O = Symantec Corporation, OU = Symantec Trust Network, CN = Symantec Class 3 Secure Server CA - G4
verify return:1
depth=0 C = CN, ST = guangdong, L = shenzhen, O = Shenzhen Tencent Computer Systems Company Limited, OU = R&D, CN = *.cdn.myqcloud.com
verify return:1
---
.......

看到这个证书是颁发给一个叫做 cdn.myqcloud.com 的,而不是用户的域名。从这个域名中能看出一个是证书属于腾讯云,另一个就是这是 CDN 的证书。说明这个用户将 CDN 托管给了腾讯云。此时开始怀疑是因为我们在发请求调用用户 Web Hook 的时候 SSL 握手没有带着 SNI。于是执行:

1
openssl s_client -connect target.host.name:443 -showcerts -servername target.host.name

此时看到了用户真正的证书:

1
2
3
4
5
6
7
8
CONNECTED(00000003)
depth=2 O = Digital Signature Trust Co., CN = DST Root CA X3
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3
verify return:1
depth=0 CN = target.host.name
verify return:1
........

从而确认是我们在调用用户 Web Hook 时候 SSL 握手一定是没有带着 SNI 导致握手时拿到的不是用户真实的证书,拿的是用户 CDN 的证书,最终导致握手失败。

证实服务确实是不支持 SNI

首先需要说的是,从 JDK 是从 1.7 开始才真正支持 SNI,也就是说还在使用 1.6 版本 JDK 的话是无论如何都无法使用 SNI 的。

HttpClient 是从 4.3.2 开始支持 SNI 的。即使你使用的是 JDK 1.7 或更新版本的 JDK,但还是使用 4.3.2 以前的 HttpClient 的话,也是无法使用 SNI 的。

我们调用 Web Hook 的服务使用的是 clj-http 0.7.8,这个版本的 clj-http 刚好使用的是 4.3.1 的 HttpClient,所以才有了上面说的调用 Web Hook 进行 SSL 握手时没有带着 SNI 导致拿到错误证书。

为了证实 clj-http 0.7.8 在请求 https 服务时,SSL 握手没有带着 SNI ,首先需要添加 JVM 参数:

1
-Djavax.net.debug=all

这个参数在调试 SSL 握手相关问题时非常有用,能把完整的握手过程,使用的证书等都打印出来。测试就是随意发了个 POST 请求到 https:leancloud.cn 在打印出来的 ClientHello 阶段有如下信息:

1
2
3
4
5
6
7
8
*** ClientHello, TLSv1.2
RandomCookie: GMT: 1475193456 bytes = { 187, 13, 85, ...... }
Session ID: {}
Cipher Suites: [TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, .......]
Compression Methods: { 0 }
Extension elliptic_curves, curve names: {secp256r1, .......}
Extension ec_point_formats, formats: [uncompressed]
Extension signature_algorithms, signature_algorithms: SHA512withECDSA, ......

上面内容通过 tcpdump 抓包也能得到,但如果能增加 -Djavax.net.debug=all 这个配置的话还是打印出来会更方便一点。主要是看到上面 Extension 只有三行内容,少了:

1
Extension server_name, server_name: [type=host_name (0), value=leancloud.cn]

如果支持 SNI 的话是一定会打印上面这个 Extension 信息的。从而证实 clj-http 0.7.8 确实是不支持 SNI 的。

那是不是将 clj-http 升级到最新版,HttpClient 也使用最新版就可以了呢?

还不行。目前 HttpClient 对 SNI 的支持并不是向前兼容的,而是提供了一套新的 API 让用户使用。想要使用 SNI 就必须调用新的 HttpClient 的 API。clj-http 从 0.7.8 直到最新的发布版 2.3.0 都还在使用 HttpClient 老版本的 API,只有更新一些的还在开发中的 3.4.1 才真正切换到了新的 API。

这里就有疑问了,为什么 JDK 支持了 SNI,HttpClient 还得靠增加一套 API 来支持 SNI 呢?

JDK 对 SNI 的支持

为了解开疑问,先来看看 JDK 是怎么支持 SNI 的。JDK 要创建 SSL 的 Socket 需要使用 javax.net.ssl.SSLSocketFactory。SSLSocketFactory 提供了几种构造 Socket 的方式:

1
2
3
4
5
6
Socket createSocket()
Socket createSocket(String host, int port)
Socket createSocket(String host, int port, InetAddress localhost, int localPort) throws IOException, UnknownHostException;
Socket createSocket(InetAddress address, int port)
Socket createSocket(InetAddress address, int port, InetAddress remoteHost, int remotePort)
Socket createSocket(Socket socket, String host, int port, boolean autoClose)

需要注意:

  1. 有 host 参数的都会在创建 Socket 的时候自动连接 host
  2. 只有直接以 String 传递 host 的方式才会在握手中使用 SNI,以 InetAddress 传递 host 的方式握手时都不会带着 SNI
  3. 第 6 行的 createSocket 很特殊,是传入一个 Socket (已连接或未连接),然后建立一个新的 Socket layered over 原来的 Socket,如果原来的 Socket 没有建立连接,则在创建后会立即连接 host

第二条很关键,但在 JDK 文档上竟然完全没有说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
javax.net.ssl.SSLSocketFactory socketfactory = (javax.net.ssl.SSLSocketFactory)javax.net.ssl.SSLSocketFactory.getDefault();
SSLSocket sock;
// 握手不会带着 SNI
sock = (SSLSocket)socketfactory.createSocket();
sock.connect(new InetSocketAddress("leancloud.cn", 443));
sock.startHandshake();
// 握手不会带着 SNI
sock = (SSLSocket)socketfactory.createSocket(InetAddress.getByName("leancloud.cn"), 443);
sock.startHandshake();
// 握手会带着 SNI
sock = (SSLSocket)socketfactory.createSocket("leancloud.cn", 443);
sock.startHandshake();
// 握手会带着 SNI
Socket plainSocket = SocketFactory.getDefault().createSocket();
// plainSocket 可以先执行 connect,并且这里可以传递 InetSocketAddress
// 只要 Layered Socket 创建时用的传 String 的 createSocket 即可
plainSocket.connect(new InetSocketAddress("leancloud.cn", 443), 30);
// 因为 plainSocket 已经建立连接,所以这里传递 String 的 Host 只是为了将其填入 SNI
sock = socketfactory.createSocket(plainSocket, "leancloud.cn", 443, true);
sock.startHandshake();

从这里也能看出来是否使用 SNI 创建连接藏的很隐晦。据说 JDK 不允许传递 InetAddress 的 createSocket 创建出来的 SSLSocket 在 SSL 握手时自动使用 SNI,是因为 InetAddress 构造的时候支持 getByName 函数,该函数可以传个 IP 而不是 Host。这种情况下用户真传个 IP 进来再允许开启 SNI 将这个 IP 放入 SNI 中就不符合 SNI 使用条件了,因为 SNI 只能填 Host Name。不过感觉理由还是比较牵强,总之就是这个 API 设计的有些诡异,藏得有点深。

HttpClient 对 SNI 的支持

为了了解缘由需要看一下这个 JIRA 讨论

注意:以下内容基于:
[org.apache.httpcomponents/httpcore “4.4.5”]
[org.apache.httpcomponents/httpclient “4.5.2”]
来说。以后内部实现可能还会变化。

在 HttpClient 的框架中,所有 Socket 都是先调用 SocketFactory (有新旧两个版本,org.apache.http.conn.scheme.SocketFactory 和 org.apache.http.conn.socket.ConnectionSocketFactory。两个版本都有 createSocket 和 connectSocket) 的 createSocket 方法先创建 Socket,之后对构造出来的 Socket 进行配置,添加比如 SO_TIMEOUT,SO_REUSEADDR,TCP_NODELAY 等,之后再调用 SocketFactory 的 connectSocket 方法去和 remote 地址建立连接。

在老版本的 HttpClient 下,默认都是用 javax.net.ssl.SSLSocketFactory 无参的 createSocket 函数来创建 Socket 的。在完全不改动上层实现的情况下是无法支持 SNI 了,所以新建立了一套 API。

clj-http 0.7.8 翻译为直接使用 HttpClient 的代码如下,这个是不支持 SNI 的:

1
2
3
4
5
6
7
8
9
10
11
12
SchemeRegistry registry = new SchemeRegistry();
registry.register(new Scheme("http", 80, PlainSocketFactory.getSocketFactory()));
SSLSocketFactory sslFac = SSLSocketFactory.getSocketFactory();
sslFac.setHostnameVerifier(SSLSocketFactory.STRICT_HOSTNAME_VERIFIER);
registry.register(new Scheme("https", 443, sslFac));
BasicClientConnectionManager manager = new BasicClientConnectionManager(registry);
HttpPost post = new HttpPost("https://leancloud.cn");
DefaultHttpClient httpClient = new DefaultHttpClient(manager);
httpClient.execute(post);

clj-http 3.4.1 翻译为直接使用 HttpClient 的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory>create()
.register("http", PlainConnectionSocketFactory.getSocketFactory())
.register("https", SSLConnectionSocketFactory.getSocketFactory())
.build();
BasicHttpClientConnectionManager manager = new BasicHttpClientConnectionManager(registry);
HttpPost post = new HttpPost("https://leancloud.cn");
HttpClient httpClient = HttpClients.custom()
.setConnectionManager(manager)
.build();
httpClient.execute(post);

最关键的差别在于老的 clj-http 使用的是 SSLSocketFactory 而新的使用的是 SSLConnectionSocketFactory,再就是老版本使用的是 DefaultHttpClient,新版本使用的是 HttpClients 构造出来的 HttpClient 。

DefaultHttpClient 中,处理连接部分的是:org.apache.http.impl.conn.DefaultClientConnectionOperator

不同点 新版 老版
创建 SSLSocket 的 Factory 不同 SSLConnectionSocketFactory SSLSocketFactory
使用的 HttpClient 不同 HttpClients 构造出来的 HttpClient DefaultHttpClient
HttpClient 构建连接的类不同 DefaultHttpClientConnectionOperator DefaultClientConnectionOperator

DefaultClientConnectionOperator 使用 SSLSocketFactory 构造 SSLSocket,用的是 javax.net.ssl.SSLSocketFactory 的无参的 createSocket 构造 SSLSocket并且在 SSLSocketFactory 内使用创建出来的 SSLSocket 与目标 Host 建立连接时使用的 InetAddress 方式传递目标 Host Name之后再开始握手流程,这就无法使用 SNI 了。

DefaultHttpClientConnectionOperator 使用的 SSLConnectionSocketFactory 先构造出普通的 Socket在 SSLConnectionSocketFactory 调用 Socket 的 connect 参数先与目标服务建立连接。注意与 DefaultClientConnectionOperator 的不同,DefaultClientConnectionOperator 在调用 SSLSocketFactory 的 connectSocket 时传入的 Socket 就是 SSLSocket,而 DefaultHttpClientConnectionOperator 在调用 SSLConnectionSocketFactory 的 connectSocket 时传入的 socket 只是普通的 Socket。在这个普通的 Socket 与 remote host 建立连接之后,通过调用 SSLConnectionSocketFactory 内 createLayeredSocket 在普通 Socket 之上调用 javax.net.ssl.SSLSocketFactory 的传递 Socket 和普通 String 形式 Host Name 的 createSocket 函数构造出 SSLSocket,之后开始握手流程就能使用 SNI 了。