@@ -20,13 +20,67 @@ import (
2020 "crypto/x509"
2121 "encoding/pem"
2222 "sort"
23+ "strings"
2324 "time"
2425
2526 corev1 "k8s.io/api/core/v1"
27+ "sigs.k8s.io/controller-runtime/pkg/log"
2628
2729 "github.com/kaasops/envoy-xds-controller/internal/helpers"
2830)
2931
32+ // ValidateDomainPattern checks if a domain pattern is valid.
33+ // Returns an error message if invalid, empty string if valid.
34+ //
35+ // Valid patterns:
36+ // - example.com (exact domain)
37+ // - *.example.com (wildcard - asterisk followed by dot)
38+ //
39+ // Invalid patterns:
40+ // - *example.com (asterisk without dot)
41+ // - **.example.com (double asterisk)
42+ // - example.*.com (asterisk not at start)
43+ // - * (standalone asterisk)
44+ func ValidateDomainPattern (domain string ) string {
45+ if domain == "" {
46+ return "empty domain"
47+ }
48+
49+ // Check for asterisk in the domain
50+ asteriskIdx := strings .Index (domain , "*" )
51+ if asteriskIdx == - 1 {
52+ // No wildcard, valid exact domain
53+ return ""
54+ }
55+
56+ // Wildcard validation
57+ if asteriskIdx != 0 {
58+ return "wildcard (*) must be at the start of domain"
59+ }
60+
61+ if len (domain ) < 3 {
62+ // Need at least "*.x"
63+ return "wildcard domain too short"
64+ }
65+
66+ if domain [1 ] != '.' {
67+ return "wildcard must be followed by dot (e.g., *.example.com, not *example.com)"
68+ }
69+
70+ // Check for multiple asterisks
71+ if strings .Count (domain , "*" ) > 1 {
72+ return "multiple wildcards not allowed"
73+ }
74+
75+ // Check that there's something after "*."
76+ rest := domain [2 :]
77+ if rest == "" || rest == "." {
78+ return "wildcard domain must have at least one label after *."
79+ }
80+
81+ return ""
82+ }
83+
3084// SecretDomainEntry holds information about a secret for domain lookup
3185type SecretDomainEntry struct {
3286 NamespacedName helpers.NamespacedName
@@ -68,108 +122,136 @@ func (idx DomainSecretsIndex) Remove(domain string, nn helpers.NamespacedName) {
68122type validityPriority int
69123
70124const (
71- validityExpired validityPriority = iota // Known expired certificate - lowest priority
72- validityUnknown // Could not parse certificate - medium priority (better than nothing)
73- validityValid // Known valid (non-expired) certificate - highest priority
125+ validityNotFound validityPriority = iota // Domain not found in index - used only for "not found" returns
126+ validityExpired // Known expired certificate - lowest priority for actual certs
127+ validityUnknown // Could not parse certificate - medium priority (better than nothing)
128+ validityValid // Known valid (non-expired) certificate - highest priority
74129)
75130
76- // GetBestSecret returns the best secret for a domain with preference for the given namespace.
77- // Selection logic:
78- // 1. If only one secret exists - return it (if it exists in secrets map)
79- // 2. Priority by validity: valid > unknown > expired
80- // 3. Among same validity: prefer same namespace
81- // 4. Final tie-breaker: alphabetically by namespace/name
82- //
83- // Note: Secrets with unparseable certificates (validityUnknown) are ranked below valid
84- // certificates but above expired ones. This ensures we prefer known-good certificates
85- // while still providing fallback behavior when certificate parsing fails.
86- func (idx DomainSecretsIndex ) GetBestSecret (
131+ // SecretLookupResult contains the result of a secret lookup with additional metadata.
132+ // This provides complete diagnostic information about the secret selection process.
133+ type SecretLookupResult struct {
134+ Secret * corev1.Secret
135+ UsedWildcard bool // true if wildcard secret was used instead of exact
136+ FallbackReason string // reason for fallback: "expired", "unknown", or empty if no fallback
137+ ExactSecretName string // name of the exact secret if it existed (format: "namespace/name")
138+ ExactValidity string // validity of exact secret: "valid", "expired", "unknown", "not_found"
139+ WildcardSecretName string // name of the wildcard secret if it was considered (format: "namespace/name")
140+ WildcardValidity string // validity of wildcard secret: "valid", "expired", "unknown", "not_found"
141+ }
142+
143+ // GetBestSecretWithValidity returns the best secret for a domain along with its validity status.
144+ // This is an optimized version that does a single traversal instead of calling
145+ // GetBestSecret and GetSecretValidity separately.
146+ // Returns (nil, validityNotFound) if domain not found in index.
147+ func (idx DomainSecretsIndex ) GetBestSecretWithValidity (
87148 domain string ,
88149 preferredNamespace string ,
89150 secrets map [helpers.NamespacedName ]* corev1.Secret ,
90- ) * corev1.Secret {
151+ ) ( * corev1.Secret , validityPriority ) {
91152 entries , exists := idx [domain ]
92153 if ! exists || len (entries ) == 0 {
93- return nil
154+ return nil , validityNotFound
94155 }
95156
157+ // Capture time once for consistent validity checks throughout this call
158+ now := time .Now ()
159+
96160 // Fast path: single secret
161+ // Note: using range to get the single element from a map is a Go idiom
97162 if len (entries ) == 1 {
98- for nn := range entries {
99- // Defensive nil check for consistency between index and secrets map
100- if secret := secrets [ nn ]; secret ! = nil {
101- return secret
163+ for nn , entry := range entries {
164+ secret := secrets [ nn ]
165+ if secret = = nil {
166+ return nil , validityNotFound // indexed but missing from secrets map
102167 }
103- return nil
168+ validity := getValidityFromEntry (entry , now )
169+ return secret , validity
104170 }
105171 }
106172
107- now := time .Now ()
108-
109- // Collect entries into a slice for sorting
173+ // Collect and sort candidates
110174 type candidateEntry struct {
111175 nn helpers.NamespacedName
112- notAfter time.Time
113176 validity validityPriority
114177 isSameNs bool
115178 }
116179
117180 candidates := make ([]candidateEntry , 0 , len (entries ))
118181 for nn , entry := range entries {
119- // Skip entries that don't exist in the secrets map (defensive check)
120182 if secrets [nn ] == nil {
121183 continue
122184 }
123185
124186 var validity validityPriority
125187 switch {
126188 case entry .NotAfter .IsZero ():
127- // Certificate parsing failed - treat as unknown validity
128189 validity = validityUnknown
129190 case entry .NotAfter .After (now ):
130- // Certificate is valid (not expired)
131191 validity = validityValid
132192 default :
133- // Certificate is expired
134193 validity = validityExpired
135194 }
136195
137196 candidates = append (candidates , candidateEntry {
138197 nn : nn ,
139- notAfter : entry .NotAfter ,
140198 validity : validity ,
141199 isSameNs : nn .Namespace == preferredNamespace ,
142200 })
143201 }
144202
145- // No valid candidates found
146203 if len (candidates ) == 0 {
147- return nil
204+ return nil , validityNotFound // all indexed secrets missing from secrets map
148205 }
149206
150- // Sort candidates by priority:
151- // 1. Higher validity priority first (valid > unknown > expired)
152- // 2. Same namespace first
153- // 3. Alphabetically by namespace/name
207+ // Sort: validity desc, same namespace first, then alphabetically
154208 sort .Slice (candidates , func (i , j int ) bool {
155- // Higher validity priority first
156209 if candidates [i ].validity != candidates [j ].validity {
157210 return candidates [i ].validity > candidates [j ].validity
158211 }
159- // Same namespace first
160212 if candidates [i ].isSameNs != candidates [j ].isSameNs {
161213 return candidates [i ].isSameNs
162214 }
163- // Alphabetically by namespace
164215 if candidates [i ].nn .Namespace != candidates [j ].nn .Namespace {
165216 return candidates [i ].nn .Namespace < candidates [j ].nn .Namespace
166217 }
167- // Alphabetically by name
168218 return candidates [i ].nn .Name < candidates [j ].nn .Name
169219 })
170220
171- // Return the best candidate
172- return secrets [candidates [0 ].nn ]
221+ best := candidates [0 ]
222+ return secrets [best .nn ], best .validity
223+ }
224+
225+ // getValidityFromEntry determines validity from a single entry.
226+ // The now parameter ensures consistent time comparison across all validity checks.
227+ func getValidityFromEntry (entry SecretDomainEntry , now time.Time ) validityPriority {
228+ switch {
229+ case entry .NotAfter .IsZero ():
230+ return validityUnknown
231+ case entry .NotAfter .After (now ):
232+ return validityValid
233+ default :
234+ return validityExpired
235+ }
236+ }
237+
238+ // GetBestSecret returns the best secret for a domain with preference for the given namespace.
239+ // Selection logic:
240+ // 1. If only one secret exists - return it (if it exists in secrets map)
241+ // 2. Priority by validity: valid > unknown > expired
242+ // 3. Among same validity: prefer same namespace
243+ // 4. Final tie-breaker: alphabetically by namespace/name
244+ //
245+ // Note: Secrets with unparseable certificates (validityUnknown) are ranked below valid
246+ // certificates but above expired ones. This ensures we prefer known-good certificates
247+ // while still providing fallback behavior when certificate parsing fails.
248+ func (idx DomainSecretsIndex ) GetBestSecret (
249+ domain string ,
250+ preferredNamespace string ,
251+ secrets map [helpers.NamespacedName ]* corev1.Secret ,
252+ ) * corev1.Secret {
253+ secret , _ := idx .GetBestSecretWithValidity (domain , preferredNamespace , secrets )
254+ return secret
173255}
174256
175257// GetAnySecret returns any valid secret for a domain (for backward compatibility)
@@ -186,18 +268,27 @@ func (idx DomainSecretsIndex) GetAnySecret(
186268// expiration time to ensure we consider the most restrictive validity period.
187269// This handles cases where the end-entity certificate expires before intermediate/root CAs.
188270// Returns zero time if no valid certificates could be parsed.
271+ // Logs warnings for parsing errors to aid debugging in production.
189272func ParseCertificateNotAfter (secret * corev1.Secret ) time.Time {
273+ logger := log .Log .WithName ("certificate-parser" )
274+
190275 if secret == nil {
191276 return time.Time {}
192277 }
193278
279+ secretKey := secret .Namespace + "/" + secret .Name
280+
194281 certData , ok := secret .Data [corev1 .TLSCertKey ]
195282 if ! ok || len (certData ) == 0 {
283+ logger .V (1 ).Info ("Secret missing tls.crt data" ,
284+ "secret" , secretKey )
196285 return time.Time {}
197286 }
198287
199288 var minNotAfter time.Time
200289 rest := certData
290+ blockIndex := 0
291+ parseErrors := 0
201292
202293 // Parse all PEM blocks in the certificate chain
203294 for {
@@ -206,6 +297,7 @@ func ParseCertificateNotAfter(secret *corev1.Secret) time.Time {
206297 break
207298 }
208299 rest = remaining
300+ blockIndex ++
209301
210302 // Skip non-certificate blocks (e.g., private keys that might be included)
211303 if block .Type != "CERTIFICATE" {
@@ -214,6 +306,11 @@ func ParseCertificateNotAfter(secret *corev1.Secret) time.Time {
214306
215307 cert , err := x509 .ParseCertificate (block .Bytes )
216308 if err != nil {
309+ parseErrors ++
310+ logger .V (1 ).Info ("Failed to parse certificate in chain" ,
311+ "secret" , secretKey ,
312+ "blockIndex" , blockIndex ,
313+ "error" , err .Error ())
217314 continue
218315 }
219316
@@ -223,5 +320,13 @@ func ParseCertificateNotAfter(secret *corev1.Secret) time.Time {
223320 }
224321 }
225322
323+ // Log warning if all certificates failed to parse
324+ if minNotAfter .IsZero () && blockIndex > 0 {
325+ logger .Info ("Failed to parse any certificates from secret" ,
326+ "secret" , secretKey ,
327+ "totalBlocks" , blockIndex ,
328+ "parseErrors" , parseErrors )
329+ }
330+
226331 return minNotAfter
227332}
0 commit comments