Skip to content

Commit e8dca0f

Browse files
authored
Add wildcard certificate fallback for expired/unknown exact certs (#216)
1 parent 3fe0831 commit e8dca0f

File tree

11 files changed

+1898
-259
lines changed

11 files changed

+1898
-259
lines changed

internal/grpcapi/utils.go

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"crypto/x509"
66
"encoding/pem"
77
"fmt"
8-
"strings"
98
"time"
109

1110
"connectrpc.com/connect"
@@ -15,7 +14,6 @@ import (
1514
"github.com/kaasops/envoy-xds-controller/pkg/api/grpc/util/v1/utilv1connect"
1615
virtual_service_templatev1 "github.com/kaasops/envoy-xds-controller/pkg/api/grpc/virtual_service_template/v1"
1716
"google.golang.org/protobuf/types/known/timestamppb"
18-
corev1 "k8s.io/api/core/v1"
1917
)
2018

2119
type UtilsService struct {
@@ -28,44 +26,38 @@ func NewUtilsService(s store.Store) *UtilsService {
2826
}
2927

3028
// VerifyDomains checks if valid TLS certificates exist for the given domains.
31-
// Note: This intentionally uses MapDomainSecrets() without namespace preference
32-
// because verification checks domain-level certificate availability, not
33-
// VirtualService-specific secret binding. For VirtualService-specific binding,
34-
// see MapDomainSecretsForNamespace() which considers namespace affinity.
29+
// Uses the same wildcard fallback logic as the secrets builder: if exact cert is
30+
// expired/unparseable, falls back to a valid wildcard certificate.
31+
//
32+
// Wildcard matching: Only immediate parent wildcard is checked.
33+
// For example, "api.example.com" will match "*.example.com" but
34+
// "sub.api.example.com" will only match "*.api.example.com" (not "*.example.com").
3535
func (s *UtilsService) VerifyDomains(_ context.Context, req *connect.Request[v1.VerifyDomainsRequest]) (*connect.Response[v1.VerifyDomainsResponse], error) {
3636
results := make([]*v1.DomainVerificationResult, 0, len(req.Msg.Domains))
3737
for _, domain := range req.Msg.Domains {
38-
res := verifyDomainFromSecrets(domain, s.store.MapDomainSecrets())
38+
res := s.verifyDomainWithFallback(domain)
3939
results = append(results, res)
4040
}
4141
return connect.NewResponse(&v1.VerifyDomainsResponse{Results: results}), nil
4242
}
4343

44-
func verifyDomainFromSecrets(domain string, secrets map[string]*corev1.Secret) *v1.DomainVerificationResult {
44+
// verifyDomainWithFallback checks certificate for a domain using the same
45+
// wildcard fallback logic as the secrets builder
46+
func (s *UtilsService) verifyDomainWithFallback(domain string) *v1.DomainVerificationResult {
4547
result := &v1.DomainVerificationResult{Domain: domain}
46-
var matchedSecret *corev1.Secret
47-
var matchedByWildcard bool
48-
49-
if secret, ok := secrets[domain]; ok {
50-
matchedSecret = secret
51-
} else {
52-
parts := strings.Split(domain, ".")
53-
for i := 1; i < len(parts)-1; i++ {
54-
wildcard := "*." + strings.Join(parts[i:], ".")
55-
if secret, ok := secrets[wildcard]; ok {
56-
matchedSecret = secret
57-
matchedByWildcard = true
58-
break
59-
}
60-
}
61-
}
6248

63-
if matchedSecret == nil {
49+
// Use store's wildcard fallback logic - empty namespace for domain-level check
50+
lookupResult := s.store.GetDomainSecretWithWildcardFallbackInfo(domain, "")
51+
52+
if lookupResult.Secret == nil {
6453
result.Error = "no matching certificate found"
6554
return result
6655
}
6756

68-
crtBytes, ok := matchedSecret.Data["tls.crt"]
57+
result.MatchedByWildcard = lookupResult.UsedWildcard
58+
59+
// Parse and validate the matched certificate
60+
crtBytes, ok := lookupResult.Secret.Data["tls.crt"]
6961
if !ok {
7062
result.Error = "missing tls.crt in secret"
7163
return result
@@ -85,7 +77,10 @@ func verifyDomainFromSecrets(domain string, secrets map[string]*corev1.Secret) *
8577

8678
result.Issuer = cert.Issuer.CommonName
8779
result.ExpiresAt = timestamppb.New(cert.NotAfter)
88-
result.MatchedByWildcard = matchedByWildcard
80+
81+
// Note: lookupResult also contains FallbackReason and ExactValidity
82+
// for debugging purposes, but the proto doesn't support these fields yet.
83+
// When the proto is updated, this info can be exposed in the API response.
8984

9085
if time.Now().After(cert.NotAfter) {
9186
result.Error = "certificate expired"

internal/store/domain_secrets.go

Lines changed: 147 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -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
3185
type SecretDomainEntry struct {
3286
NamespacedName helpers.NamespacedName
@@ -68,108 +122,136 @@ func (idx DomainSecretsIndex) Remove(domain string, nn helpers.NamespacedName) {
68122
type validityPriority int
69123

70124
const (
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.
189272
func 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

Comments
 (0)