昨天晚上碰到个问题,当从HLB迁移到SLB的时候,有些client突然连不通了。报了如下错误。
org.glassfish.jersey.netty.connector.JerseyClientHandler$5.run(JerseyClientHandler.java:175)
java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
java.lang.Thread.run(Thread.java:748)\nCaused by: java.io.IOException: Connection reset by peer
sun.nio.ch.FileDispatcherImpl.read0(Native Method)
sun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:39)
sun.nio.ch.IOUtil.readIntoNativeBuffer(IOUtil.java:223)
sun.nio.ch.IOUtil.read(IOUtil.java:192)
sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:379)
io.netty.buffer.PooledByteBuf.setBytes(PooledByteBuf.java:253)
通过抓包比对,发现HLB上并没有Sever name这一项,而SLB是有的。
SNI
首先我们搞清楚啥是SNI,为啥这个server name会对连接有影响。
SNI(Server Name Indication)是 TLS 的扩展,用来解决一个服务器拥有多个域名的情况。
在客户端和服务端建立 HTTPS 的过程中要先进行 TLS 握手,握手后会将 HTTP 报文使用协商好的密钥加密传输。
在 TLS 握手信息中并没有携带客户端要访问的目标地址。这样会导致一个问题,如果一台服务器有多个虚拟主机,且每个主机的域名不一样,使用了不一样的证书,该和哪台虚拟主机进行通信?这也就解释了为什么SLB需要SNI。
为了解决此问题,产生了 SNI,SNI 中文名为服务器名称指示,是对 SSL/TLS 协议的扩展,允许在单个 IP 地址上承载多个 SSL 证书。SNI 的实现方式是将 HTTP 头插入到 SSL 的握手中,提交请求的 Host 信息,使得服务器能够切换到正确的域并返回相应的正确证书。
SNI(Server Name Indication)定义在RFC 4366,是一项用于改善SSL/TLS的技术,在SSLv3/TLSv1中被启用。它允许客户端在发起SSL握手请求时(具体说来,是客户端发出SSL请求中的ClientHello阶段),就提交请求的Host信息,使得服务器能够切换到正确的域并返回相应的证书。在 TLSv1.2(OpenSSL 0.9.8)版本开始支持。下面是SNI的格式。
Extension: server_name
Type: server_name (0x0000)
Length: 16
Server Name Indication extension
Server Name list length: 14
Server Name Type: host_name (0)
Server Name length: 11
Server Name: www.github.com
这里指定了该 TLS 握手的目标域名为 www.github.com 。通过 SNI,拥有多虚拟机主机和多域名的服务器就可以正常建立 TLS 连接了。
在上述问题,肯是连接没带SNI extension导致的链接失败。
证实服务确实是不支持 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 参数:
-Djavax.net.debug=all
这个参数在调试 SSL 握手相关问题时非常有用,能把完整的握手过程,使用的证书等都打印出来。测试就是随意发了个 POST 请求到 https:leancloud.cn 在打印出来的 ClientHello 阶段有如下信息:
*** 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 只有三行内容,少了:
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 的方式:
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)
需要注意:
- 有 host 参数的都会在创建 Socket 的时候自动连接 host
- 只有直接以 String 传递 host 的方式才会在握手中使用 SNI,以 InetAddress 传递 host 的方式握手时都不会带着 SNI
- 第 6 行的 createSocket 很特殊,是传入一个 Socket (已连接或未连接),然后建立一个新的 Socket layered over 原来的 Socket,如果原来的 Socket 没有建立连接,则在创建后会立即连接 host
第二条很关键,但在 JDK 文档上竟然完全没有说明。
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 的:
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 的代码如下:
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
老版 | 新版 | 不同点 |
---|---|---|
SSLSocketFactory |
SSLConnectionSocketFactory |
创建 SSLSocket 的 Factory 不同 |
DefaultHttpClient |
HttpClients 构造出来的 HttpClient
|
使用的 HttpClient 不同 |
DefaultClientConnectionOperator |
DefaultHttpClientConnectionOperator |
HttpClient 构建连接的类不同 |
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 了
对于上文出现的问题,它并不是由httpclient 发出的,而且又netty的client 发出的。这就需要Netty的connector 在处理sslsocket 时候按照JDK的规范来写。目前的实现是不能满足SNI 握手需要的。
代码
客户端直接指定SNI,这里是另外一种写法
SSLSocketFactory factory = ...
SSLSocket sslSocket = factory.createSocket("172.16.10.6", 443);
// SSLEngine sslEngine = sslContext.createSSLEngine("172.16.10.6", 443);
SNIHostName serverName = new SNIHostName("www.example.com");
List<SNIServerName> serverNames = new ArrayList<>(1);
serverNames.add(serverName);
SSLParameters params = sslSocket.getSSLParameters();
params.setServerNames(serverNames);
sslSocket.setSSLParameters(params);
// sslEngine.setSSLParameters(params);
server端如果需要自己enable SNI 校验,需要自己实现逻辑。但是事实上这个工作并不需要,在LB层已经实现了。只是让大家参考,如果自己写大概是什么样子。
SSLServerSocket sslServerSocket = ...;
SNIMatcher matcher = SNIHostName.createSNIMatcher("www\\.example\\.(com|org)");
Collection<SNIMatcher> matchers = new ArrayList<>(1);
matchers.add(matcher);
SSLParameters params = sslServerSocket.getSSLParameters();
params.setSNIMatchers(matchers);
sslServerSocket.setSSLParameters(params);
SSLSocket sslSocket = sslServerSocket.accept();
调试
ssl日志打印到控制台。Debug Configrations 中找到使用的启动类名称后配置参数。在vm arguments 中添加参数-Djavax.net.debug=ssl
-Djavax.net.debug=ssl
或者
-Djavax.net.debug=all
all turn on all debugging
ssl turn on ssl debugging
The following can be used with ssl:
record enable per-record tracing
handshake print each handshake message
keygen print key generation data
session print session activity
defaultctx print default SSL initialization
sslctx print SSLContext tracing
sessioncache print session cache tracing
keymanager print key manager tracing
trustmanager print trust manager tracing
pluggability print pluggability tracing
handshake debugging can be widened with:
data hex dump of each handshake message
verbose verbose handshake message printing
record debugging can be widened with:
plaintext hex dump of record plaintext
packet print raw SSL/TLS packets