由于我们拥有大量的域名,并且这些域名都使用了https,因此https证书的管理成了一个问题。于是我写了一个https证书的监控器,能在https证书出现问题(例如即将到期,已经到期,或者证书错误)时,主动通知管理人员。
该https证书监控器在我本机开发运行时一切正常,但当部署到监控服务器后,大量的https证书无法通过检查,出现了上图所示第2项的预警,即https证书监控器认为证书与域名不匹配的问题,并且打印出来的证书信息确实和域名不匹配,而事实上,通过浏览器访问有问题的域名,证书却是正确的。
何故如此?
一. 这里首先涉及到的是SNI的问题。
SNI(Server Name Indication)是为了解决一个服务器使用多个域名和证书的SSL/TLS扩展。它的工作原理是:在连接到服务器建立SSL连接之前,先发送要访问站点的域名(Hostname),这样服务器会根据这个域名返回一个合适的证书。
目前,大多数操作系统和浏览器都已经很好地支持SNI扩展。OpenSSL 0.9.8 已经内置这一功能,新版的Nginx也支持SNI。
我这里出现的问题就是https证书监控器向域名发出https请求时,web服务器没有返回正确的https证书,所以出现了证书和域名不匹配的预警。
二. 为什么监控器在本机运行正常,到了监控服务器上就出问题了?
SNI解决的是同一个IP地址下挂多个域名和多个SSL证书时,如何区分这些域名和证书的问题,需要服务端和客户端的双重支持。
我们的服务端支持SNI是毫无疑问的,否则浏览器访问我们的域名就会有问题。
那问题只能出在客户端,当客户端不支持SNI时,服务端无法得知客户端使用的是哪个hostname,即域名来访问服务器,只能返回默认的域名和SSL证书给客户端。我的监控服务器就是这种情况,当服务端的Nginx配置了多个域名和ssl证书时,客户端不论使用哪个域名来访问服务端,始终拿到的是服务端默认的域名和SSL证书。Nginx中可以指定默认server,如果不显示指定,Nginx自己会设置一个默认server,而我的客户端得到的证书就是这个默认server的证书。
资料显示,Java从java 7开始已经支持了SNI,我本机的java版本为java 1.8_172,监控服务器的java版本为java1.8_45,都是java 8。难道是java1.8_45有bug,服务器端正好有java1.8_131的安装包,于是我更新java至1.8_131版本,但是问题依旧,难道是操作系统的问题?
继续查资料,有人也遇到了同样的问题。
SNI client-side mystery using Java8
https://stackoverflow.com/questions/36323704/sni-client-side-mystery-using-java8
这个问题下面的一个答主很具体地指出了问题所在:为 HttpsURLConnection 设置自定义的 HostnameVerifier 将导致 SNI 失效。
我在之前的一篇文章中介绍过如何使用https,并且避开一些https证书的验证问题。
https://blog.csdn.net/Dancen/article/details/76682388
我在https监控器中便使用了getMyHttpsURLConnection的方式来获取HttpsURLConnection。
/**
* 获取不安全的HttpsConnection
* 关闭证书验证,可以和任何https站点建立连接
* @param url
* @return
* @throws IOException
*/
public static HttpsURLConnection getMyHttpsURLConnection(String url) throws IOException
{
HttpsURLConnection rs = null;
if(null != url && !url.isEmpty())
{
try
{
rs = (HttpsURLConnection)new URL(url.trim()).openConnection();
TrustManager[] trustManagers = {new MyX509TrustManager()};
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagers, new SecureRandom());
rs.setSSLSocketFactory(sslContext.getSocketFactory());
rs.setHostnameVerifier(new MyHostnameVerifier());
}
catch(Exception e)
{
throw new IOException(e);
}
}
return rs;
}
getMyHttpsURLConnection方法中正好就使用了自定义的HostnameVerifier,经过测试,当注释掉这一行之后,SNI问题确实也就解决了。但这并不能解决我的问题,因为我之所以使用getMyHttpsURLConnection方法,就是为了关闭SSL证书验证,获取到证书的信息,再自己来对证书进行验证,以满足一些预警需要,即将SSL证书的验证后置。否则,Java会自己来对SSL证书进行验证,这样,证书有问题时就无法和服务端建立连接,HttpsURLConnection.connect()无法执行下去。
import com.dancen.serverdog.domain.CertException.CertExpiredException;
import com.dancen.serverdog.domain.CertException.CertExpiringException;
import com.dancen.serverdog.domain.CertException.CertNotMatchException;
import com.dancen.serverdog.domain.CertException.CertNotYetValidException;
以上是供我在自己执行SSL证书检查时抛出的一些自定义的异常。
三. 为 HttpsURLConnection 设置自定义的 HostnameVerifier 为什么会导致 SNI 失效。
上面的答主Johannes Ernst是这样说的:
经过对JDK几个小时的调试之后,我发现为 HttpsURLConnection 设置自定义的 HostnameVerifier 将导致 SNI 失效(正如我在getMyHttpsURLConnection中做的一样)。
在源码中可以发现问题,sun.net.www.protocol.https.HttpsClient
:
if (hv != null) {
String canonicalName = hv.getClass().getCanonicalName();
if (canonicalName != null &&
canonicalName.equalsIgnoreCase(defaultHVCanonicalName)) {
isDefaultHostnameVerifier = true;
}
} else {
// Unlikely to happen! As the behavior is the same as the
// default hostname verifier, so we prefer to let the
// SSLSocket do the spoof checks.
isDefaultHostnameVerifier = true;
}
if (isDefaultHostnameVerifier) {
// If the HNV is the default from HttpsURLConnection, we
// will do the spoof checks in SSLSocket.
SSLParameters paramaters = s.getSSLParameters();
paramaters.setEndpointIdentificationAlgorithm("HTTPS");
s.setSSLParameters(paramaters);
needToCheckSpoofing = false;
}
HttpsClient这个类的作者是个聪明人,他在该类中检查了HttpsURLConnection所使用的HostnameVerifier是否是默认的JDK类,并基于此修改了一些SSL连接的参数,这样做的后果是,当我们使用了自定义的HostnameVerifier时,SNI会失效。
通过检查一个类的名称来确认该类是否是默认的JDK类,并使其具有一定的逻辑依赖是我永远不会想到的一个好主意,妈妈呀,更糟糕的是,SNI和HostnameVerifier有毛的关系呢?
怎么解决这个问题呢?我们依然可以使用自定义的HostnameVerifier,但是自定义的类名也必须是HostnameVerifier,只在大小写上要和默认的JDK类做出区分。因为我们的聪明人在高明地通过检查一个类的名称来确认该类是否是默认的JDK类时,并没有区分大小写。
由此可见,JDK的开发人员中果然是人才济济。
四. 解决办法。
上面的答主Johannes Ernst已经提供了解决方案,但是过于猥琐,于是我继续查资料。这个Java BUG已经有很多人提出来过:
https://bugs.java.com/view_bug.do?bug_id=JDK-8144566
上面显示已经在java1.8_141修复了bug。
看来我的监控服务器的java版本还是不够新,继续将java升级至java1.8_181版本,问题终于解决。