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}