service.go RawHistoryFolder

1package pki
2
3import (
4	"context"
5	"crypto/tls"
6	"crypto/x509"
7	"fmt"
8	"strings"
9	"sync"
10	"time"
11
12	"github.com/bnema/zerowrap"
13	"golang.org/x/sync/singleflight"
14
15	"github.com/bnema/gordon/internal/boundaries/out"
16	"github.com/bnema/gordon/internal/domain"
17)
18
19const (
20	renewalWindowRatio = 0.2
21	checkInterval      = 10 * time.Minute
22)
23
24type cachedCert struct {
25	cert      *tls.Certificate
26	expiresAt time.Time
27}
28
29// Service provides on-demand TLS certificate issuance with caching
30// and automatic intermediate CA renewal.
31//
32// TODO: consider adding CRL/OCSP support for certificate revocation,
33// especially given the 10-year root CA lifetime.
34type Service struct {
35	ca             out.CertificateAuthority
36	routes         out.AppRoutes
37	allowedMu      sync.RWMutex
38	allowedDomains map[string]struct{}
39	log            zerowrap.Logger
40
41	cache  sync.Map // map[string]*cachedCert
42	flight singleflight.Group
43	cancel context.CancelFunc
44	done   chan struct{}
45}
46
47// NewService creates a PKI service and starts background maintenance goroutines.
48// It performs an initial intermediate renewal check synchronously so the first
49// TLS handshakes never use a nearly-expired intermediate.
50func NewService(ctx context.Context, ca out.CertificateAuthority, routes out.AppRoutes, allowedDomains []string, log zerowrap.Logger) *Service {
51	ctx, cancel := context.WithCancel(ctx)
52	allowed := canonicalDomainSet(allowedDomains)
53	svc := &Service{
54		ca:             ca,
55		routes:         routes,
56		allowedDomains: allowed,
57		log:            log,
58		cancel:         cancel,
59		done:           make(chan struct{}),
60	}
61	svc.renewIntermediateIfNeeded()
62	go svc.maintenanceLoop(ctx)
63	return svc
64}
65
66// Stop cancels background goroutines and waits for them to finish.
67func (s *Service) Stop() {
68	s.cancel()
69	<-s.done
70}
71
72// CachedCertCount returns the number of cached leaf certificates.
73func (s *Service) CachedCertCount() int {
74	count := 0
75	s.cache.Range(func(_, _ any) bool {
76		count++
77		return true
78	})
79	return count
80}
81
82// GetCertificate is the tls.Config.GetCertificate callback.
83// It returns (nil, nil) for unknown domains so Go's TLS stack
84// falls back to tls.Config.Certificates.
85func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
86	domainName := canonicalServerName(hello.ServerName)
87	if domainName == "" {
88		return nil, nil
89	}
90
91	if cert := s.validCachedCertificate(domainName); cert != nil {
92		if s.isDomainAllowed(hello.Context(), domainName) {
93			return cert, nil
94		}
95		s.cache.Delete(domainName)
96		return nil, nil
97	}
98	if !s.isDomainAllowed(hello.Context(), domainName) {
99		return nil, nil
100	}
101
102	result, err, _ := s.flight.Do(domainName, func() (any, error) {
103		return s.issueCertificate(hello.Context(), domainName)
104	})
105	if err != nil {
106		return nil, err
107	}
108	if result == nil || !s.isDomainAllowed(hello.Context(), domainName) {
109		s.cache.Delete(domainName)
110		return nil, nil
111	}
112	return result.(*tls.Certificate), nil
113}
114
115func canonicalServerName(serverName string) string {
116	return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(serverName), "."))
117}
118
119func (s *Service) validCachedCertificate(domainName string) *tls.Certificate {
120	entry, ok := s.cache.Load(domainName)
121	if !ok {
122		return nil
123	}
124	cached, ok := entry.(*cachedCert)
125	if !ok || !time.Now().Before(cached.expiresAt) {
126		s.cache.Delete(domainName)
127		return nil
128	}
129	return cached.cert
130}
131
132func (s *Service) issueCertificate(ctx context.Context, domainName string) (*tls.Certificate, error) {
133	if ctx == nil {
134		ctx = context.Background()
135	}
136	ctx = zerowrap.WithCtx(ctx, s.log)
137	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
138		zerowrap.FieldLayer:   "usecase",
139		zerowrap.FieldUseCase: "IssueCertificate",
140		"domain":              domainName,
141	})
142	log := zerowrap.FromCtx(ctx)
143
144	if !s.isDomainAllowed(ctx, domainName) {
145		return nil, nil
146	}
147	if cert := s.validCachedCertificate(domainName); cert != nil {
148		return cert, nil
149	}
150
151	cert, err := s.ca.IssueCertificate(domainName)
152	if err != nil {
153		log.Error().Err(err).Msg("failed to issue leaf certificate")
154		return nil, fmt.Errorf("issue leaf certificate for %q: %w", domainName, err)
155	}
156	if !s.isDomainAllowed(ctx, domainName) {
157		return nil, nil
158	}
159
160	s.cache.Store(domainName, &cachedCert{cert: cert, expiresAt: certificateExpiry(cert, s.leafLifetime())})
161	log.Debug().Msg("issued new leaf certificate")
162	return cert, nil
163}
164
165func certificateExpiry(cert *tls.Certificate, fallbackLifetime time.Duration) time.Time {
166	if len(cert.Certificate) > 0 {
167		if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
168			return leaf.NotAfter
169		}
170	}
171	return time.Now().Add(fallbackLifetime)
172}
173
174// SetAdditionalDomains atomically replaces non-route domains authorized for
175// on-demand certificates and evicts certificates that are no longer allowed.
176func (s *Service) SetAdditionalDomains(domains []string) {
177	next := canonicalDomainSet(domains)
178
179	s.allowedMu.Lock()
180	previous := s.allowedDomains
181	s.allowedDomains = next
182	s.allowedMu.Unlock()
183
184	for name := range previous {
185		if _, ok := next[name]; !ok {
186			s.cache.Delete(name)
187		}
188	}
189}
190
191func (s *Service) isDomainAllowed(ctx context.Context, domainName string) bool {
192	s.allowedMu.RLock()
193	_, additionallyAllowed := s.allowedDomains[domainName]
194	s.allowedMu.RUnlock()
195	if additionallyAllowed {
196		return true
197	}
198	for _, h := range s.routes.AppHosts() {
199		if h.Host == domainName && h.TLSMode != domain.AppTLSNever {
200			return true
201		}
202	}
203	if _, ok := s.routes.GetExternalRoutes()[domainName]; ok {
204		return true
205	}
206	return false
207}
208
209func canonicalDomainSet(domains []string) map[string]struct{} {
210	canonical := make(map[string]struct{}, len(domains))
211	for _, name := range domains {
212		name = strings.TrimSuffix(strings.TrimSpace(name), ".")
213		if name, ok := domain.CanonicalRouteDomain(name); ok {
214			canonical[name] = struct{}{}
215		}
216	}
217	return canonical
218}
219
220func (s *Service) leafLifetime() time.Duration {
221	return s.ca.LeafLifetime()
222}
223
224func (s *Service) maintenanceLoop(ctx context.Context) {
225	defer close(s.done)
226	ticker := time.NewTicker(checkInterval)
227	defer ticker.Stop()
228
229	for {
230		select {
231		case <-ctx.Done():
232			return
233		case <-ticker.C:
234			s.renewIntermediateIfNeeded()
235			s.sweepExpiredCerts(ctx)
236		}
237	}
238}
239
240func (s *Service) renewIntermediateIfNeeded() {
241	expiresAt := s.ca.IntermediateExpiresAt()
242	remaining := time.Until(expiresAt)
243	lifetime := s.ca.IntermediateLifetime()
244	window := time.Duration(float64(lifetime) * renewalWindowRatio)
245	if remaining < window {
246		s.log.Info().Time("expires", expiresAt).Msg("renewing intermediate CA")
247		if err := s.ca.RenewIntermediate(); err != nil {
248			s.log.Error().Err(err).Msg("intermediate CA renewal failed")
249		}
250	}
251}
252
253func (s *Service) sweepExpiredCerts(ctx context.Context) {
254	now := time.Now()
255
256	// Fetch app hosts once for all cache entries.
257	hosts := s.routes.AppHosts()
258	extRoutes := s.routes.GetExternalRoutes()
259
260	s.allowedMu.RLock()
261	allowed := make(map[string]struct{}, len(hosts)+len(extRoutes)+len(s.allowedDomains))
262	for domain := range s.allowedDomains {
263		allowed[domain] = struct{}{}
264	}
265	s.allowedMu.RUnlock()
266	for _, h := range hosts {
267		allowed[h.Host] = struct{}{}
268	}
269	for d := range extRoutes {
270		allowed[d] = struct{}{}
271	}
272
273	swept := 0
274	s.cache.Range(func(key, value any) bool {
275		domain := key.(string)
276		cached := value.(*cachedCert)
277		_, ok := allowed[domain]
278		if now.After(cached.expiresAt) || !ok {
279			s.cache.Delete(key)
280			swept++
281		}
282		return true
283	})
284	if swept > 0 {
285		s.log.Debug().Int("swept", swept).Msg("cleaned expired/unauthorized leaf certs from cache")
286	}
287}

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