123 lines
3.9 KiB
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:
|
|
}
|
|
}
|
|
}
|