- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听) - 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP) - 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点) - 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
215 lines
4.8 KiB
Go
215 lines
4.8 KiB
Go
package certificate
|
||
|
||
import (
|
||
"crypto"
|
||
"crypto/ecdsa"
|
||
"crypto/rsa"
|
||
"crypto/x509"
|
||
"encoding/pem"
|
||
"fmt"
|
||
"net"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
type ImportInfo struct {
|
||
Primary string
|
||
Domains []string
|
||
Wildcard bool
|
||
ExpiresAt time.Time
|
||
}
|
||
|
||
func InspectImport(certPEM, keyPEM []byte) (ImportInfo, error) {
|
||
cert, err := parseCertificate(certPEM)
|
||
if err != nil {
|
||
return ImportInfo{}, err
|
||
}
|
||
key, err := parsePrivateKey(keyPEM)
|
||
if err != nil {
|
||
return ImportInfo{}, err
|
||
}
|
||
if !publicKeysMatch(cert.PublicKey, key) {
|
||
return ImportInfo{}, fmt.Errorf("证书与私钥不匹配")
|
||
}
|
||
domains, err := ExtractCertDomains(cert)
|
||
if err != nil {
|
||
return ImportInfo{}, err
|
||
}
|
||
return ImportInfo{
|
||
Primary: primaryDomain(domains),
|
||
Domains: domains,
|
||
Wildcard: hasWildcardDomain(domains),
|
||
ExpiresAt: cert.NotAfter.UTC(),
|
||
}, nil
|
||
}
|
||
|
||
func ValidatePEM(certPEM, keyPEM []byte) (time.Time, error) {
|
||
info, err := InspectImport(certPEM, keyPEM)
|
||
if err != nil {
|
||
return time.Time{}, err
|
||
}
|
||
return info.ExpiresAt, nil
|
||
}
|
||
|
||
func ExtractCertDomains(cert *x509.Certificate) ([]string, error) {
|
||
seen := map[string]bool{}
|
||
var domains []string
|
||
add := func(name string) error {
|
||
name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
|
||
if name == "" || net.ParseIP(name) != nil {
|
||
return nil
|
||
}
|
||
if seen[name] {
|
||
return nil
|
||
}
|
||
if strings.HasPrefix(name, "*.") {
|
||
if err := validateDomainLabel(strings.TrimPrefix(name, "*.")); err != nil {
|
||
return err
|
||
}
|
||
} else if err := validateDomainLabel(name); err != nil {
|
||
return err
|
||
}
|
||
seen[name] = true
|
||
domains = append(domains, name)
|
||
return nil
|
||
}
|
||
|
||
for _, name := range cert.DNSNames {
|
||
if err := add(name); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if err := add(cert.Subject.CommonName); err != nil {
|
||
return nil, err
|
||
}
|
||
if len(domains) == 0 {
|
||
return nil, fmt.Errorf("证书中未包含有效域名")
|
||
}
|
||
return domains, nil
|
||
}
|
||
|
||
func primaryDomain(domains []string) string {
|
||
for _, domain := range domains {
|
||
if !strings.HasPrefix(domain, "*.") {
|
||
return domain
|
||
}
|
||
}
|
||
return strings.TrimPrefix(domains[0], "*.")
|
||
}
|
||
|
||
func hasWildcardDomain(domains []string) bool {
|
||
for _, domain := range domains {
|
||
if strings.HasPrefix(domain, "*.") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func validateDomainLabel(domain string) error {
|
||
if domain == "" {
|
||
return fmt.Errorf("域名格式无效")
|
||
}
|
||
parts := strings.Split(domain, ".")
|
||
for _, part := range parts {
|
||
if part == "" || len(part) > 63 {
|
||
return fmt.Errorf("域名格式无效")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func parseCertificate(certPEM []byte) (*x509.Certificate, error) {
|
||
rest := certPEM
|
||
for {
|
||
block, remaining := pem.Decode(rest)
|
||
if block == nil {
|
||
return nil, fmt.Errorf("证书 PEM 格式无效")
|
||
}
|
||
if block.Type == "CERTIFICATE" {
|
||
cert, err := x509.ParseCertificate(block.Bytes)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("解析证书失败:%w", err)
|
||
}
|
||
return cert, nil
|
||
}
|
||
rest = remaining
|
||
if len(rest) == 0 {
|
||
break
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("证书 PEM 中未找到有效证书")
|
||
}
|
||
|
||
func parsePrivateKey(keyPEM []byte) (crypto.PrivateKey, error) {
|
||
block, _ := pem.Decode(keyPEM)
|
||
if block == nil {
|
||
return nil, fmt.Errorf("私钥 PEM 格式无效")
|
||
}
|
||
switch block.Type {
|
||
case "RSA PRIVATE KEY":
|
||
return x509.ParsePKCS1PrivateKey(block.Bytes)
|
||
case "EC PRIVATE KEY":
|
||
return x509.ParseECPrivateKey(block.Bytes)
|
||
case "PRIVATE KEY":
|
||
return x509.ParsePKCS8PrivateKey(block.Bytes)
|
||
default:
|
||
return nil, fmt.Errorf("不支持的私钥类型:%s", block.Type)
|
||
}
|
||
}
|
||
|
||
func publicKeysMatch(certKey crypto.PublicKey, privateKey crypto.PrivateKey) bool {
|
||
switch pub := certKey.(type) {
|
||
case *rsa.PublicKey:
|
||
priv, ok := privateKey.(*rsa.PrivateKey)
|
||
return ok && pub.N.Cmp(priv.N) == 0
|
||
case *ecdsa.PublicKey:
|
||
priv, ok := privateKey.(*ecdsa.PrivateKey)
|
||
return ok && pub.X.Cmp(priv.X) == 0 && pub.Y.Cmp(priv.Y) == 0
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func NormalizePEM(v string) []byte {
|
||
return []byte(strings.TrimSpace(v))
|
||
}
|
||
|
||
func ResolvePEMSource(content, path string) ([]byte, error) {
|
||
path = strings.TrimSpace(path)
|
||
if path != "" {
|
||
return ReadPEMFile(path)
|
||
}
|
||
data := NormalizePEM(content)
|
||
if len(data) == 0 {
|
||
return nil, fmt.Errorf("内容不能为空")
|
||
}
|
||
return data, nil
|
||
}
|
||
|
||
func ReadPEMFile(path string) ([]byte, error) {
|
||
path = strings.TrimSpace(path)
|
||
if path == "" {
|
||
return nil, fmt.Errorf("文件路径不能为空")
|
||
}
|
||
info, err := os.Stat(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("无法读取文件 %s:%w", path, err)
|
||
}
|
||
if info.IsDir() {
|
||
return nil, fmt.Errorf("%s 是目录,不是文件", path)
|
||
}
|
||
if info.Size() > 1<<20 {
|
||
return nil, fmt.Errorf("文件过大(最大 1MB)")
|
||
}
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取文件失败:%w", err)
|
||
}
|
||
if len(strings.TrimSpace(string(data))) == 0 {
|
||
return nil, fmt.Errorf("文件内容为空")
|
||
}
|
||
return data, nil
|
||
}
|