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 _, h := range routes.AppHosts() {
115 // tls=never interfaces stay plain HTTP: not ACME coverage candidates.
116 if h.TLSMode == domain.AppTLSNever {
117 continue
118 }
119 canonical, ok := domain.CanonicalRouteDomain(h.Host)
120 if ok {
121 if _, exists := routeSet[canonical]; !exists {
122 routeSet[canonical] = struct{}{}
123 domains = append(domains, canonical)
124 }
125 }
126 }
127 for h := range routes.GetExternalRoutes() {
128 canonical, ok := domain.CanonicalRouteDomain(h)
129 if ok {
130 if _, exists := routeSet[canonical]; !exists {
131 routeSet[canonical] = struct{}{}
132 domains = append(domains, canonical)
133 }
134 }
135 }
136 return domains
137}
138
139// findCoverageLocked checks if any managed certificate covers the given domain.
140// Must be called with s.mu held.
141func (s *Service) findCoverageLocked(routeDomain string, managedCerts []domain.ManagedCertificate, now time.Time) (bool, string) {
142 for _, mc := range managedCerts {
143 if !mc.Covers(routeDomain) {
144 continue
145 }
146 switch mc.Status {
147 case domain.TLSCertificateStatusExpired, domain.TLSCertificateStatusMissing:
148 continue
149 }
150 // Double-check the raw stored cert expiry to guard against stale
151 // ManagedCertificate.Status values that may not reflect real expiry.
152 if sc, ok := s.certs[mc.ID]; ok {
153 if !sc.NotAfter.IsZero() && !now.Before(sc.NotAfter) {
154 continue
155 }
156 }
157 return true, mc.ID
158 }
159 return false, ""
160}
161
162// Sensitive key names that may appear in error messages.
163var sensitiveKeys = `(token|secret|key|password|pass|api_key|apikey|auth)`
164
165// Patterns for redacting sensitive values in common formats.
166var (
167 // key=value (unquoted, case-insensitive)
168 keyEqValueRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `=[^\s"']+`)
169 // key="value" (double-quoted)
170 keyEqQuotedRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `="[^"]*"`)
171 // key='value' (single-quoted)
172 keyEqSingleQuotedRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `='[^']*'`)
173 // JSON "key": "value"
174 jsonKeyValueRe = regexp.MustCompile(`(?i)"` + sensitiveKeys + `"\s*:\s*"[^"]*"`)
175 // YAML key: value (start of line or after indent)
176 yamlKeyValueRe = regexp.MustCompile(`(?i)(?m)^\s*` + sensitiveKeys + `\s*:\s+\S+`)
177)
178
179// sanitizeError removes obvious sensitive strings from error messages.
180// It redacts key=value, key="value", key='value', JSON "key": "value",
181// and YAML key: value patterns for known sensitive keys while preserving
182// safe context. It does not blanket-redact messages just because they
183// contain the word "secret" or similar.
184func sanitizeError(err string) string {
185 if err == "" {
186 return ""
187 }
188
189 result := keyEqValueRe.ReplaceAllString(err, "$1=redacted")
190 result = keyEqQuotedRe.ReplaceAllString(result, `$1="redacted"`)
191 result = keyEqSingleQuotedRe.ReplaceAllString(result, "$1='redacted'")
192 result = jsonKeyValueRe.ReplaceAllString(result, `"$1":"redacted"`)
193 result = yamlKeyValueRe.ReplaceAllString(result, "$1: redacted")
194 return result
195}