豆子域名管家是一个使用Wails3开发的域名和证书检测客户端工具。该工具最初围绕标准的 HTTPS(TCP 443 端口)构建。通过 tls.Dial 建立三次握手,获取 PeerCertificates,然后获取到期时间。

具体的代码片段如下:

func checkByTCP(ctx context.Context, domain, addr string) (int64, error) {
	dialer := &net.Dialer{Timeout: 5 * time.Second}

	// 使用 DialWithDialer 但需配合 context 处理取消
	conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{
		ServerName:         domain,
		InsecureSkipVerify: true,
	})
	if err != nil {
		return 0, err
	}
	defer conn.Close()

	// 检查 Context 是否已取消
	select {
	case <-ctx.Done():
		return 0, ctx.Err()
	default:
		cert := conn.ConnectionState().PeerCertificates[0]
		return cert.NotAfter.Unix(), nil
	}
}

在运行一段时间后,有用户反应无法检测它的域名证书。经过排查后发现,用户使用的webtransport协议,且仅提供了quic流服务。当使用TCP探测纯 QUIC 服务时会直接报 Connection Refused。所以需要优化探测功能,增加基于UDP的证书提取。随着QUIC(UDP)的兴起以及HTTP/3的普及,越来越多的服务开始提供基于UDP的QUIC协议。这增加了新开实现UDP证书检测需求的迫切性。

为了实现HTTP/3协议的证书探测,根据客户端实际情况,我选择了引入quic-go库,来实现基于UDP证书的提取。

具体的代码片段如下:

func checkByQUIC(ctx context.Context, domain, addr string) (int64, error) {
	tlsConf := &tls.Config{
		ServerName:         domain,
		InsecureSkipVerify: true,
		NextProtos:         []string{"h3"}, // 非常重要,需要匹配服务端的协议
	}

	conn, err := quic.DialAddr(ctx, addr, tlsConf, &quic.Config{
		HandshakeIdleTimeout: 5 * time.Second,
		MaxIdleTimeout:       5 * time.Second,
	})
	if err != nil {
		return 0, err
	}
	defer conn.CloseWithError(0, "")

	certs := conn.ConnectionState().TLS.PeerCertificates
	if len(certs) == 0 {
		return 0, fmt.Errorf("no quic certs")
	}

	c := certs[0]

	fmt.Printf("域名: %v\n", c.Subject.CommonName)
	fmt.Printf("序列号: %X\n", c.SerialNumber) // 对比这个序列号!
	fmt.Printf("有效期至: %v\n", c.NotAfter.Format("2006-01-02 15:04:05"))
	return certs[0].NotAfter.Unix(), nil
}

在实现的过程中,这里有个小插曲,最初的代码没有NextProtos: []string{"h3"}这行代码,探测HTTP/3是正常的,能够获取到到期时间。有一个QUIC服务的证书马上到期了,已经更换了证书,但是使用工具还是以前的旧日期。这个时候,我不知道到底是客户端工具的问题,还是服务器端没有正确更换证书的问题?

首先,需要解决看看到底是哪里的问题?我使用了如下工具进行排查:

openssl x509 -in certs/quic.xxx.com.pem -noout -dates // 查看日期
openssl x509 -in certs/quic.xxx.com.pem -noout -serial // 查看序列号

运行命令后,发现日期和申请证书到期时间一致,说明证书文件是正确的。那么服务还是老的日期?这就需要按照网上说的,需要重启服务,必须杀掉重启,而不是软重启,因为需要重新加载证书,不能使用缓存证书。

两次重启后,发现工具探测还是旧日期,这个时候就有点崩溃,抛开工具,重新写一个脚本来测试证书到期日期。脚本如下:

package main

import (
	"context"
	"crypto/tls"
	"fmt"
	"time"

	"github.com/quic-go/quic-go"
)

func checkQUICCertStrict(domain, ipAddr string) {
	// 1. 构造 TLS 配置
	tlsConf := &tls.Config{
		ServerName:         domain,         // 确保 SNI 正确
		InsecureSkipVerify: true,           // 即使过期也能连上,方便看日期
		NextProtos:         []string{"h3"}, // 必须包含服务端支持的协议
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	// 2. 建立连接
	fmt.Printf("正在连接 %s (%s)...\n", domain, ipAddr)
	conn, err := quic.DialAddr(ctx, ipAddr, tlsConf, &quic.Config{
		HandshakeIdleTimeout: 5 * time.Second,
	})
	if err != nil {
		fmt.Printf("连接失败: %v\n", err)
		return
	}
	defer conn.CloseWithError(0, "")

	// 3. 提取并分析证书
	state := conn.ConnectionState()
	if len(state.TLS.PeerCertificates) > 0 {
		cert := state.TLS.PeerCertificates[0]
		fmt.Println("--- 实时证书信息 ---")
		fmt.Printf("主题 (Subject): %s\n", cert.Subject.CommonName)
		fmt.Printf("序列号 (Serial): %X\n", cert.SerialNumber)
		fmt.Printf("生效时间: %v\n", cert.NotBefore.Local())
		fmt.Printf("过期时间: %v\n", cert.NotAfter.Local())
		fmt.Printf("颁发者: %s\n", cert.Issuer.CommonName)

		// 关键排查点:对比当前时间
		daysLeft := time.Until(cert.NotAfter).Hours() / 24
		fmt.Printf("剩余天数: %.2f 天\n", daysLeft)
	} else {
		fmt.Println("未获取到证书链")
	}
}

func main() {
	// 替换为你的域名和服务器公网 IP:端口
	checkQUICCertStrict("quic.xxx.com", "xxx.xxx.xxx.xxx:443")
}

运行脚本后,发现打印出来的日期和申请证书日期一致,呼出一口浊气。对比脚本发现,缺少了NextProtos: []string{"h3"},添加之后,工具也能正常检测到新的到期日期了。

至于为何以前能够检测到,为何更换证书后无法检测到,我也上网搜索了,给出的一种答案是:如果你没有显式提供 NextProtos,但你使用的客户端工具或库版本在底层为了提高兼容性,可能会默认填充一些常见的协议(如 h3, h3-29 或 http/1.1)。

这次更新仅仅将以前的阿里云证书切换为Let’s证书。APP客户端是运行正常的。我没有深入探究具体原因,可能之前的成功属于“非标准状态下的巧合”。在规范的 QUIC 通讯中,没有 NextProtos 就如同在 HTTPS 请求中没有域名一样。添加 NextProtos: []string{“h3”} 是将我的程序从“靠运气探测”转变为“按标准通信”。

提到标准通信,无论是TCP还是UDP,默认都是443端口,但在实际生产环境中,并非所有证书服务都运行在 443 端口(如 4433 或自定义管理端口)。需要打破 443 端口的限制,就需要数据模型优化,为了支持自定义端口,我们将域名导入逻辑从单纯的 domain 演进为 domain:port 格式。为了向下兼容,不破坏旧用户的习惯,系统引入了默认值机制——若未显式指定端口,则自动补全为 443。

由于工具仅让用户导入域名,无法知道用户实际的域名到底运行的是TCP服务还是UDP服务,所以还需要调整核心逻辑,双协议并行探测,这是全篇最核心的架构调整。由于我们无法预知用户提供的 IP:Port 究竟运行着哪种协议,我采用了“竞速检测”策略。

并发设计,利用 Go 的 sync.WaitGroup 和 channel,同时发起 TCP 和 UDP (QUIC) 两个探测协程。快速返回,采用“任一成功即返回”的逻辑。只要其中一种协议完成了握手并获取到证书,立即通过 context 取消另一个协程,避免无谓的资源浪费。这样肯定在特殊情况下有问题,比如用户同时使用了TCP和UDP,但是根据生产部署实际情况,他们常使用一个证书,所以从业务上来说是没有问题的。同上,只有当两种协议都报错时,才最终判定为探测失败。

具体代码片段如下:

func sslCheck(domain string, port int) (int64, error) {
	if port == 0 {
		port = 443
	}
	addr := fmt.Sprintf("%s:%d", domain, port)

	// 控制整体超时,防止协程永久挂起
	ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
	defer cancel()
    // 结果通道
	resultChan := make(chan checkResult, 2)
	var wg sync.WaitGroup

	// 1. 发起 TCP 检测
	wg.Go(func() {
		t, err := checkByTCP(ctx, domain, addr)
		resultChan <- checkResult{t, err}
	})

	// 2. 发起 UDP (QUIC) 检测
	wg.Go(func() {
		t, err := checkByQUIC(ctx, domain, addr)
		resultChan <- checkResult{t, err}
	})

	// 辅助协程:当所有任务结束时关闭 channel
	go func() {
		wg.Wait()
		close(resultChan)
	}()

	var lastErr error
	// 3. 收集结果
	for range 2 {
		res := <-resultChan
		if res.err == nil {
			cancel() // 一旦有一个成功,立即取消另一个正在进行的请求
			return res.expireTime, nil
		}
		lastErr = res.err // 记录错误,如果都失败了就返回最后一个错误
	}

	return 0, fmt.Errorf("all protocols failed: %v", lastErr)
}

经过这样改造之后,不再担心服务器是只开了 TCP 还是只开了 UDP。用户只需要输入域名地址,不需要关心底层走的是 TLS 还是 DTLS/QUIC。同时并发探测保证了在网络环境复杂时,能以最快响应的那种协议作为结果。

豆子域名管家是使用Go Wails开发的一款本地域名证书检测工具,它是本地化域名与 SSL 证书哨兵,支持钉钉、企微、飞书通知,保护您的资产不掉线。

下载地址:https://91demo.top/tools/