Scert/backend/internal/domain/domain.go

136 lines
4.2 KiB
Go

package domain
import (
"crypto/rand"
"fmt"
"regexp"
"strings"
"time"
)
var domainPattern = regexp.MustCompile(`^(?i)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$`)
type CertificateType string
const (
CertificateSingle CertificateType = "single"
CertificateWildcard CertificateType = "wildcard"
CertificateSAN CertificateType = "san"
)
const (
ACMEEnvironmentStaging = "staging"
ACMEEnvironmentProduction = "production"
)
type Domain struct {
ResourceScope
ID string `json:"id"`
Name string `json:"name"`
CertificateType CertificateType `json:"certificate_type"`
SANs []string `json:"sans"`
AutoRenew bool `json:"auto_renew"`
ACMEEnvironment string `json:"acme_environment"`
DNSProvider string `json:"dns_provider,omitempty"`
ChallengeType string `json:"challenge_type"`
Status string `json:"status"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func NewDomain(name string, certificateType CertificateType, sans []string) (Domain, error) {
return NewDomainWithOptions(name, certificateType, sans, true, ACMEEnvironmentStaging, "")
}
func NewDomainWithOptions(name string, certificateType CertificateType, sans []string, autoRenew bool, acmeEnvironment string, dnsProvider string) (Domain, error) {
name = strings.ToLower(strings.TrimSpace(name))
if !IsValidName(name) {
return Domain{}, fmt.Errorf("域名格式无效:%s", name)
}
if certificateType != CertificateSingle && certificateType != CertificateWildcard && certificateType != CertificateSAN {
return Domain{}, fmt.Errorf("证书类型无效:%s", certificateType)
}
acmeEnvironment = strings.ToLower(strings.TrimSpace(acmeEnvironment))
if !IsValidACMEEnvironment(acmeEnvironment) {
return Domain{}, fmt.Errorf("ACME 环境无效:%s", acmeEnvironment)
}
dnsProvider = strings.ToLower(strings.TrimSpace(dnsProvider))
cleanSANs := normalizeSANs(sans)
if certificateType == CertificateSAN {
cleanSANs = normalizeSANs(append([]string{name}, cleanSANs...))
}
if certificateType == CertificateSAN && len(cleanSANs) < 2 {
return Domain{}, fmt.Errorf("SAN 证书至少需要两个域名")
}
for _, san := range cleanSANs {
if !IsValidName(san) {
_, err := NewDomain(san, CertificateSingle, nil)
return Domain{}, err
}
}
if certificateType != CertificateSAN {
cleanSANs = []string{name}
}
if certificateType == CertificateWildcard && !strings.HasPrefix(name, "*.") {
return Domain{}, fmt.Errorf("泛域名必须以 *. 开头")
}
if certificateType != CertificateWildcard && strings.HasPrefix(name, "*.") {
return Domain{}, fmt.Errorf("只有泛域名证书类型允许使用 *. 域名")
}
now := time.Now().UTC()
return Domain{
ResourceScope: ResourceScope{ProjectID: DefaultProjectID},
ID: NewID(),
Name: name,
CertificateType: certificateType,
SANs: cleanSANs,
AutoRenew: autoRenew,
ACMEEnvironment: acmeEnvironment,
DNSProvider: dnsProvider,
ChallengeType: ChallengeDNS01,
Status: "pending",
CreatedAt: now,
UpdatedAt: now,
}, nil
}
func IsValidACMEEnvironment(value string) bool {
return value == ACMEEnvironmentStaging || value == ACMEEnvironmentProduction
}
func IsValidName(name string) bool {
name = strings.TrimSpace(strings.ToLower(name))
if strings.HasPrefix(name, "*.") {
name = strings.TrimPrefix(name, "*.")
}
return domainPattern.MatchString(name)
}
func normalizeSANs(sans []string) []string {
seen := make(map[string]struct{}, len(sans))
result := make([]string, 0, len(sans))
for _, item := range sans {
item = strings.ToLower(strings.TrimSpace(item))
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
return result
}
func NewID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}