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 _, 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}

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