v2.31.0-dev16 8d0a092

renewal.go RawHistoryFolder

1package publictls
2
3import (
4	"context"
5	"fmt"
6	"time"
7
8	"github.com/bnema/zerowrap"
9
10	"github.com/bnema/gordon/internal/boundaries/out"
11	"github.com/bnema/gordon/internal/domain"
12)
13
14// ShouldRenew reports whether a certificate should be renewed at the given time.
15// A certificate with a zero NotAfter is always considered due for renewal.
16func ShouldRenew(cert out.StoredCertificate, now time.Time) bool {
17	if cert.NotAfter.IsZero() {
18		return true
19	}
20	return !now.Before(cert.NotAfter.Add(-domain.TLSRenewalWindow))
21}
22
23// StartRenewalLoop starts a background goroutine that periodically reconciles
24// certificates and renews due certificates. Returns a channel that is closed
25// when the loop exits (ctx is cancelled).
26//
27// If interval <= 0, a default of 1 hour is used.
28func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration) <-chan struct{} {
29	if interval <= 0 {
30		interval = time.Hour
31	}
32
33	done := make(chan struct{})
34
35	// Derive a cancellable context so Stop can terminate the loop.
36	loopCtx, cancel := context.WithCancel(ctx)
37	loopCtx = zerowrap.CtxWithFields(loopCtx, map[string]any{
38		zerowrap.FieldLayer:   "usecase",
39		zerowrap.FieldUseCase: "RenewDueCertificates",
40	})
41
42	// Atomically swap loop state under lock before cancelling the old loop,
43	// preventing overlapping loop goroutines and race conditions.
44	s.mu.Lock()
45	oldCancel := s.cancel
46	oldDone := s.done
47	s.cancel = cancel
48	s.done = done
49	s.mu.Unlock()
50
51	if oldCancel != nil {
52		oldCancel()
53	}
54	if oldDone != nil {
55		select {
56		case <-oldDone:
57		case <-ctx.Done():
58		case <-time.After(30 * time.Second):
59		}
60	}
61
62	go func() {
63		defer close(done)
64		defer cancel()
65
66		ticker := time.NewTicker(interval)
67		defer ticker.Stop()
68
69		log := zerowrap.FromCtx(loopCtx)
70
71		// Startup and reload paths reconcile routes; the periodic loop only renews
72		// due certificates to avoid repeated zone lookups and ACME ordering work.
73		if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {
74			log.Warn().Err(err).Msg("failed to renew due public TLS certificates")
75		}
76
77		for {
78			select {
79			case <-loopCtx.Done():
80				return
81			case <-ticker.C:
82				if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {
83					log.Warn().Err(err).Msg("failed to renew due public TLS certificates")
84				}
85			}
86		}
87	}()
88
89	return done
90}
91
92// renewDueCertificates renews all certificates in the cache that are due
93// for renewal according to ShouldRenew.
94//
95// Per-certificate errors are recorded in lastErr and do not halt the loop.
96// Returns nil unless the store or issuer is nil (systemic error).
97func (s *Service) renewDueCertificates(ctx context.Context, now time.Time) error {
98	log := zerowrap.FromCtx(ctx)
99	if !s.cfg.Enabled {
100		return nil
101	}
102	if s.deps.Store == nil {
103		return fmt.Errorf("%w: certificate store is nil", domain.ErrCertificateStoreRequired)
104	}
105	if s.deps.Issuer == nil {
106		return fmt.Errorf("%w: certificate issuer is nil", domain.ErrCertificateIssuerRequired)
107	}
108
109	// Collect due certs from in-memory cache under s.mu (no store lock needed).
110	s.mu.Lock()
111	due := make([]out.StoredCertificate, 0, len(s.certs))
112	for _, cert := range s.certs {
113		if ShouldRenew(*cert, now) {
114			due = append(due, *cert)
115		}
116	}
117	s.mu.Unlock()
118
119	for _, cert := range due {
120		if err := ctx.Err(); err != nil {
121			return err
122		}
123		renewed, err := s.deps.Issuer.Renew(ctx, cert)
124		if err != nil {
125			s.mu.Lock()
126			s.lastErr[cert.ID] = fmt.Sprintf("renew: %v", err)
127			s.mu.Unlock()
128			continue
129		}
130
131		if renewed == nil {
132			s.mu.Lock()
133			s.lastErr[cert.ID] = "renew returned nil certificate"
134			s.mu.Unlock()
135			continue
136		}
137		if err := populateStoredCertificate(renewed); err != nil {
138			s.mu.Lock()
139			s.lastErr[cert.ID] = err.Error()
140			s.mu.Unlock()
141			continue
142		}
143		renewed.LastError = ""
144
145		// Acquire store lock only around Save to avoid holding it across
146		// the potentially long-running Issuer.Renew call.
147		unlock, err := s.deps.Store.Lock(ctx)
148		if err != nil {
149			s.mu.Lock()
150			s.lastErr[cert.ID] = fmt.Sprintf("acquire store lock: %v", err)
151			s.mu.Unlock()
152			continue
153		}
154
155		saveErr := s.deps.Store.Save(ctx, *renewed)
156		if unlockErr := unlock(); unlockErr != nil {
157			log.Warn().Err(unlockErr).Msg("failed to release store lock")
158		}
159
160		if saveErr != nil {
161			s.mu.Lock()
162			s.lastErr[cert.ID] = fmt.Sprintf("save renewed certificate: %v", saveErr)
163			s.mu.Unlock()
164			continue
165		}
166
167		s.mu.Lock()
168		s.certs[renewed.ID] = renewed
169		delete(s.lastErr, renewed.ID)
170		s.mu.Unlock()
171	}
172
173	return nil
174}

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