main bac21c6

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}

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