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。