SNI 问题

昨天晚上碰到个问题,当从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是有的。


image.png

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)

需要注意:

  1. 有 host 参数的都会在创建 Socket 的时候自动连接 host
  2. 只有直接以 String 传递 host 的方式才会在握手中使用 SNI,以 InetAddress 传递 host 的方式握手时都不会带着 SNI
  3. 第 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
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 221,635评论 6 515
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,543评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 168,083评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,640评论 1 296
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,640评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,262评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,833评论 3 421
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,736评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,280评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,369评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,503评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,185评论 5 350
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,870评论 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,340评论 0 24
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,460评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,909评论 3 376
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,512评论 2 359

推荐阅读更多精彩内容