v2.31.0-dev18 7b9923f

status.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close