detached 8c0f7e8

Harden public TLS certificate persistence

commit
8c0f7e846ffeb04d17ec085716f1dcd91447dd8d
author
brice · · 2026-04-29 18:03 UTC
parent
32d0f87
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +303 -45
M internal/adapters/out/acmestore/store.go +157 -23
@@ -9,6 +9,7 @@ import (
99 "os"
1010 "path/filepath"
1111 "strings"
12+ "syscall"
1213 "time"
1314
1415 "github.com/bnema/gordon/internal/boundaries/out"
@@ -28,6 +29,8 @@ const (
2829 lockFile = ".lock"
2930 accountFile = "account.json"
3031 certDir = "certs"
32+ backupSuffix = ".old"
33+ tempInfix = ".tmp-"
3134 certFile = "cert.pem"
3235 chainFile = "chain.pem"
3336 fullchainFile = "fullchain.pem"
@@ -42,6 +45,8 @@ const (
4245 pemMode os.FileMode = 0644
4346 )
4447
48+var ErrLockHeld = errors.New("acmestore: lock already held")
49+
4550 // Store implements out.CertificateStore using the local filesystem.
4651 type Store struct {
4752 root string
@@ -56,8 +61,8 @@ func New(root string) (*Store, error) {
5661 if err := os.MkdirAll(root, dirMode); err != nil {
5762 return nil, fmt.Errorf("acmestore: mkdir root: %w", err)
5863 }
59- if err := os.MkdirAll(filepath.Join(root, certDir), dirMode); err != nil {
60- return nil, fmt.Errorf("acmestore: mkdir certs: %w", err)
64+ if _, err := ensureCertsDir(root); err != nil {
65+ return nil, err
6166 }
6267 return &Store{root: root}, nil
6368 }
@@ -114,7 +119,7 @@ func (s *Store) LoadAll(_ context.Context) ([]out.StoredCertificate, error) {
114119 continue
115120 }
116121 // Skip temporary and backup directories created by Save.
117- if strings.Contains(id, ".tmp-") || strings.HasSuffix(id, ".old") {
122+ if isStoreInternalCertDir(id) {
118123 continue
119124 }
120125
@@ -186,12 +191,15 @@ func (s *Store) loadOne(id string) (*out.StoredCertificate, error) {
186191 // All PEM files and metadata are written atomically.
187192 func (s *Store) Save(_ context.Context, cert out.StoredCertificate) error {
188193 if !safeID(cert.ID) {
189- return fmt.Errorf("acmestore: unsafe certificate id %q", cert.ID)
194+ return fmt.Errorf("acmestore: unsafe certificate id %q: %w", cert.ID, domain.ErrPathTraversal)
190195 }
191196
192- parent := filepath.Join(s.root, certDir)
193- if err := os.MkdirAll(parent, dirMode); err != nil {
194- return fmt.Errorf("acmestore: mkdir certs: %w", err)
197+ parent, err := ensureCertsDir(s.root)
198+ if err != nil {
199+ return err
200+ }
201+ if err := recoverCertificateDir(parent, cert.ID); err != nil {
202+ return fmt.Errorf("acmestore: recover cert %s: %w", cert.ID, err)
195203 }
196204
197205 dir := filepath.Join(parent, cert.ID)
@@ -210,7 +218,7 @@ func (s *Store) Save(_ context.Context, cert out.StoredCertificate) error {
210218 return err
211219 }
212220
213- backupDir := dir + ".old"
221+ backupDir := dir + backupSuffix
214222 _ = os.RemoveAll(backupDir)
215223 hadExisting := false
216224 if err := os.Rename(dir, backupDir); err != nil {
@@ -266,11 +274,11 @@ func writeCertificateFiles(dir string, cert out.StoredCertificate) error {
266274 return nil
267275 }
268276
269-// Lock acquires an exclusive lock using a lock file at <root>/.lock.
270-// It is fail-fast and non-blocking: if the lock file already exists, Lock
271-// returns an error immediately without waiting. Context cancellation is
272-// respected before attempting the lock acquisition. The returned unlock
273-// function removes the lock file to clean up.
277+// Lock acquires an exclusive advisory lock using a lock file at <root>/.lock.
278+// It is fail-fast and non-blocking: if another live process holds the lock,
279+// Lock returns an error immediately without waiting. Context cancellation is
280+// respected before attempting acquisition. The returned unlock function releases
281+// the OS advisory lock, closes the file descriptor, and removes the lock file.
274282 func (s *Store) Lock(ctx context.Context) (func() error, error) {
275283 select {
276284 case <-ctx.Done():
@@ -279,26 +287,152 @@ func (s *Store) Lock(ctx context.Context) (func() error, error) {
279287 }
280288
281289 path := filepath.Join(s.root, lockFile)
282- f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, lockMode)
290+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, lockMode)
283291 if err != nil {
284- if os.IsExist(err) {
285- return nil, fmt.Errorf("acmestore: lock already held")
292+ return nil, fmt.Errorf("acmestore: open lock file: %w", err)
293+ }
294+
295+ fd, err := fileDescriptor(f)
296+ if err != nil {
297+ _ = f.Close()
298+ return nil, err
299+ }
300+ if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
301+ _ = f.Close()
302+ if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
303+ return nil, ErrLockHeld
286304 }
287305 return nil, fmt.Errorf("acmestore: acquire lock: %w", err)
288306 }
289- if err := f.Close(); err != nil {
290- os.Remove(path)
291- return nil, fmt.Errorf("acmestore: close lock file: %w", err)
307+
308+ if err := writeLockMetadata(f); err != nil {
309+ _ = syscall.Flock(fd, syscall.LOCK_UN)
310+ _ = f.Close()
311+ return nil, err
292312 }
293313
294314 return func() error {
295- if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
296- return fmt.Errorf("acmestore: release lock: %w", err)
297- }
298- return nil
315+ return releaseLockFile(path, f, fd)
299316 }, nil
300317 }
301318
319+func writeLockMetadata(f *os.File) error {
320+ if err := f.Truncate(0); err != nil {
321+ return fmt.Errorf("acmestore: truncate lock file: %w", err)
322+ }
323+ if _, err := f.Seek(0, 0); err != nil {
324+ return fmt.Errorf("acmestore: seek lock file: %w", err)
325+ }
326+ metadata := fmt.Sprintf("pid=%d\nhost=%s\ncreated_at=%s\n", os.Getpid(), hostname(), time.Now().UTC().Format(time.RFC3339Nano))
327+ if _, err := f.WriteString(metadata); err != nil {
328+ return fmt.Errorf("acmestore: write lock metadata: %w", err)
329+ }
330+ return nil
331+}
332+
333+func releaseLockFile(path string, f *os.File, fd int) error {
334+ var errs []error
335+ if err := syscall.Flock(fd, syscall.LOCK_UN); err != nil {
336+ errs = append(errs, fmt.Errorf("unlock: %w", err))
337+ }
338+ if err := f.Close(); err != nil {
339+ errs = append(errs, fmt.Errorf("close: %w", err))
340+ }
341+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
342+ errs = append(errs, fmt.Errorf("remove: %w", err))
343+ }
344+ if err := errors.Join(errs...); err != nil {
345+ return fmt.Errorf("acmestore: release lock: %w", err)
346+ }
347+ return nil
348+}
349+
350+func fileDescriptor(f *os.File) (int, error) {
351+ fd := f.Fd()
352+ if fd > uintptr(^uint(0)>>1) {
353+ return 0, fmt.Errorf("acmestore: lock file descriptor overflows int")
354+ }
355+ return int(fd), nil
356+}
357+
358+func hostname() string {
359+ host, err := os.Hostname()
360+ if err != nil {
361+ return "unknown"
362+ }
363+ return host
364+}
365+
366+func ensureCertsDir(root string) (string, error) {
367+ certsPath := filepath.Join(root, certDir)
368+ if err := os.MkdirAll(certsPath, dirMode); err != nil {
369+ return "", fmt.Errorf("acmestore: mkdir certs: %w", err)
370+ }
371+ if err := recoverCertificateDirs(certsPath); err != nil {
372+ return "", fmt.Errorf("acmestore: recover certs: %w", err)
373+ }
374+ return certsPath, nil
375+}
376+
377+func isStoreInternalTempDir(id string) bool {
378+ return strings.Contains(id, tempInfix)
379+}
380+
381+func isStoreInternalCertDir(id string) bool {
382+ return isStoreInternalTempDir(id) || strings.HasSuffix(id, backupSuffix)
383+}
384+
385+func recoverCertificateDir(certsPath, id string) error {
386+ if !safeID(id) {
387+ return nil
388+ }
389+ backupDir := filepath.Join(certsPath, id+backupSuffix)
390+ dir := filepath.Join(certsPath, id)
391+ if _, err := os.Stat(dir); err == nil {
392+ return nil
393+ } else if !os.IsNotExist(err) {
394+ return err
395+ }
396+ if _, err := os.Stat(backupDir); err != nil {
397+ if os.IsNotExist(err) {
398+ return nil
399+ }
400+ return err
401+ }
402+ if err := os.Rename(backupDir, dir); err != nil {
403+ return fmt.Errorf("restore backup %s: %w", filepath.Base(backupDir), err)
404+ }
405+ return nil
406+}
407+
408+func recoverCertificateDirs(certsPath string) error {
409+ entries, err := os.ReadDir(certsPath)
410+ if err != nil {
411+ if os.IsNotExist(err) {
412+ return nil
413+ }
414+ return err
415+ }
416+ for _, entry := range entries {
417+ if !entry.IsDir() {
418+ continue
419+ }
420+ name := entry.Name()
421+ if isStoreInternalTempDir(name) {
422+ if err := os.RemoveAll(filepath.Join(certsPath, name)); err != nil {
423+ return fmt.Errorf("remove temp %s: %w", name, err)
424+ }
425+ continue
426+ }
427+ if base, ok := strings.CutSuffix(name, backupSuffix); ok {
428+ if err := recoverCertificateDir(certsPath, base); err != nil {
429+ return err
430+ }
431+ }
432+ }
433+ return nil
434+}
435+
302436 // safeID rejects IDs that could cause path traversal.
303437 func safeID(id string) bool {
304438 return id != "" && !strings.HasPrefix(id, ".") && !strings.Contains(id, "/") && !strings.Contains(id, "\\") && !strings.Contains(id, "..") && !strings.Contains(id, "\x00")
@@ -9,6 +9,7 @@ import (
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11 "strings"11 "strings"
12+ "syscall"
12 "time"13 "time"
13 14
14 "github.com/bnema/gordon/internal/boundaries/out"15 "github.com/bnema/gordon/internal/boundaries/out"
@@ -28,6 +29,8 @@ const (
28 lockFile = ".lock"29 lockFile = ".lock"
29 accountFile = "account.json"30 accountFile = "account.json"
30 certDir = "certs"31 certDir = "certs"
32+ backupSuffix = ".old"
33+ tempInfix = ".tmp-"
31 certFile = "cert.pem"34 certFile = "cert.pem"
32 chainFile = "chain.pem"35 chainFile = "chain.pem"
33 fullchainFile = "fullchain.pem"36 fullchainFile = "fullchain.pem"
@@ -42,6 +45,8 @@ const (
42 pemMode os.FileMode = 064445 pemMode os.FileMode = 0644
43 )46 )
44 47
48+var ErrLockHeld = errors.New("acmestore: lock already held")
49+
45 // Store implements out.CertificateStore using the local filesystem.50 // Store implements out.CertificateStore using the local filesystem.
46 type Store struct {51 type Store struct {
47 root string52 root string
@@ -56,8 +61,8 @@ func New(root string) (*Store, error) {
56 if err := os.MkdirAll(root, dirMode); err != nil {61 if err := os.MkdirAll(root, dirMode); err != nil {
57 return nil, fmt.Errorf("acmestore: mkdir root: %w", err)62 return nil, fmt.Errorf("acmestore: mkdir root: %w", err)
58 }63 }
59- if err := os.MkdirAll(filepath.Join(root, certDir), dirMode); err != nil {64+ if _, err := ensureCertsDir(root); err != nil {
60- return nil, fmt.Errorf("acmestore: mkdir certs: %w", err)65+ return nil, err
61 }66 }
62 return &Store{root: root}, nil67 return &Store{root: root}, nil
63 }68 }
@@ -114,7 +119,7 @@ func (s *Store) LoadAll(_ context.Context) ([]out.StoredCertificate, error) {
114 continue119 continue
115 }120 }
116 // Skip temporary and backup directories created by Save.121 // Skip temporary and backup directories created by Save.
117- if strings.Contains(id, ".tmp-") || strings.HasSuffix(id, ".old") {122+ if isStoreInternalCertDir(id) {
118 continue123 continue
119 }124 }
120 125
@@ -186,12 +191,15 @@ func (s *Store) loadOne(id string) (*out.StoredCertificate, error) {
186 // All PEM files and metadata are written atomically.191 // All PEM files and metadata are written atomically.
187 func (s *Store) Save(_ context.Context, cert out.StoredCertificate) error {192 func (s *Store) Save(_ context.Context, cert out.StoredCertificate) error {
188 if !safeID(cert.ID) {193 if !safeID(cert.ID) {
189- return fmt.Errorf("acmestore: unsafe certificate id %q", cert.ID)194+ return fmt.Errorf("acmestore: unsafe certificate id %q: %w", cert.ID, domain.ErrPathTraversal)
190 }195 }
191 196
192- parent := filepath.Join(s.root, certDir)197+ parent, err := ensureCertsDir(s.root)
193- if err := os.MkdirAll(parent, dirMode); err != nil {198+ if err != nil {
194- return fmt.Errorf("acmestore: mkdir certs: %w", err)199+ return err
200+ }
201+ if err := recoverCertificateDir(parent, cert.ID); err != nil {
202+ return fmt.Errorf("acmestore: recover cert %s: %w", cert.ID, err)
195 }203 }
196 204
197 dir := filepath.Join(parent, cert.ID)205 dir := filepath.Join(parent, cert.ID)
@@ -210,7 +218,7 @@ func (s *Store) Save(_ context.Context, cert out.StoredCertificate) error {
210 return err218 return err
211 }219 }
212 220
213- backupDir := dir + ".old"221+ backupDir := dir + backupSuffix
214 _ = os.RemoveAll(backupDir)222 _ = os.RemoveAll(backupDir)
215 hadExisting := false223 hadExisting := false
216 if err := os.Rename(dir, backupDir); err != nil {224 if err := os.Rename(dir, backupDir); err != nil {
@@ -266,11 +274,11 @@ func writeCertificateFiles(dir string, cert out.StoredCertificate) error {
266 return nil274 return nil
267 }275 }
268 276
269-// Lock acquires an exclusive lock using a lock file at <root>/.lock.277+// Lock acquires an exclusive advisory lock using a lock file at <root>/.lock.
270-// It is fail-fast and non-blocking: if the lock file already exists, Lock278+// It is fail-fast and non-blocking: if another live process holds the lock,
271-// returns an error immediately without waiting. Context cancellation is279+// Lock returns an error immediately without waiting. Context cancellation is
272-// respected before attempting the lock acquisition. The returned unlock280+// respected before attempting acquisition. The returned unlock function releases
273-// function removes the lock file to clean up.281+// the OS advisory lock, closes the file descriptor, and removes the lock file.
274 func (s *Store) Lock(ctx context.Context) (func() error, error) {282 func (s *Store) Lock(ctx context.Context) (func() error, error) {
275 select {283 select {
276 case <-ctx.Done():284 case <-ctx.Done():
@@ -279,26 +287,152 @@ func (s *Store) Lock(ctx context.Context) (func() error, error) {
279 }287 }
280 288
281 path := filepath.Join(s.root, lockFile)289 path := filepath.Join(s.root, lockFile)
282- f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, lockMode)290+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, lockMode)
283 if err != nil {291 if err != nil {
284- if os.IsExist(err) {292+ return nil, fmt.Errorf("acmestore: open lock file: %w", err)
285- return nil, fmt.Errorf("acmestore: lock already held")293+ }
294+
295+ fd, err := fileDescriptor(f)
296+ if err != nil {
297+ _ = f.Close()
298+ return nil, err
299+ }
300+ if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
301+ _ = f.Close()
302+ if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
303+ return nil, ErrLockHeld
286 }304 }
287 return nil, fmt.Errorf("acmestore: acquire lock: %w", err)305 return nil, fmt.Errorf("acmestore: acquire lock: %w", err)
288 }306 }
289- if err := f.Close(); err != nil {307+
290- os.Remove(path)308+ if err := writeLockMetadata(f); err != nil {
291- return nil, fmt.Errorf("acmestore: close lock file: %w", err)309+ _ = syscall.Flock(fd, syscall.LOCK_UN)
310+ _ = f.Close()
311+ return nil, err
292 }312 }
293 313
294 return func() error {314 return func() error {
295- if err := os.Remove(path); err != nil && !os.IsNotExist(err) {315+ return releaseLockFile(path, f, fd)
296- return fmt.Errorf("acmestore: release lock: %w", err)
297- }
298- return nil
299 }, nil316 }, nil
300 }317 }
301 318
319+func writeLockMetadata(f *os.File) error {
320+ if err := f.Truncate(0); err != nil {
321+ return fmt.Errorf("acmestore: truncate lock file: %w", err)
322+ }
323+ if _, err := f.Seek(0, 0); err != nil {
324+ return fmt.Errorf("acmestore: seek lock file: %w", err)
325+ }
326+ metadata := fmt.Sprintf("pid=%d\nhost=%s\ncreated_at=%s\n", os.Getpid(), hostname(), time.Now().UTC().Format(time.RFC3339Nano))
327+ if _, err := f.WriteString(metadata); err != nil {
328+ return fmt.Errorf("acmestore: write lock metadata: %w", err)
329+ }
330+ return nil
331+}
332+
333+func releaseLockFile(path string, f *os.File, fd int) error {
334+ var errs []error
335+ if err := syscall.Flock(fd, syscall.LOCK_UN); err != nil {
336+ errs = append(errs, fmt.Errorf("unlock: %w", err))
337+ }
338+ if err := f.Close(); err != nil {
339+ errs = append(errs, fmt.Errorf("close: %w", err))
340+ }
341+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
342+ errs = append(errs, fmt.Errorf("remove: %w", err))
343+ }
344+ if err := errors.Join(errs...); err != nil {
345+ return fmt.Errorf("acmestore: release lock: %w", err)
346+ }
347+ return nil
348+}
349+
350+func fileDescriptor(f *os.File) (int, error) {
351+ fd := f.Fd()
352+ if fd > uintptr(^uint(0)>>1) {
353+ return 0, fmt.Errorf("acmestore: lock file descriptor overflows int")
354+ }
355+ return int(fd), nil
356+}
357+
358+func hostname() string {
359+ host, err := os.Hostname()
360+ if err != nil {
361+ return "unknown"
362+ }
363+ return host
364+}
365+
366+func ensureCertsDir(root string) (string, error) {
367+ certsPath := filepath.Join(root, certDir)
368+ if err := os.MkdirAll(certsPath, dirMode); err != nil {
369+ return "", fmt.Errorf("acmestore: mkdir certs: %w", err)
370+ }
371+ if err := recoverCertificateDirs(certsPath); err != nil {
372+ return "", fmt.Errorf("acmestore: recover certs: %w", err)
373+ }
374+ return certsPath, nil
375+}
376+
377+func isStoreInternalTempDir(id string) bool {
378+ return strings.Contains(id, tempInfix)
379+}
380+
381+func isStoreInternalCertDir(id string) bool {
382+ return isStoreInternalTempDir(id) || strings.HasSuffix(id, backupSuffix)
383+}
384+
385+func recoverCertificateDir(certsPath, id string) error {
386+ if !safeID(id) {
387+ return nil
388+ }
389+ backupDir := filepath.Join(certsPath, id+backupSuffix)
390+ dir := filepath.Join(certsPath, id)
391+ if _, err := os.Stat(dir); err == nil {
392+ return nil
393+ } else if !os.IsNotExist(err) {
394+ return err
395+ }
396+ if _, err := os.Stat(backupDir); err != nil {
397+ if os.IsNotExist(err) {
398+ return nil
399+ }
400+ return err
401+ }
402+ if err := os.Rename(backupDir, dir); err != nil {
403+ return fmt.Errorf("restore backup %s: %w", filepath.Base(backupDir), err)
404+ }
405+ return nil
406+}
407+
408+func recoverCertificateDirs(certsPath string) error {
409+ entries, err := os.ReadDir(certsPath)
410+ if err != nil {
411+ if os.IsNotExist(err) {
412+ return nil
413+ }
414+ return err
415+ }
416+ for _, entry := range entries {
417+ if !entry.IsDir() {
418+ continue
419+ }
420+ name := entry.Name()
421+ if isStoreInternalTempDir(name) {
422+ if err := os.RemoveAll(filepath.Join(certsPath, name)); err != nil {
423+ return fmt.Errorf("remove temp %s: %w", name, err)
424+ }
425+ continue
426+ }
427+ if base, ok := strings.CutSuffix(name, backupSuffix); ok {
428+ if err := recoverCertificateDir(certsPath, base); err != nil {
429+ return err
430+ }
431+ }
432+ }
433+ return nil
434+}
435+
302 // safeID rejects IDs that could cause path traversal.436 // safeID rejects IDs that could cause path traversal.
303 func safeID(id string) bool {437 func safeID(id string) bool {
304 return id != "" && !strings.HasPrefix(id, ".") && !strings.Contains(id, "/") && !strings.Contains(id, "\\") && !strings.Contains(id, "..") && !strings.Contains(id, "\x00")438 return id != "" && !strings.HasPrefix(id, ".") && !strings.Contains(id, "/") && !strings.Contains(id, "\\") && !strings.Contains(id, "..") && !strings.Contains(id, "\x00")
M internal/adapters/out/acmestore/store_test.go +70 -0
@@ -138,10 +138,80 @@ func TestStoreRejectsUnsafeID(t *testing.T) {
138138 err = store.Save(ctx, cert)
139139 require.Error(t, err)
140140 assert.Contains(t, err.Error(), "unsafe certificate id")
141+ assert.ErrorIs(t, err, domain.ErrPathTraversal)
141142 })
142143 }
143144 }
144145
146+func TestStoreLockAcquireRelease(t *testing.T) {
147+ root := t.TempDir()
148+ ctx := context.Background()
149+
150+ store, err := New(root)
151+ require.NoError(t, err)
152+
153+ unlock, err := store.Lock(ctx)
154+ require.NoError(t, err)
155+ assert.FileExists(t, filepath.Join(root, lockFile))
156+
157+ _, err = store.Lock(ctx)
158+ require.Error(t, err)
159+ assert.Contains(t, err.Error(), "lock already held")
160+
161+ require.NoError(t, unlock())
162+ assert.NoFileExists(t, filepath.Join(root, lockFile))
163+
164+ unlock, err = store.Lock(ctx)
165+ require.NoError(t, err)
166+ require.NoError(t, unlock())
167+}
168+
169+func TestStoreLockContextCanceled(t *testing.T) {
170+ root := t.TempDir()
171+ store, err := New(root)
172+ require.NoError(t, err)
173+
174+ ctx, cancel := context.WithCancel(context.Background())
175+ cancel()
176+
177+ _, err = store.Lock(ctx)
178+ require.ErrorIs(t, err, context.Canceled)
179+ assert.NoFileExists(t, filepath.Join(root, lockFile))
180+}
181+
182+func TestStoreNewRestoresBackupDir(t *testing.T) {
183+ root := t.TempDir()
184+ ctx := context.Background()
185+
186+ store, err := New(root)
187+ require.NoError(t, err)
188+
189+ fullchainPEM, privKeyPEM := generateTestCertPEM(t)
190+ cert := out.StoredCertificate{
191+ ID: "restore.example.com",
192+ Names: []string{"restore.example.com"},
193+ Challenge: domain.ACMEChallengeHTTP01,
194+ CertPEM: fullchainPEM,
195+ FullchainPEM: fullchainPEM,
196+ PrivateKeyPEM: privKeyPEM,
197+ }
198+ require.NoError(t, store.Save(ctx, cert))
199+
200+ certPath := filepath.Join(root, certDir, cert.ID)
201+ backupPath := certPath + ".old"
202+ require.NoError(t, os.Rename(certPath, backupPath))
203+ require.NoDirExists(t, certPath)
204+
205+ store, err = New(root)
206+ require.NoError(t, err)
207+ assert.DirExists(t, certPath)
208+
209+ certs, err := store.LoadAll(ctx)
210+ require.NoError(t, err)
211+ require.Len(t, certs, 1)
212+ assert.Equal(t, cert.ID, certs[0].ID)
213+}
214+
145215 func TestStoreSaveLoadAccount(t *testing.T) {
146216 root := t.TempDir()
147217 ctx := context.Background()
@@ -138,10 +138,80 @@ func TestStoreRejectsUnsafeID(t *testing.T) {
138 err = store.Save(ctx, cert)138 err = store.Save(ctx, cert)
139 require.Error(t, err)139 require.Error(t, err)
140 assert.Contains(t, err.Error(), "unsafe certificate id")140 assert.Contains(t, err.Error(), "unsafe certificate id")
141+ assert.ErrorIs(t, err, domain.ErrPathTraversal)
141 })142 })
142 }143 }
143 }144 }
144 145
146+func TestStoreLockAcquireRelease(t *testing.T) {
147+ root := t.TempDir()
148+ ctx := context.Background()
149+
150+ store, err := New(root)
151+ require.NoError(t, err)
152+
153+ unlock, err := store.Lock(ctx)
154+ require.NoError(t, err)
155+ assert.FileExists(t, filepath.Join(root, lockFile))
156+
157+ _, err = store.Lock(ctx)
158+ require.Error(t, err)
159+ assert.Contains(t, err.Error(), "lock already held")
160+
161+ require.NoError(t, unlock())
162+ assert.NoFileExists(t, filepath.Join(root, lockFile))
163+
164+ unlock, err = store.Lock(ctx)
165+ require.NoError(t, err)
166+ require.NoError(t, unlock())
167+}
168+
169+func TestStoreLockContextCanceled(t *testing.T) {
170+ root := t.TempDir()
171+ store, err := New(root)
172+ require.NoError(t, err)
173+
174+ ctx, cancel := context.WithCancel(context.Background())
175+ cancel()
176+
177+ _, err = store.Lock(ctx)
178+ require.ErrorIs(t, err, context.Canceled)
179+ assert.NoFileExists(t, filepath.Join(root, lockFile))
180+}
181+
182+func TestStoreNewRestoresBackupDir(t *testing.T) {
183+ root := t.TempDir()
184+ ctx := context.Background()
185+
186+ store, err := New(root)
187+ require.NoError(t, err)
188+
189+ fullchainPEM, privKeyPEM := generateTestCertPEM(t)
190+ cert := out.StoredCertificate{
191+ ID: "restore.example.com",
192+ Names: []string{"restore.example.com"},
193+ Challenge: domain.ACMEChallengeHTTP01,
194+ CertPEM: fullchainPEM,
195+ FullchainPEM: fullchainPEM,
196+ PrivateKeyPEM: privKeyPEM,
197+ }
198+ require.NoError(t, store.Save(ctx, cert))
199+
200+ certPath := filepath.Join(root, certDir, cert.ID)
201+ backupPath := certPath + ".old"
202+ require.NoError(t, os.Rename(certPath, backupPath))
203+ require.NoDirExists(t, certPath)
204+
205+ store, err = New(root)
206+ require.NoError(t, err)
207+ assert.DirExists(t, certPath)
208+
209+ certs, err := store.LoadAll(ctx)
210+ require.NoError(t, err)
211+ require.Len(t, certs, 1)
212+ assert.Equal(t, cert.ID, certs[0].ID)
213+}
214+
145 func TestStoreSaveLoadAccount(t *testing.T) {215 func TestStoreSaveLoadAccount(t *testing.T) {
146 root := t.TempDir()216 root := t.TempDir()
147 ctx := context.Background()217 ctx := context.Background()
M internal/usecase/publictls/renewal.go +3 -2
@@ -6,6 +6,7 @@ import (
66 "time"
77
88 "github.com/bnema/gordon/internal/boundaries/out"
9+ "github.com/bnema/gordon/internal/domain"
910 )
1011
1112 // renewalWindow is the window before certificate expiry when renewal should begin.
@@ -89,10 +90,10 @@ func (s *Service) renewDueCertificates(ctx context.Context, now time.Time) error
8990 return nil
9091 }
9192 if s.deps.Store == nil {
92- return fmt.Errorf("certificate store is nil")
93+ return fmt.Errorf("%w: certificate store is nil", domain.ErrCertificateStoreRequired)
9394 }
9495 if s.deps.Issuer == nil {
95- return fmt.Errorf("certificate issuer is nil")
96+ return fmt.Errorf("%w: certificate issuer is nil", domain.ErrCertificateIssuerRequired)
9697 }
9798
9899 // Collect due certs from in-memory cache under s.mu (no store lock needed).
@@ -6,6 +6,7 @@ import (
6 "time"6 "time"
7 7
8 "github.com/bnema/gordon/internal/boundaries/out"8 "github.com/bnema/gordon/internal/boundaries/out"
9+ "github.com/bnema/gordon/internal/domain"
9 )10 )
10 11
11 // renewalWindow is the window before certificate expiry when renewal should begin.12 // renewalWindow is the window before certificate expiry when renewal should begin.
@@ -89,10 +90,10 @@ func (s *Service) renewDueCertificates(ctx context.Context, now time.Time) error
89 return nil90 return nil
90 }91 }
91 if s.deps.Store == nil {92 if s.deps.Store == nil {
92- return fmt.Errorf("certificate store is nil")93+ return fmt.Errorf("%w: certificate store is nil", domain.ErrCertificateStoreRequired)
93 }94 }
94 if s.deps.Issuer == nil {95 if s.deps.Issuer == nil {
95- return fmt.Errorf("certificate issuer is nil")96+ return fmt.Errorf("%w: certificate issuer is nil", domain.ErrCertificateIssuerRequired)
96 }97 }
97 98
98 // Collect due certs from in-memory cache under s.mu (no store lock needed).99 // Collect due certs from in-memory cache under s.mu (no store lock needed).
M internal/usecase/publictls/renewal_test.go +14 -0
@@ -56,6 +56,20 @@ func TestShouldRenew(t *testing.T) {
5656 }
5757 }
5858
59+func TestRenewDueCertificatesRequiresDependencies(t *testing.T) {
60+ cfg := Config{Enabled: true}
61+ svc := NewService(cfg, ServiceDeps{})
62+
63+ err := svc.renewDueCertificates(context.Background(), time.Now())
64+ require.Error(t, err)
65+ assert.ErrorIs(t, err, domain.ErrCertificateStoreRequired)
66+
67+ svc.deps.Store, _ = newMockCertificateStore(t)
68+ err = svc.renewDueCertificates(context.Background(), time.Now())
69+ require.Error(t, err)
70+ assert.ErrorIs(t, err, domain.ErrCertificateIssuerRequired)
71+}
72+
5973 // ---------------------------------------------------------------------------
6074 // RenewalLoopStops
6175 // ---------------------------------------------------------------------------
@@ -56,6 +56,20 @@ func TestShouldRenew(t *testing.T) {
56 }56 }
57 }57 }
58 58
59+func TestRenewDueCertificatesRequiresDependencies(t *testing.T) {
60+ cfg := Config{Enabled: true}
61+ svc := NewService(cfg, ServiceDeps{})
62+
63+ err := svc.renewDueCertificates(context.Background(), time.Now())
64+ require.Error(t, err)
65+ assert.ErrorIs(t, err, domain.ErrCertificateStoreRequired)
66+
67+ svc.deps.Store, _ = newMockCertificateStore(t)
68+ err = svc.renewDueCertificates(context.Background(), time.Now())
69+ require.Error(t, err)
70+ assert.ErrorIs(t, err, domain.ErrCertificateIssuerRequired)
71+}
72+
59 // ---------------------------------------------------------------------------73 // ---------------------------------------------------------------------------
60 // RenewalLoopStops74 // RenewalLoopStops
61 // ---------------------------------------------------------------------------75 // ---------------------------------------------------------------------------
M internal/usecase/publictls/service.go +33 -20
@@ -91,17 +91,8 @@ func (s *Service) Load(ctx context.Context) error {
9191
9292 for i := range stored {
9393 cert := &stored[i]
94- // If the tls.Certificate is empty but PEM data is available, parse it.
95- if len(cert.Certificate.Certificate) == 0 && len(cert.FullchainPEM) > 0 && len(cert.PrivateKeyPEM) > 0 {
96- parsed, parseErr := tls.X509KeyPair(cert.FullchainPEM, cert.PrivateKeyPEM)
97- if parseErr != nil {
98- s.lastErr[cert.ID] = fmt.Sprintf("parse stored certificate: %v", parseErr)
99- continue
100- }
101- cert.Certificate = parsed
102- }
103- if len(cert.Certificate.Certificate) == 0 {
104- s.lastErr[cert.ID] = "stored certificate is empty"
94+ if parseErr := populateStoredCertificate(cert); parseErr != nil {
95+ s.lastErr[cert.ID] = parseErr.Error()
10596 continue
10697 }
10798 s.certs[cert.ID] = cert
@@ -116,16 +107,16 @@ func (s *Service) Load(ctx context.Context) error {
116107 // Reconcile ensures all desired certificates are obtained and cached.
117108 // If ACME is disabled, it is a no-op.
118109 func (s *Service) Reconcile(ctx context.Context) error {
119- if !s.cfg.Enabled {
120- s.log.Debug().Msg("acme disabled, skipping reconcile")
121- return nil
122- }
123-
124110 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
125111 zerowrap.FieldLayer: "usecase",
126112 zerowrap.FieldUseCase: "Reconcile",
127113 })
128114 log := zerowrap.FromCtx(ctx)
115+
116+ if !s.cfg.Enabled {
117+ log.Debug().Msg("acme disabled, skipping reconcile")
118+ return nil
119+ }
129120 log.Debug().Msg("starting certificate reconciliation")
130121
131122 if s.deps.Store == nil {
@@ -144,12 +135,11 @@ func (s *Service) Reconcile(ctx context.Context) error {
144135 // Determine effective challenge mode.
145136 effective := s.deps.Effective
146137 if effective.Mode == "" {
147- // Derive conservatively from config.
148- mode, err := domain.ParseACMEChallengeMode(s.cfg.Challenge)
138+ resolved, err := ResolveEffectiveChallenge(ctx, s.cfg, nil)
149139 if err != nil {
150- mode = domain.ACMEChallengeHTTP01
140+ return fmt.Errorf("resolve effective challenge: %w", err)
151141 }
152- effective.Mode = mode
142+ effective = resolved
153143 }
154144
155145 // Get route hosts early to build required hosts set before target derivation.
@@ -214,6 +204,23 @@ func (s *Service) Reconcile(ctx context.Context) error {
214204 return nil
215205 }
216206
207+func populateStoredCertificate(cert *out.StoredCertificate) error {
208+ if cert == nil {
209+ return fmt.Errorf("stored certificate is nil")
210+ }
211+ if len(cert.Certificate.Certificate) == 0 && len(cert.FullchainPEM) > 0 && len(cert.PrivateKeyPEM) > 0 {
212+ parsed, err := tls.X509KeyPair(cert.FullchainPEM, cert.PrivateKeyPEM)
213+ if err != nil {
214+ return fmt.Errorf("parse stored certificate: %w", err)
215+ }
216+ cert.Certificate = parsed
217+ }
218+ if len(cert.Certificate.Certificate) == 0 {
219+ return fmt.Errorf("stored certificate is empty")
220+ }
221+ return nil
222+}
223+
217224 func canonicalHostSet(hosts []string) map[string]struct{} {
218225 required := make(map[string]struct{}, len(hosts))
219226 for _, host := range hosts {
@@ -259,6 +266,12 @@ func (s *Service) reconcileMissingTargets(ctx context.Context, missing []Certifi
259266 s.mu.Unlock()
260267 continue
261268 }
269+ if err := populateStoredCertificate(stored); err != nil {
270+ s.mu.Lock()
271+ s.lastErr[target.ID] = err.Error()
272+ s.mu.Unlock()
273+ continue
274+ }
262275
263276 if err := s.deps.Store.Save(ctx, *stored); err != nil {
264277 s.mu.Lock()
@@ -91,17 +91,8 @@ func (s *Service) Load(ctx context.Context) error {
91 91
92 for i := range stored {92 for i := range stored {
93 cert := &stored[i]93 cert := &stored[i]
94- // If the tls.Certificate is empty but PEM data is available, parse it.94+ if parseErr := populateStoredCertificate(cert); parseErr != nil {
95- if len(cert.Certificate.Certificate) == 0 && len(cert.FullchainPEM) > 0 && len(cert.PrivateKeyPEM) > 0 {95+ s.lastErr[cert.ID] = parseErr.Error()
96- parsed, parseErr := tls.X509KeyPair(cert.FullchainPEM, cert.PrivateKeyPEM)
97- if parseErr != nil {
98- s.lastErr[cert.ID] = fmt.Sprintf("parse stored certificate: %v", parseErr)
99- continue
100- }
101- cert.Certificate = parsed
102- }
103- if len(cert.Certificate.Certificate) == 0 {
104- s.lastErr[cert.ID] = "stored certificate is empty"
105 continue96 continue
106 }97 }
107 s.certs[cert.ID] = cert98 s.certs[cert.ID] = cert
@@ -116,16 +107,16 @@ func (s *Service) Load(ctx context.Context) error {
116 // Reconcile ensures all desired certificates are obtained and cached.107 // Reconcile ensures all desired certificates are obtained and cached.
117 // If ACME is disabled, it is a no-op.108 // If ACME is disabled, it is a no-op.
118 func (s *Service) Reconcile(ctx context.Context) error {109 func (s *Service) Reconcile(ctx context.Context) error {
119- if !s.cfg.Enabled {
120- s.log.Debug().Msg("acme disabled, skipping reconcile")
121- return nil
122- }
123-
124 ctx = zerowrap.CtxWithFields(ctx, map[string]any{110 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
125 zerowrap.FieldLayer: "usecase",111 zerowrap.FieldLayer: "usecase",
126 zerowrap.FieldUseCase: "Reconcile",112 zerowrap.FieldUseCase: "Reconcile",
127 })113 })
128 log := zerowrap.FromCtx(ctx)114 log := zerowrap.FromCtx(ctx)
115+
116+ if !s.cfg.Enabled {
117+ log.Debug().Msg("acme disabled, skipping reconcile")
118+ return nil
119+ }
129 log.Debug().Msg("starting certificate reconciliation")120 log.Debug().Msg("starting certificate reconciliation")
130 121
131 if s.deps.Store == nil {122 if s.deps.Store == nil {
@@ -144,12 +135,11 @@ func (s *Service) Reconcile(ctx context.Context) error {
144 // Determine effective challenge mode.135 // Determine effective challenge mode.
145 effective := s.deps.Effective136 effective := s.deps.Effective
146 if effective.Mode == "" {137 if effective.Mode == "" {
147- // Derive conservatively from config.138+ resolved, err := ResolveEffectiveChallenge(ctx, s.cfg, nil)
148- mode, err := domain.ParseACMEChallengeMode(s.cfg.Challenge)
149 if err != nil {139 if err != nil {
150- mode = domain.ACMEChallengeHTTP01140+ return fmt.Errorf("resolve effective challenge: %w", err)
151 }141 }
152- effective.Mode = mode142+ effective = resolved
153 }143 }
154 144
155 // Get route hosts early to build required hosts set before target derivation.145 // Get route hosts early to build required hosts set before target derivation.
@@ -214,6 +204,23 @@ func (s *Service) Reconcile(ctx context.Context) error {
214 return nil204 return nil
215 }205 }
216 206
207+func populateStoredCertificate(cert *out.StoredCertificate) error {
208+ if cert == nil {
209+ return fmt.Errorf("stored certificate is nil")
210+ }
211+ if len(cert.Certificate.Certificate) == 0 && len(cert.FullchainPEM) > 0 && len(cert.PrivateKeyPEM) > 0 {
212+ parsed, err := tls.X509KeyPair(cert.FullchainPEM, cert.PrivateKeyPEM)
213+ if err != nil {
214+ return fmt.Errorf("parse stored certificate: %w", err)
215+ }
216+ cert.Certificate = parsed
217+ }
218+ if len(cert.Certificate.Certificate) == 0 {
219+ return fmt.Errorf("stored certificate is empty")
220+ }
221+ return nil
222+}
223+
217 func canonicalHostSet(hosts []string) map[string]struct{} {224 func canonicalHostSet(hosts []string) map[string]struct{} {
218 required := make(map[string]struct{}, len(hosts))225 required := make(map[string]struct{}, len(hosts))
219 for _, host := range hosts {226 for _, host := range hosts {
@@ -259,6 +266,12 @@ func (s *Service) reconcileMissingTargets(ctx context.Context, missing []Certifi
259 s.mu.Unlock()266 s.mu.Unlock()
260 continue267 continue
261 }268 }
269+ if err := populateStoredCertificate(stored); err != nil {
270+ s.mu.Lock()
271+ s.lastErr[target.ID] = err.Error()
272+ s.mu.Unlock()
273+ continue
274+ }
262 275
263 if err := s.deps.Store.Save(ctx, *stored); err != nil {276 if err := s.deps.Store.Save(ctx, *stored); err != nil {
264 s.mu.Lock()277 s.mu.Lock()
M internal/usecase/publictls/service_test.go +26 -0
@@ -309,6 +309,32 @@ func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
309309 assert.NotEmpty(t, stored[0].Certificate.Certificate)
310310 }
311311
312+func TestServiceReconcileResolvesMissingEffectiveChallenge(t *testing.T) {
313+ ctx := context.Background()
314+ routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
315+ issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
316+ store, _ := newMockCertificateStore(t)
317+ cfg := Config{
318+ Enabled: true,
319+ Email: "admin@example.com",
320+ Challenge: "http-01",
321+ HTTPPort: 8088,
322+ // Missing TLSPort should be caught by ResolveEffectiveChallenge rather
323+ // than silently defaulting an effective mode.
324+ }
325+
326+ svc := NewService(cfg, ServiceDeps{
327+ Config: cfg,
328+ Routes: routes,
329+ Issuer: issuer,
330+ Store: store,
331+ })
332+
333+ err := svc.Reconcile(ctx)
334+ require.Error(t, err)
335+ assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
336+}
337+
312338 func TestServiceStatusReportsCoverage(t *testing.T) {
313339 ctx := context.Background()
314340
@@ -309,6 +309,32 @@ func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
309 assert.NotEmpty(t, stored[0].Certificate.Certificate)309 assert.NotEmpty(t, stored[0].Certificate.Certificate)
310 }310 }
311 311
312+func TestServiceReconcileResolvesMissingEffectiveChallenge(t *testing.T) {
313+ ctx := context.Background()
314+ routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
315+ issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
316+ store, _ := newMockCertificateStore(t)
317+ cfg := Config{
318+ Enabled: true,
319+ Email: "admin@example.com",
320+ Challenge: "http-01",
321+ HTTPPort: 8088,
322+ // Missing TLSPort should be caught by ResolveEffectiveChallenge rather
323+ // than silently defaulting an effective mode.
324+ }
325+
326+ svc := NewService(cfg, ServiceDeps{
327+ Config: cfg,
328+ Routes: routes,
329+ Issuer: issuer,
330+ Store: store,
331+ })
332+
333+ err := svc.Reconcile(ctx)
334+ require.Error(t, err)
335+ assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
336+}
337+
312 func TestServiceStatusReportsCoverage(t *testing.T) {338 func TestServiceStatusReportsCoverage(t *testing.T) {
313 ctx := context.Background()339 ctx := context.Background()
314 340

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