HTTPS 保护一条连接,并按照客户端的信任规则认证对端。但它不会证明网站意图、业务正确性或服务可用性。地址栏的安全指示既不只是“字节已加密”的标签,也不是完整健康报告。有效检查应分别判断 TLS 连接、HTTP 响应,以及服务实际应该提供的行为,不能把三个问题合成一个模糊的绿灯。
分别提出具体问题
首先确认目标与身份。连接预期入口的同时,应按照证书 SAN 和可接受的信任链验证预期主机名。IP 地址正确,或者能够打印证书主题,都不足以完成身份验证。为了让探针通过而关闭验证,只会移除原本想取得的证据,并不能证明对端已经变得正确。
接着检查有效时间和协商协议。叶证书的到期日期仍在未来,不代表整条证书链可用;一次 TLS 协商成功,也只说明这个客户端实际选中了哪个版本和套件,并没有枚举服务端可能接受的全部配置。配置或库升级之后,仍应另外验证明确的允许和拒绝策略,不能用一次成功连接概括全部安全条件。
最后定义应用成功的条件。HTTP 状态、预期内容、重定向和相关依赖各自回答不同问题。静态站也可能提供有效证书,却返回不存在的页面、旧版本内容或上游错误。即使应用只负责发送文件,TLS 也不是整段访问的全部,更不能替代页面和发布结果的检查。
坏掉的样子
HTTP 库收到一个可正常读取的 503 响应时,未必会把它当成传输错误。若探针只检查 err 是否为空,就可能把不可用服务判断为健康。同样,HTTP 200 也可能来自错误虚拟主机、登录页面或被缓存的错误文档,因此应核验稳定、属于目标服务自身的响应约定。
自动跟随重定向可能隐藏真正被检查的入口。最初的 HTTPS 响应可能指向 HTTP 或非预期地址,最终只看到一个 200 就会丢失中途发生的变化。应依据明确策略检查跳转链,不能为了把请求跑通,就顺手把凭据转发到新的目标地址。
TLS 终止位置同样重要。CDN 证书验证通过,认证的是客户端到 CDN 的这一跳,并不自动认证之后到源站的每一跳。公共访问路径和相关上游路径需要分别监控。诊断应保留目标及观察时间,区分证书故障与 HTTP 故障,同时避免把凭据和私有请求数据写进输出。
快速验证
保存为 health_probe.go,使用 Go 1.23 或更新版本运行 go run health_probe.go。本地 TLS 1.3 服务提供四种应用响应,另一个明文服务充当重定向目标。客户端使用 httptest 提供的配置来信任测试服务器证书,不改变全局信任库,也不访问任何公共入口。
package main
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"runtime"
"sync/atomic"
"time"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
var handled, redirectHits atomic.Int64
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
redirectHits.Add(1)
fmt.Fprint(w, "not followed")
}))
defer target.Close()
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled.Add(1)
switch r.URL.Path {
case "/ready":
fmt.Fprint(w, "ready")
case "/wrong":
fmt.Fprint(w, "different service")
case "/down":
http.Error(w, "unavailable", http.StatusServiceUnavailable)
case "/redirect":
http.Redirect(w, r, target.URL+"/landing", http.StatusFound)
default:
http.NotFound(w, r)
}
}))
server.Config.ErrorLog = log.New(io.Discard, "", 0)
server.TLS = &tls.Config{MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13}
server.StartTLS()
defer server.Close()
probe := func(route, name string) (int, bool, error) {
config := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
config.MinVersion = tls.VersionTLS13
config.ServerName = name
check(!config.InsecureSkipVerify, "certificate verification must stay enabled")
transport := &http.Transport{TLSClientConfig: config, DisableKeepAlives: true}
defer transport.CloseIdleConnections()
client := &http.Client{
Transport: transport, Timeout: 2 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
response, err := client.Get(server.URL + route)
if err != nil {
return 0, false, err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
must(err)
check(response.TLS.Version == tls.VersionTLS13 && len(response.TLS.VerifiedChains) > 0, "verified TLS 1.3 required")
if response.StatusCode == http.StatusFound {
check(response.Header.Get("Location") == target.URL+"/landing", "wrong redirect target")
}
return response.StatusCode, response.StatusCode == http.StatusOK && string(body) == "ready", nil
}
for _, test := range []struct {
path string
status int
healthy bool
}{
{"/ready", 200, true}, {"/wrong", 200, false},
{"/down", 503, false}, {"/redirect", 302, false},
} {
status, healthy, err := probe(test.path, "")
must(err)
check(status == test.status && healthy == test.healthy, "wrong health classification")
fmt.Println(test.path, "verified TLS; HTTP", status, "healthy:", healthy)
}
_, _, err := probe("/ready", "wrong.example.test")
var nameError x509.HostnameError
check(errors.As(err, &nameError), "wrong TLS name must fail")
check(handled.Load() == 4, "wrong name must not reach HTTP")
status, healthy, err := probe("/ready", "")
must(err)
check(status == 200 && healthy && handled.Load() == 5, "trusted health probe must recover")
check(redirectHits.Load() == 0, "HTTP redirect destination must remain unvisited")
fmt.Println("wrong name rejected before HTTP; redirect not followed; healthy request succeeds again")
fmt.Println(runtime.Version())
}
最初四次请求都通过 TLS 验证,但只有 /ready 满足本例的健康约定:HTTP 200,且正文精确等于 ready。/wrong 同样返回 200,却包含另一种服务内容;/down 返回 503。两者都不能判断为健康,即使 HTTP 层面的交换已经成功完成,也不能省略对业务响应的判断。
/redirect 返回 302,并在 Location 中指向明文地址。ErrUseLastResponse 让调用方取得这个响应而不继续跳转,因此目标服务的计数始终为零。本例刻意拒绝所有自动重定向,并不是完整的跳转链审计器。实际监控在跟随跳转之前,仍需先定义可接受的目标范围和传输要求。
故意提供错误 TLS 名称时,客户端得到 HostnameError,不会新增一次 HTTP handler 调用。随后再次使用正确配置请求 /ready,必须恢复成功,使应用请求总数为五。每次尝试都使用独立 transport,保留普通证书验证,并设置两秒客户端超时,让错误身份测试不受已认证连接复用的影响。
这里的两秒是整个客户端请求的截止时间,包含建立连接和读取正文,不是仅限制 TLS 握手;即使收到响应头,正文读取仍受它约束。这个实验没有刻意注入超时,不能把设置超时本身算作超时行为验收。
已核验的 macOS、Go 1.27.0 运行通过全部断言。这用真实本地连接区分了传输身份、协议交换成功和应用健康,但没有枚举公网套件、检查所有边缘节点、验证吊销或浏览器策略,也没有检查上游链路或运行生产负载。正文 ready 只是实验约定,不是所有服务健康判断都应该照抄的固定答案。
延伸阅读
来源
- RFC 9110:HTTP 语义,响应状态与重定向语义,已核验 2026-09-09。
- Go net/http,非成功状态响应、重定向控制和客户端超时,已核验 2026-09-09。
- Go crypto/tls,对端验证与协商连接状态,已核验 2026-09-09。
- Go crypto/x509,主机名验证与证书链检查,已核验 2026-09-09。