豆子域名管家是一个使用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 证书哨兵,支持钉钉、企微、飞书通知,保护您的资产不掉线。