安全日志与监控:记录 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 频繁出现握手失败

下面是我画的一个监控流程图,你可以参考这个结构来设计自己的监控体系:

SSL/TLS 异常监控流程图 Android 客户端日志采集 日志上报到监控平台 数据清洗、聚合、异常检测 触发告警 更新监控看板 工程师介入排查 趋势分析与优化 持续改进 SSL/TLS 配置

实战中的避坑指南

最后,分享几个我在项目中踩过的坑:

  • 不要记录证书私钥:日志里绝对不能出现私钥内容,哪怕是在调试模式下。我曾经见过有人把 KeyStore 的密码直接打印到日志里,后果很严重。
  • 注意日志轮转:SSL 握手日志可能非常频繁,建议按天或按文件大小轮转,保留最近 7 天的日志就够了。
  • 区分 debug 和 release:debug 版本可以记录详细握手信息,release 版本只记录成功/失败和错误码。用 BuildConfig.DEBUG 来控制。
  • 敏感信息脱敏:证书的 SubjectDN 里可能包含组织名称、邮箱等信息,如果不想暴露给第三方,记得做 hash 处理。

我的个人习惯:在日志里加一个唯一的请求 ID(比如 OkHttp 的 callId),这样可以把一次请求的所有日志串联起来。排查问题时,直接搜这个 ID 就能看到完整的握手链路,非常方便。

好了,关于 SSL/TLS 的日志与监控就聊这么多。记住一句话:没有日志的系统,出了问题是没法查的。花半天时间把日志体系搭好,能省下未来无数个熬夜排查的夜晚。


公众号:蓝海资料掘金营,微信deep3321