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 perform an immediate reconcile; the loop still
72 // renews due certificates right away so already-managed certs do not wait
73 // an extra interval before renewal.
74 if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {
75 log.Warn().Err(err).Msg("failed to renew due public TLS certificates")
76 }
77
78 for {
79 select {
80 case <-loopCtx.Done():
81 return
82 case <-ticker.C:
83 if err := s.Reconcile(loopCtx); err != nil {
84 log.Warn().Err(err).Msg("failed to reconcile public TLS certificates")
85 }
86 if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {
87 log.Warn().Err(err).Msg("failed to renew due public TLS certificates")
88 }
89 }
90 }
91 }()
92
93 return done
94}
95
96// renewDueCertificates renews all certificates in the cache that are due
97// for renewal according to ShouldRenew.
98//
99// Per-certificate errors are recorded in lastErr and do not halt the loop.
100// Returns nil unless the store or issuer is nil (systemic error).
101func (s *Service) renewDueCertificates(ctx context.Context, now time.Time) error {
102 log := zerowrap.FromCtx(ctx)
103 if !s.cfg.Enabled {
104 return nil
105 }
106 if s.deps.Store == nil {
107 return fmt.Errorf("%w: certificate store is nil", domain.ErrCertificateStoreRequired)
108 }
109 if s.deps.Issuer == nil {
110 return fmt.Errorf("%w: certificate issuer is nil", domain.ErrCertificateIssuerRequired)
111 }
112
113 // Collect due certs from in-memory cache under s.mu (no store lock needed).
114 s.mu.Lock()
115 due := make([]out.StoredCertificate, 0, len(s.certs))
116 for _, cert := range s.certs {
117 if ShouldRenew(*cert, now) {
118 due = append(due, *cert)
119 }
120 }
121 s.mu.Unlock()
122
123 for _, cert := range due {
124 if err := ctx.Err(); err != nil {
125 return err
126 }
127 renewed, err := s.deps.Issuer.Renew(ctx, cert)
128 if err != nil {
129 s.mu.Lock()
130 s.lastErr[cert.ID] = fmt.Sprintf("renew: %v", err)
131 s.mu.Unlock()
132 continue
133 }
134
135 if renewed == nil {
136 s.mu.Lock()
137 s.lastErr[cert.ID] = "renew returned nil certificate"
138 s.mu.Unlock()
139 continue
140 }
141 if err := populateStoredCertificate(renewed); err != nil {
142 s.mu.Lock()
143 s.lastErr[cert.ID] = err.Error()
144 s.mu.Unlock()
145 continue
146 }
147 renewed.LastError = ""
148
149 // Acquire store lock only around Save to avoid holding it across
150 // the potentially long-running Issuer.Renew call.
151 unlock, err := s.deps.Store.Lock(ctx)
152 if err != nil {
153 s.mu.Lock()
154 s.lastErr[cert.ID] = fmt.Sprintf("acquire store lock: %v", err)
155 s.mu.Unlock()
156 continue
157 }
158
159 saveErr := s.deps.Store.Save(ctx, *renewed)
160 if unlockErr := unlock(); unlockErr != nil {
161 log.Warn().Err(unlockErr).Msg("failed to release store lock")
162 }
163
164 if saveErr != nil {
165 s.mu.Lock()
166 s.lastErr[cert.ID] = fmt.Sprintf("save renewed certificate: %v", saveErr)
167 s.mu.Unlock()
168 continue
169 }
170
171 s.mu.Lock()
172 s.certs[renewed.ID] = renewed
173 delete(s.lastErr, renewed.ID)
174 s.mu.Unlock()
175 }
176
177 return nil
178}