Files
kevin bf4744bb1d
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
feat: 添加服务端证书合法性验证(自定义CA/跳过验证/证书固定)
- 新增 internal/tlsconfig 包,集中构建 TLS 配置
- 修复 CDN 边缘 IP 故障转移时 HTTP 登录 TLS 验证失败的问题
- 支持自定义 CA 证书(内联 PEM + 文件路径,合并生效)
- 支持 InsecureSkipVerify 跳过证书验证
- 支持证书固定(SHA-256 指纹校验)
- TLS 验证错误设为不可恢复,避免无限重试
- Profile 编辑界面新增 TLS 设置区域,协议联动启用/禁用
- DB schema v4 迁移,新增 4 个 TLS 字段
2026-07-08 11:33:36 +08:00

158 lines
4.1 KiB
Go

// Package tlsconfig builds *tls.Config instances from user-supplied
// verification settings (custom CA, insecure mode, certificate
// pinning). It is shared by the WebSocket transport and the HTTP
// login client so both paths enforce identical TLS policy.
package tlsconfig
import (
"bytes"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"
)
// Config holds TLS verification settings for a server connection.
type Config struct {
ServerName string // TLS SNI hostname (for CDN edge IP connections)
CACertPEM string // inline CA certificate PEM content
CACertPath string // path to a CA certificate file
InsecureSkipVerify bool // skip chain verification entirely
PinnedCertHash string // SHA-256 fingerprint of the leaf cert (hex, optional "sha256:" prefix)
}
// Build creates a *tls.Config from the given settings.
//
// Verification behaviour matrix:
//
// Pin set | Insecure | Behaviour
// --------+----------+-------------------------------------------
// no | no | Normal chain + hostname verification (Go default)
// no | yes | All verification skipped
// yes | no | Normal chain verification, then pin check
// yes | yes | Chain skipped, pin check only
func Build(cfg Config) (*tls.Config, error) {
tlsCfg := &tls.Config{
ServerName: cfg.ServerName,
}
if cfg.CACertPEM != "" || cfg.CACertPath != "" {
pool, err := buildCertPool(cfg.CACertPEM, cfg.CACertPath)
if err != nil {
return nil, fmt.Errorf("load CA cert: %w", err)
}
tlsCfg.RootCAs = pool
}
var pin []byte
if cfg.PinnedCertHash != "" {
var err error
pin, err = decodePin(cfg.PinnedCertHash)
if err != nil {
return nil, fmt.Errorf("invalid pinned cert hash: %w", err)
}
}
if pin != nil {
if cfg.InsecureSkipVerify {
tlsCfg.InsecureSkipVerify = true
}
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
return verifyPin(rawCerts, pin)
}
} else if cfg.InsecureSkipVerify {
tlsCfg.InsecureSkipVerify = true
}
return tlsCfg, nil
}
// PinMismatchError indicates the server leaf certificate does not
// match the pinned SHA-256 fingerprint.
type PinMismatchError struct {
Expected []byte
Got []byte
}
func (e *PinMismatchError) Error() string {
return fmt.Sprintf("certificate pin mismatch: expected %x, got %x", e.Expected, e.Got)
}
// IsTLSError reports whether err represents a TLS certificate
// verification failure that will not resolve on retry.
func IsTLSError(err error) bool {
var certInvalid *x509.CertificateInvalidError
if errors.As(err, &certInvalid) {
return true
}
var hostErr *x509.HostnameError
if errors.As(err, &hostErr) {
return true
}
var unknownAuth *x509.UnknownAuthorityError
if errors.As(err, &unknownAuth) {
return true
}
var verifyErr *tls.CertificateVerificationError
if errors.As(err, &verifyErr) {
return true
}
var pinErr *PinMismatchError
if errors.As(err, &pinErr) {
return true
}
return false
}
func buildCertPool(pemData, path string) (*x509.CertPool, error) {
pool := x509.NewCertPool()
if pemData != "" {
if !pool.AppendCertsFromPEM([]byte(pemData)) {
return nil, errors.New("failed to parse inline CA certificate PEM")
}
}
if path != "" {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read CA cert file %s: %w", path, err)
}
if !pool.AppendCertsFromPEM(data) {
return nil, errors.New("failed to parse CA certificate file")
}
}
return pool, nil
}
func decodePin(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "sha256:") {
s = s[len("sha256:"):]
}
pin, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("invalid hex: %w", err)
}
if len(pin) != sha256.Size {
return nil, fmt.Errorf("expected %d bytes, got %d", sha256.Size, len(pin))
}
return pin, nil
}
func verifyPin(rawCerts [][]byte, pin []byte) error {
if len(rawCerts) == 0 {
return errors.New("no server certificate provided")
}
h := sha256.Sum256(rawCerts[0])
if !bytes.Equal(h[:], pin) {
return &PinMismatchError{Expected: pin, Got: h[:]}
}
return nil
}