Scert/backend/internal/nginxactivation/probe.go

123 lines
3.9 KiB
Go

package nginxactivation
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
// Endpoint 只允许探测本机指定端口,域名仍用于 SNI、Host 和可信链校验。
type Endpoint struct {
Address string
Hostname string
Roots *x509.CertPool
}
func (e Endpoint) validate() error {
host, port, err := net.SplitHostPort(e.Address)
number, portErr := strconv.Atoi(port)
ip := net.ParseIP(host)
if err != nil || portErr != nil || number < 1 || number > 65535 || ip == nil || !ip.IsLoopback() || port != strconv.Itoa(number) || host != ip.String() {
return errors.New("管理证书探测地址必须是本机回环 IP 和有效端口")
}
if !domain.IsValidName(e.Hostname) || net.ParseIP(e.Hostname) != nil || strings.Contains(e.Hostname, "*") || strings.ToLower(strings.TrimSpace(e.Hostname)) != e.Hostname {
return errors.New("管理证书探测域名无效")
}
return nil
}
// Check 每次建立新 TLS 连接,不复用连接、不使用系统代理、不跟随跳转。
// expected 为空时读取已验证的当前证书,否则同时校验实际叶证书字节。
func (e Endpoint) Check(ctx context.Context, expected []byte) ([]byte, error) {
if err := e.validate(); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
_, port, _ := net.SplitHostPort(e.Address)
transport := &http.Transport{
Proxy: nil, DisableKeepAlives: true, DisableCompression: true,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, ServerName: e.Hostname, RootCAs: e.Roots},
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "tcp", e.Address)
},
}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
address := url.URL{Scheme: "https", Host: net.JoinHostPort(e.Hostname, port), Path: "/readyz"}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, address.String(), nil)
if err != nil {
return nil, err
}
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("管理入口 TLS 探测失败:%w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK || response.TLS == nil || len(response.TLS.VerifiedChains) == 0 || len(response.TLS.PeerCertificates) == 0 {
return nil, errors.New("管理入口未通过 HTTPS 就绪检查")
}
actual := response.TLS.PeerCertificates[0].Raw
if len(expected) != 0 && !bytes.Equal(expected, actual) {
return nil, errors.New("管理入口仍未提供预期的新证书")
}
body, err := io.ReadAll(io.LimitReader(response.Body, 4097))
if err != nil || len(body) > 4096 {
return nil, errors.New("管理入口就绪响应读取失败或超过限制")
}
var readiness struct {
Service string `json:"service"`
Status string `json:"status"`
}
if err := json.Unmarshal(body, &readiness); err != nil || readiness.Service != "scert" || readiness.Status != "ready" {
return nil, errors.New("管理入口未返回 Scert 就绪状态")
}
return bytes.Clone(actual), nil
}
func (e Endpoint) Wait(ctx context.Context, expected []byte) error {
if len(expected) == 0 {
return errors.New("等待管理入口切换时必须指定证书")
}
if err := e.validate(); err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
matched := 0
for {
_, err := e.Check(ctx, expected)
if err == nil {
matched++
// 平滑重载期间新旧工作进程可能短暂并存,单次命中不足以确认切换。
if matched == 3 {
return nil
}
} else {
matched = 0
}
timer := time.NewTimer(100 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return errors.Join(ctx.Err(), err)
case <-timer.C:
}
}
}