安全日志与监控:记录 SSL/TLS 握手日志,监控异常连接
说实话,SSL/TLS 握手过程对大多数 Android 开发者来说就是个黑盒。证书发过去了,握手成功了,就完事了。但我在线上项目里吃过亏——用户反馈连不上服务器,我们查了半天日志,才发现是某个中间证书过期了。从那以后,我养成了一个习惯:所有网络通信,必须留日志。
这一章,我们就聊聊怎么把 SSL/TLS 握手过程记录下来,以及如何通过日志发现异常连接。
为什么要记录握手日志?
你想想看,一个 HTTPS 连接从建立到销毁,中间经历了多少次状态转换?光是 TLS 1.3 的握手就有 2-RTT。如果某个环节出了问题,没有日志,你根本不知道是客户端证书没发出去,还是服务端证书校验失败。
我个人习惯把握手日志分为三个等级:
- 基础日志:记录连接建立、断开、失败
- 详细日志:记录证书链、加密套件、握手耗时
- 调试日志:记录每个握手消息的原始字节(只在 debug 版本开启)
核心原则:生产环境只记录基础日志和部分详细日志,调试日志绝对不能带到线上。否则日志文件一天能涨几个 GB。
Android 端的日志采集方案
Android 原生没有直接提供 SSL 握手日志的 API,但我们可以通过两种方式实现:
方案一:使用 SSLSocket 的日志回调
如果你用的是自定义的 SSLSocketFactory,可以在握手过程中插入日志。我记得有一次排查一个国产手机的兼容性问题,就是靠这个方案定位到的。
class LoggingSSLSocketFactory : SSLSocketFactory() {
private val delegate = SSLSocketFactory.getDefault() as SSLSocketFactory
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket {
val socket = delegate.createSocket(s, host, port, autoClose) as SSLSocket
return LoggingSSLSocket(socket)
}
// 其他方法委托给 delegate ...
}
class LoggingSSLSocket(private val delegate: SSLSocket) : SSLSocket() {
override fun startHandshake() {
Log.d("SSL_HANDSHAKE", "开始握手: ${delegate.inetAddress}")
val startTime = System.currentTimeMillis()
try {
delegate.startHandshake()
val elapsed = System.currentTimeMillis() - startTime
Log.d("SSL_HANDSHAKE", "握手成功, 耗时: ${elapsed}ms")
Log.d("SSL_HANDSHAKE", "加密套件: ${delegate.session.cipherSuite}")
Log.d("SSL_HANDSHAKE", "协议版本: ${delegate.session.protocol}")
} catch (e: Exception) {
Log.e("SSL_HANDSHAKE", "握手失败: ${e.message}")
throw e
}
}
// 其他方法委托给 delegate ...
}
小技巧:在 startHandshake 前后记录时间戳,可以精确测量握手耗时。如果某个地区的用户频繁出现握手超时,大概率是网络延迟或 DNS 解析问题。
方案二:使用 OkHttp 的 EventListener
如果你用的是 OkHttp(大部分项目都是),那更简单。OkHttp 提供了 EventListener,可以监听连接的全生命周期。
class SSLHandshakeListener : EventListener() {
override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) {
Log.d("SSL_MONITOR", "开始连接: ${inetSocketAddress.hostName}:${inetSocketAddress.port}")
}
override fun secureConnectStart(call: Call) {
Log.d("SSL_MONITOR", "开始 TLS 握手")
}
override fun secureConnectEnd(call: Call, handshake: Handshake?) {
if (handshake != null) {
Log.d("SSL_MONITOR", "TLS 握手完成")
Log.d("SSL_MONITOR", "TLS 版本: ${handshake.tlsVersion}")
Log.d("SSL_MONITOR", "加密套件: ${handshake.cipherSuite}")
Log.d("SSL_MONITOR", "服务器证书: ${handshake.peerCertificates.size} 个")
handshake.peerCertificates.forEachIndexed { index, certificate ->
val x509 = certificate as X509Certificate
Log.d("SSL_MONITOR", " 证书[$index]: ${x509.subjectDN.name}, 颁发者: ${x509.issuerDN.name}")
}
} else {
Log.w("SSL_MONITOR", "TLS 握手失败或未完成")
}
}
override fun connectFailed(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy, protocol: Protocol?, e: IOException) {
Log.e("SSL_MONITOR", "连接失败: ${e.message}")
}
}
// 使用方式
val client = OkHttpClient.Builder()
.eventListener(SSLHandshakeListener())
.build()
这个方案的好处是,你不需要动任何业务代码,只需要在构建 OkHttpClient 时注入一个 Listener。我在项目中就靠这个方案,抓到了某个 CDN 节点返回了过期证书的问题。
监控异常连接的策略
光记录日志还不够,你得能自动发现异常。我总结了几种常见的异常场景:
| 异常类型 | 日志特征 | 可能原因 |
|---|---|---|
| 证书校验失败 | javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException | 证书过期、自签名证书、中间证书缺失 |
| 协议版本不匹配 | javax.net.ssl.SSLHandshakeException: No appropriate protocol | 服务端只支持 TLS 1.0,客户端禁用了低版本 |
| 加密套件不匹配 | javax.net.ssl.SSLHandshakeException: No cipher suites in common | 服务端和客户端没有共同的加密套件 |
| 连接超时 | java.net.SocketTimeoutException: connect timed out | 网络不通、防火墙拦截、DNS 解析失败 |
| 证书主机名不匹配 | java.security.cert.CertificateException: No subjectAltNames matching | 使用了通配符证书但域名不匹配,或者证书绑定了错误的域名 |
我曾经踩过的坑:有一次线上报警 SSL 握手失败率飙升,查了半天发现是某个第三方 SDK 在后台线程里偷偷改了系统的 TrustManager。从那以后,我要求所有 SDK 接入时必须声明是否修改了 SSL 配置,并且在日志里记录 TrustManager 的 hash 值,方便追踪。
构建异常监控看板
日志记录下来了,怎么用?我个人习惯把关键指标上报到监控平台,比如:
- 握手成功率:成功握手次数 / 总握手次数
- 握手耗时 P99:99% 的握手在多少毫秒内完成
- 证书错误分布:按错误类型统计
- 异常 IP 聚合:哪些 IP 频繁出现握手失败
下面是我画的一个监控流程图,你可以参考这个结构来设计自己的监控体系:
实战中的避坑指南
最后,分享几个我在项目中踩过的坑:
- 不要记录证书私钥:日志里绝对不能出现私钥内容,哪怕是在调试模式下。我曾经见过有人把 KeyStore 的密码直接打印到日志里,后果很严重。
- 注意日志轮转:SSL 握手日志可能非常频繁,建议按天或按文件大小轮转,保留最近 7 天的日志就够了。
- 区分 debug 和 release:debug 版本可以记录详细握手信息,release 版本只记录成功/失败和错误码。用 BuildConfig.DEBUG 来控制。
- 敏感信息脱敏:证书的 SubjectDN 里可能包含组织名称、邮箱等信息,如果不想暴露给第三方,记得做 hash 处理。
我的个人习惯:在日志里加一个唯一的请求 ID(比如 OkHttp 的 callId),这样可以把一次请求的所有日志串联起来。排查问题时,直接搜这个 ID 就能看到完整的握手链路,非常方便。
好了,关于 SSL/TLS 的日志与监控就聊这么多。记住一句话:没有日志的系统,出了问题是没法查的。花半天时间把日志体系搭好,能省下未来无数个熬夜排查的夜晚。
公众号:蓝海资料掘金营,微信deep3321