1package publictls
2
3import (
4 "context"
5 "regexp"
6 "sort"
7 "time"
8
9 "github.com/bnema/gordon/internal/domain"
10)
11
12// Status returns the current public TLS status.
13func (s *Service) Status(ctx context.Context) domain.PublicTLSStatus {
14 // Collect route domains outside the lock so route source methods
15 // (GetRoutes/GetExternalRoutes) are not called while holding s.mu.
16 routeDomains := collectRouteDomains(ctx, s.deps.Routes)
17
18 s.mu.Lock()
19 defer s.mu.Unlock()
20
21 status := domain.PublicTLSStatus{
22 ACMEEnabled: s.cfg.Enabled,
23 }
24
25 if s.deps.Effective.Mode != "" {
26 status.ConfiguredMode = s.deps.Effective.ConfiguredMode
27 status.EffectiveMode = s.deps.Effective.Mode
28 status.SelectionReason = s.deps.Effective.Reason
29 status.TokenSource = s.deps.Effective.TokenSource
30 }
31
32 status.Certificates = s.buildManagedCertificatesLocked()
33 status.Routes = s.buildRouteCoverageLocked(routeDomains, status.Certificates)
34 status.Errors = buildStatusErrors(status.Certificates, status.Routes)
35 return status
36}
37
38// buildManagedCertificatesLocked returns sorted managed certificates.
39// Must be called with s.mu held.
40func (s *Service) buildManagedCertificatesLocked() []domain.ManagedCertificate {
41 certs := make([]domain.ManagedCertificate, 0, len(s.certs))
42 now := time.Now()
43 for _, cert := range s.certs {
44 lastErr := cert.LastError
45 if e, ok := s.lastErr[cert.ID]; ok && e != "" {
46 lastErr = e
47 }
48 mc := domain.ManagedCertificate{
49 ID: cert.ID,
50 Names: cert.Names,
51 Challenge: cert.Challenge,
52 NotAfter: cert.NotAfter,
53 LastError: sanitizeError(lastErr),
54 }
55 mc.Status = mc.Health(now)
56 certs = append(certs, mc)
57 }
58 sort.Slice(certs, func(i, j int) bool {
59 return certs[i].ID < certs[j].ID
60 })
61 return certs
62}
63
64func buildStatusErrors(certs []domain.ManagedCertificate, routes []domain.TLSRouteCoverage) []string {
65 seen := make(map[string]struct{})
66 var errorsList []string
67 add := func(message string) {
68 message = sanitizeError(message)
69 if message == "" {
70 return
71 }
72 if _, ok := seen[message]; ok {
73 return
74 }
75 seen[message] = struct{}{}
76 errorsList = append(errorsList, message)
77 }
78 for _, cert := range certs {
79 add(cert.LastError)
80 }
81 for _, route := range routes {
82 add(route.Error)
83 }
84 return errorsList
85}
86
87// buildRouteCoverageLocked returns route coverage for the given domains.
88// Must be called with s.mu held.
89func (s *Service) buildRouteCoverageLocked(routeDomains []string, managedCerts []domain.ManagedCertificate) []domain.TLSRouteCoverage {
90 sort.Strings(routeDomains)
91
92 now := time.Now()
93 routes := make([]domain.TLSRouteCoverage, 0, len(routeDomains))
94 for _, d := range routeDomains {
95 coverage := domain.TLSRouteCoverage{
96 Domain: d,
97 RequiredACME: s.cfg.Enabled,
98 }
99 coverage.Covered, coverage.CoveredBy = s.findCoverageLocked(d, managedCerts, now)
100 if err, hasErr := s.routeErr[d]; hasErr {
101 coverage.Error = sanitizeError(err)
102 }
103 routes = append(routes, coverage)
104 }
105 return routes
106}
107
108// collectRouteDomains returns all unique canonical route domains.
109// Does not hold s.mu so route source methods may be called safely.
110func collectRouteDomains(ctx context.Context, routes RouteSource) []string {
111 routeSet := make(map[string]struct{})
112 var domains []string
113
114 for _, r := range routes.GetRoutes(ctx) {
115 canonical, ok := domain.CanonicalRouteDomain(r.Domain)
116 if ok {
117 if _, exists := routeSet[canonical]; !exists {
118 routeSet[canonical] = struct{}{}
119 domains = append(domains, canonical)
120 }
121 }
122 }
123 for h := range routes.GetExternalRoutes() {
124 canonical, ok := domain.CanonicalRouteDomain(h)
125 if ok {
126 if _, exists := routeSet[canonical]; !exists {
127 routeSet[canonical] = struct{}{}
128 domains = append(domains, canonical)
129 }
130 }
131 }
132 return domains
133}
134
135// findCoverageLocked checks if any managed certificate covers the given domain.
136// Must be called with s.mu held.
137func (s *Service) findCoverageLocked(routeDomain string, managedCerts []domain.ManagedCertificate, now time.Time) (bool, string) {
138 for _, mc := range managedCerts {
139 if !mc.Covers(routeDomain) {
140 continue
141 }
142 switch mc.Status {
143 case domain.TLSCertificateStatusExpired, domain.TLSCertificateStatusMissing:
144 continue
145 }
146 // Double-check the raw stored cert expiry to guard against stale
147 // ManagedCertificate.Status values that may not reflect real expiry.
148 if sc, ok := s.certs[mc.ID]; ok {
149 if !sc.NotAfter.IsZero() && !now.Before(sc.NotAfter) {
150 continue
151 }
152 }
153 return true, mc.ID
154 }
155 return false, ""
156}
157
158// Sensitive key names that may appear in error messages.
159var sensitiveKeys = `(token|secret|key|password|pass|api_key|apikey|auth)`
160
161// Patterns for redacting sensitive values in common formats.
162var (
163 // key=value (unquoted, case-insensitive)
164 keyEqValueRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `=[^\s"']+`)
165 // key="value" (double-quoted)
166 keyEqQuotedRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `="[^"]*"`)
167 // key='value' (single-quoted)
168 keyEqSingleQuotedRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `='[^']*'`)
169 // JSON "key": "value"
170 jsonKeyValueRe = regexp.MustCompile(`(?i)"` + sensitiveKeys + `"\s*:\s*"[^"]*"`)
171 // YAML key: value (start of line or after indent)
172 yamlKeyValueRe = regexp.MustCompile(`(?i)(?m)^\s*` + sensitiveKeys + `\s*:\s+\S+`)
173)
174
175// sanitizeError removes obvious sensitive strings from error messages.
176// It redacts key=value, key="value", key='value', JSON "key": "value",
177// and YAML key: value patterns for known sensitive keys while preserving
178// safe context. It does not blanket-redact messages just because they
179// contain the word "secret" or similar.
180func sanitizeError(err string) string {
181 if err == "" {
182 return ""
183 }
184
185 result := keyEqValueRe.ReplaceAllString(err, "$1=redacted")
186 result = keyEqQuotedRe.ReplaceAllString(result, `$1="redacted"`)
187 result = keyEqSingleQuotedRe.ReplaceAllString(result, "$1='redacted'")
188 result = jsonKeyValueRe.ReplaceAllString(result, `"$1":"redacted"`)
189 result = yamlKeyValueRe.ReplaceAllString(result, "$1: redacted")
190 return result
191}