feat: 添加服务端证书合法性验证(自定义CA/跳过验证/证书固定)
- 新增 internal/tlsconfig 包,集中构建 TLS 配置 - 修复 CDN 边缘 IP 故障转移时 HTTP 登录 TLS 验证失败的问题 - 支持自定义 CA 证书(内联 PEM + 文件路径,合并生效) - 支持 InsecureSkipVerify 跳过证书验证 - 支持证书固定(SHA-256 指纹校验) - TLS 验证错误设为不可恢复,避免无限重试 - Profile 编辑界面新增 TLS 设置区域,协议联动启用/禁用 - DB schema v4 迁移,新增 4 个 TLS 字段
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user