上周有同事说他们的 Java 应用连一个新的服务, 总是有连接问题, 但是切换到老的服务一起都正常. 经过查看日志, tcp 抓包, Loader Balancer 验证, 我们发现问题的根源是 TLS 协商中 client_hello 中涉及的 SNI 扩展问题.
问题再现
这个 Java 应用连接一个 Elastic Search 服务, 这个 ES (Elastic Search) 的 endpoint 只支持 https 协议, 平时一切运行正常. 最近这个 ES 服务重新在 K8S 集群上面重新做了一个, 同样只支持 https 协议. 但是在 Java 应用这边, 把 endpoint 改成新的 URL, 可是总是报错. 一开始开发人员以为是新的 ES 可能慢, 或者网络问题, 于是加大了 client 端的 connect timeout & read timeout 时间. 即便增加了很多, 问题还是没有改善.
原因分析
首先, 我们看了出错日志, 发现是连接超时, 也就是连接本身就有问题. 然后我们在客户端抓包, 发现以下情形: 首先 TCP 的三次握手是没有任何问题的. 然后开始协商 TLS 协议, 客户端发送 client hello 包, 服务端 ack client hello, 然后就直接 reset 了这个连接. 然后一切就结束了. 所以, 可以看到是 TLS 协商的问题.
从这里推断, 我们猜测是客户端发送的 client_hello 里面的一些信息, 在服务端认为不可接受, 所以直接就 reset 了. 首先我们猜测客户端发送的这些加密方法(cipher suites) 在服务端没有一个被接受的. 客户端说我使用 TLSv1.2 协议, 发送了如下的加密方法:
我们查看了这些加密方法, 这些方法正是: io.netty.handler.ssl.SslUtils.java 里面默认的加密方法:
这些加密方法是默认所有加密端都必须接受的, 所以这个不是问题.
又咨询了做加密证书的同事, 他们说这些加密方法服务端肯定是接受的, 都是默认的. 他们猜测这些连接被 reset 的原因很有可能是 SNI 导致的, 对于在K8S新加的 endpoint, 这些都要求客户端在发送 client_hello 的时候, 必须发送 SNI 扩展, 否则直接 reset 连接.
于是重新查看了 TCP dump, 发现确实没有发送 SNI 扩展项. 这就是导致问题的根源.
HTTP 协议中的 host header
在讲 TLS 协议的 SNI 之前, 我们先说一下 HTTP 协议中的 host header. 在早期的 HTTP 协议中, 是没有 host header 的. 一个 HTTP 请求大概是这样的:
GET /index.html HTTP/1.0
在那个时候, 网站还没有那么多, 一般每个网站都有一个独立的 IP. 后来网站越来越多, IPv4逐渐被用光, IPv6 还没被大规模使用, 于是兴起了虚拟主机(Virtual Host), 每个 IP 后边可能有多个网站. 为了区分不同的网站, 于是 HTTP 协议就加了一个 host header 用来区分不同的网站. 于是一个 HTTP 请求大概是这样的:
GET /index.html HTTP/1.0
Host www.tianxiaohui.com
所以, 概括起来就是: Host header 解决了一个 IP 多个网站的问题.
TLS 协议 client_hello 中的 SNI 扩展
如果使用 https 协议, 那么在 TCP 3次握手之后, 就是 TLS 协商, 如果一个 IP 上面有多个网站, 都有不同的证书, 那么如何在 TLS 协商阶段就能区分不同的网站呢? 于是 TLS 协议添加了 SNI (Server Name Indication) 扩展, 在 SNI 扩展里面设置 server_name 的字段值, 通过这个字段值, 服务端在开始握手的时候, 就能知道它要访问具体那个网站.
Java 支持 SNI
Java 从 JDK 7 开始支持 SNI. https://docs.oracle.com/javase/7/docs/technotes/guides/security/enhancements-7.html
Server Name Indication (SNI) for JSSE client: The Java SE 7 release supports the Server Name Indication (SNI) extension in the JSSE client. SNI is described in RFC 4366. This enables TLS clients to connect to virtual servers.
Java 中常见的2种连接关于 SNI 的实验
我们的应用使用的是 JDK 8, 那么为什么还缺少 SNI 呢? 这要归因于那段代码使用的是 Netty 库, Netty 对于 TLS 的处理是使用的 JDK 底层 API, 需要自己处理 SNI 这个参数.
通常在 Java 中我们会遇到如下两种 https连接方式: 1) 使用 Java 的 URL连接; 2) 使用 Netty 的连接. 于是我们实验了这2种方式, 在使用Java 的 URL 连接的时候, 它默认是带 SNI 的. 如果使用 Netty 的方式, 就需要手工设置 SslParameter 里面设置主机名. 否则 Netty 不会发送 SNI. 通过 tcp 抓包的方式, 我们可以验证这些信息.
实验代码:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Collections;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLParameters;
import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.http.DefaultFullHttpRequest;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.ssl.SslProvider;
public class Test {
public static void main(String[] args) throws IOException, InterruptedException {
//Test.testHttpsUrlSni();
Test.testNettyHttpsSni();
}
public static void testHttpsUrlSni() throws IOException {
URL u = new URL("https://www.tianxiaohui.com/");
HttpsURLConnection http = (HttpsURLConnection) u.openConnection();
http.setAllowUserInteraction(true);
http.setRequestMethod("GET");
http.connect();
try (InputStream is = http.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {
StringBuilder stringBuilder = new StringBuilder();
String line = null;
while (null != (line = reader.readLine())) {
stringBuilder.append(line + "\n");
}
System.out.println(stringBuilder.toString());
}
System.out.println("***************Https testing completed **************");
}
public static void testNettyHttpsSni() throws SSLException, InterruptedException {
EventLoopGroup childGroup = new NioEventLoopGroup();
// SSL Parameters to set SNI TLS Extension
SSLParameters sslParameters = new SSLParameters();
sslParameters.setServerNames(Collections.singletonList(new SNIHostName("facebook.com")));
// Build SSLContext for Client
SslContext sslContext = SslContextBuilder.forClient().sslProvider(SslProvider.JDK).build();
// SSLEngine with SSL Parameters for SNI
SSLEngine sslEngine = sslContext.newEngine(ByteBufAllocator.DEFAULT);
// sslEngine.setSSLParameters(sslParameters);//是否设置在这里
// SSL Handler
SslHandler sslHandler = new SslHandler(sslEngine);
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(childGroup).channel(NioSocketChannel.class).handler(new SimpleChannelInboundHandler<>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
// JUST RANDOM CODE TO MAKE TLS REQUEST
FullHttpRequest fullHttpRequest = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/",
Unpooled.EMPTY_BUFFER);
ctx.writeAndFlush(fullHttpRequest);
}
});
Channel ch = bootstrap.connect(new InetSocketAddress("tianxiaohui.com", 443)).sync().channel();
ch.pipeline().addFirst(sslHandler);
}