From 2140e5b0b512ff97acb323cea6b8378f424c00e8 Mon Sep 17 00:00:00 2001 From: jimmyGALLAND Date: Mon, 22 Sep 2025 00:30:51 +0200 Subject: [PATCH] -Add support for including Subject Alternative Names (SANs) from existing certificates during both manual and automatic renewals. -Enhance filtering and normalization of domain names from the UI to ensure only valid domains are included when requesting certificates. --- src/mod/acme/acme.go | 43 ++++++++++++++++++++++--- src/mod/acme/autorenew.go | 9 ++++++ src/mod/acme/utils.go | 67 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/src/mod/acme/acme.go b/src/mod/acme/acme.go index 99f98f6..e38415a 100644 --- a/src/mod/acme/acme.go +++ b/src/mod/acme/acme.go @@ -432,6 +432,21 @@ func (a *ACMEHandler) HandleGetExpiredDomains(w http.ResponseWriter, r *http.Req // to renew the certificate, and sends a JSON response indicating the result of the renewal process. func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Request) { domainPara, err := utils.PostPara(r, "domains") + + //Clean each domain + cleanedDomains := []string{} + if (domainPara != "") { + for _, d := range strings.Split(domainPara, ",") { + // Apply normalization on each domain + nd, err := NormalizeDomain(d) + if err != nil { + utils.SendErrorResponse(w, jsonEscape(err.Error())) + return + } + cleanedDomains = append(cleanedDomains, nd) + } + } + if err != nil { utils.SendErrorResponse(w, jsonEscape(err.Error())) return @@ -492,7 +507,6 @@ func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Requ dns = true } - domains := strings.Split(domainPara, ",") // Default propagation timeout is 300 seconds propagationTimeout := 300 @@ -511,12 +525,31 @@ func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Requ } } - //Clean spaces in front or behind each domain - cleanedDomains := []string{} - for _, domain := range domains { - cleanedDomains = append(cleanedDomains, strings.TrimSpace(domain)) + // Extract SANs from existing PEM to ensure all domains are included + pemPath := fmt.Sprintf("./conf/certs/%s.pem", filename) + sanDomains, err := ExtractDomainsFromPEM(pemPath) + if err == nil { + // Merge domainPara + SANs + domainSet := map[string]struct{}{} + for _, d := range cleanedDomains { + domainSet[d] = struct{}{} + } + for _, d := range sanDomains { + domainSet[d] = struct{}{} + } + + // Rebuild cleanedDomains with all unique domains + cleanedDomains = []string{} + for d := range domainSet { + cleanedDomains = append(cleanedDomains, d) + } + + a.Logf("Renewal domains including SANs from PEM: "+strings.Join(cleanedDomains, ","), nil) + } else { + a.Logf("Could not extract SANs from PEM, using domainPara only", err) } + // Extract DNS servers from the request var dnsServers []string dnsServersPara, err := utils.PostPara(r, "dnsServers") diff --git a/src/mod/acme/autorenew.go b/src/mod/acme/autorenew.go index 2595ac2..db10d6d 100644 --- a/src/mod/acme/autorenew.go +++ b/src/mod/acme/autorenew.go @@ -397,6 +397,15 @@ func (a *AutoRenewer) renewExpiredDomains(certs []*ExpiredCerts) ([]string, erro dnsServers = strings.Join(certInfo.DNSServers, ",") } + // Extract SANs from the existing PEM to ensure all domains are included + sanDomains, errSan := ExtractDomainsFromPEM(expiredCert.Filepath) + if errSan == nil && len(sanDomains) > 0 { + expiredCert.Domains = sanDomains + a.Logf("Using SANs from PEM for renewal: "+strings.Join(sanDomains, ","), nil) + } else { + a.Logf("Could not extract SANs from PEM for "+fileName+", using original domains", errSan) + } + _, err = a.AcmeHandler.ObtainCert(expiredCert.Domains, certName, a.RenewerConfig.Email, certInfo.AcmeName, certInfo.AcmeUrl, certInfo.SkipTLS, certInfo.UseDNS, certInfo.PropTimeout, dnsServers) if err != nil { a.Logf("Renew "+fileName+"("+strings.Join(expiredCert.Domains, ",")+") failed", err) diff --git a/src/mod/acme/utils.go b/src/mod/acme/utils.go index fb41135..26a7f2c 100644 --- a/src/mod/acme/utils.go +++ b/src/mod/acme/utils.go @@ -7,6 +7,8 @@ import ( "fmt" "os" "time" + "strings" + "unicode" ) // Get the issuer name from pem file @@ -40,6 +42,8 @@ func ExtractDomains(certBytes []byte) ([]string, error) { return []string{}, errors.New("decode cert bytes failed") } + + func ExtractIssuerName(certBytes []byte) (string, error) { // Parse the PEM block block, _ := pem.Decode(certBytes) @@ -64,6 +68,20 @@ func ExtractIssuerName(certBytes []byte) (string, error) { return issuer, nil } +// ExtractDomainsFromPEM reads a PEM certificate file and returns all SANs +func ExtractDomainsFromPEM(pemFilePath string) ([]string, error) { + + certBytes, err := os.ReadFile(pemFilePath) + if err != nil { + return nil, err + } + domains,err := ExtractDomains(certBytes) + if err != nil { + return nil, err + } + return domains, nil +} + // Check if a cert is expired by public key func CertIsExpired(certBytes []byte) bool { block, _ := pem.Decode(certBytes) @@ -98,3 +116,52 @@ func CertExpireSoon(certBytes []byte, numberOfDays int) bool { } return false } + + +// NormalizeDomain cleans and validates a domain string. +// - Trims spaces around the domain +// - Converts to lowercase +// - Removes trailing dot (FQDN canonicalization) +// - Checks that the domain conforms to standard rules: +// * Each label ≤ 63 characters +// * Only letters, digits, and hyphens +// * Labels do not start or end with a hyphen +// * Labels must have >= 2 characters +// * Full domain ≤ 253 characters +// Returns an empty string if the domain is invalid. +func NormalizeDomain(d string) (string, error) { + d = strings.TrimSpace(d) + d = strings.ToLower(d) + d = strings.TrimSuffix(d, ".") + + if len(d) == 0 { + return "", errors.New("domain is empty") + } + if len(d) > 253 { + return "", errors.New("domain exceeds 253 characters") + } + + labels := strings.Split(d, ".") + for _, label := range labels { + if len(label) == 0 { + return "", errors.New("Domain '" + d + "' not valid: Empty label") + } + if len(label) < 2 { + return "", errors.New("Domain '" + d + "' not valid: label '" + label + "' is too short") + } + if len(label) > 63 { + return "", errors.New("Domain not valid: label exceeds 63 characters") + } + + for i, r := range label { + if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-') { + return "", errors.New("Domain '" + d + "' not valid: Invalid character '" + string(r) + "' in label") + } + if (i == 0 || i == len(label)-1) && r == '-' { + return "", errors.New("Domain '" + d + "' not valid: label '"+ label +"' starts or ends with hyphen") + } + } + } + + return d, nil +}