detached 3d6e47e

Address public TLS review findings

commit
3d6e47efbbba6d045e324f849dc377a9733870a8
author
brice · · 2026-04-29 16:32 UTC
parent
289a830
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
24 files · +276 -113
M docs/cli/tls.md +4 -4
@@ -38,8 +38,8 @@ gordon tls status --remote https://gordon.mydomain.com --token $TOKEN
3838 Public TLS / ACME Status
3939
4040 ACME: enabled
41-Configured Mode: acme
42-Effective Mode: acme-staging
41+Configured Mode: auto
42+Effective Mode: http-01
4343 Reason: configured
4444 Token Source: env
4545
@@ -62,8 +62,8 @@ Errors
6262 ```json
6363 {
6464 "acme_enabled": true,
65- "configured_mode": "acme",
66- "effective_mode": "acme-staging",
65+ "configured_mode": "auto",
66+ "effective_mode": "http-01",
6767 "selection_reason": "configured",
6868 "token_source": "env",
6969 "certificates": [
@@ -38,8 +38,8 @@ gordon tls status --remote https://gordon.mydomain.com --token $TOKEN
38 Public TLS / ACME Status38 Public TLS / ACME Status
39 39
40 ACME: enabled40 ACME: enabled
41-Configured Mode: acme41+Configured Mode: auto
42-Effective Mode: acme-staging42+Effective Mode: http-01
43 Reason: configured43 Reason: configured
44 Token Source: env44 Token Source: env
45 45
@@ -62,8 +62,8 @@ Errors
62 ```json62 ```json
63 {63 {
64 "acme_enabled": true,64 "acme_enabled": true,
65- "configured_mode": "acme",65+ "configured_mode": "auto",
66- "effective_mode": "acme-staging",66+ "effective_mode": "http-01",
67 "selection_reason": "configured",67 "selection_reason": "configured",
68 "token_source": "env",68 "token_source": "env",
69 "certificates": [69 "certificates": [
M internal/adapters/in/http/acme/handler.go +5 -1
@@ -36,8 +36,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
3636 return
3737 }
3838
39+ if !strings.HasPrefix(r.URL.Path, Prefix) {
40+ http.NotFound(w, r)
41+ return
42+ }
3943 token := strings.TrimPrefix(r.URL.Path, Prefix)
40- if !strings.HasPrefix(r.URL.Path, Prefix) || !safeToken(token) {
44+ if !safeToken(token) {
4145 http.NotFound(w, r)
4246 return
4347 }
@@ -36,8 +36,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
36 return36 return
37 }37 }
38 38
39+ if !strings.HasPrefix(r.URL.Path, Prefix) {
40+ http.NotFound(w, r)
41+ return
42+ }
39 token := strings.TrimPrefix(r.URL.Path, Prefix)43 token := strings.TrimPrefix(r.URL.Path, Prefix)
40- if !strings.HasPrefix(r.URL.Path, Prefix) || !safeToken(token) {44+ if !safeToken(token) {
41 http.NotFound(w, r)45 http.NotFound(w, r)
42 return46 return
43 }47 }
M internal/adapters/in/http/admin/handler.go +4 -1
@@ -1924,7 +1924,10 @@ func (h *Handler) handleTLSStatus(w http.ResponseWriter, r *http.Request) {
19241924 }
19251925
19261926 if h.publicTLSSvc == nil {
1927- h.sendJSON(w, http.StatusOK, dto.TLSStatusResponse{})
1927+ h.sendJSON(w, http.StatusOK, dto.TLSStatusResponse{
1928+ ACMEEnabled: false,
1929+ SelectionReason: "public TLS service not configured",
1930+ })
19281931 return
19291932 }
19301933
@@ -1924,7 +1924,10 @@ func (h *Handler) handleTLSStatus(w http.ResponseWriter, r *http.Request) {
1924 }1924 }
1925 1925
1926 if h.publicTLSSvc == nil {1926 if h.publicTLSSvc == nil {
1927- h.sendJSON(w, http.StatusOK, dto.TLSStatusResponse{})1927+ h.sendJSON(w, http.StatusOK, dto.TLSStatusResponse{
1928+ ACMEEnabled: false,
1929+ SelectionReason: "public TLS service not configured",
1930+ })
1928 return1931 return
1929 }1932 }
1930 1933
M internal/adapters/in/http/admin/handler_test.go +8 -6
@@ -2591,14 +2591,16 @@ func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) {
25912591 d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
25922592 })
25932593
2594- req := httptest.NewRequest("GET", "/admin/tls/status", nil)
2595- req = req.WithContext(ctxWithScopes("admin:config:write"))
2596- rec := httptest.NewRecorder()
2594+ server := newScopedTestServer(t, handler, "admin:config:write")
2595+ resp, err := http.Get(server.URL + "/admin/tls/status")
2596+ require.NoError(t, err)
2597+ defer resp.Body.Close()
25972598
2598- handler.ServeHTTP(rec, req)
2599+ body, err := io.ReadAll(resp.Body)
2600+ require.NoError(t, err)
25992601
2600- assert.Equal(t, http.StatusForbidden, rec.Code)
2601- assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read")
2602+ assert.Equal(t, http.StatusForbidden, resp.StatusCode)
2603+ assert.Contains(t, string(body), "insufficient permissions for status:read")
26022604 }
26032605
26042606 func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) {
@@ -2591,14 +2591,16 @@ func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) {
2591 d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)2591 d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
2592 })2592 })
2593 2593
2594- req := httptest.NewRequest("GET", "/admin/tls/status", nil)2594+ server := newScopedTestServer(t, handler, "admin:config:write")
2595- req = req.WithContext(ctxWithScopes("admin:config:write"))2595+ resp, err := http.Get(server.URL + "/admin/tls/status")
2596- rec := httptest.NewRecorder()2596+ require.NoError(t, err)
2597+ defer resp.Body.Close()
2597 2598
2598- handler.ServeHTTP(rec, req)2599+ body, err := io.ReadAll(resp.Body)
2600+ require.NoError(t, err)
2599 2601
2600- assert.Equal(t, http.StatusForbidden, rec.Code)2602+ assert.Equal(t, http.StatusForbidden, resp.StatusCode)
2601- assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read")2603+ assert.Contains(t, string(body), "insufficient permissions for status:read")
2602 }2604 }
2603 2605
2604 func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) {2606 func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) {
M internal/adapters/out/acmelego/cloudflare.go +21 -5
@@ -42,7 +42,9 @@ type CloudflareZoneResolver struct {
4242 baseURL string
4343 client *http.Client
4444
45- mu sync.RWMutex
45+ mu sync.RWMutex
46+ // cache uses a small fixed maximum size; eviction scans the map to keep the
47+ // resolver simple because defaultCacheMaxSize is intentionally low.
4648 cache map[string]zoneCacheEntry
4749 }
4850
@@ -50,10 +52,12 @@ type zoneCacheEntry struct {
5052 zone out.CloudflareZone
5153 err error
5254 createdAt time.Time
55+ ttl time.Duration
5356 }
5457
5558 const (
5659 defaultCacheTTL = 10 * time.Minute
60+ defaultErrorCacheTTL = time.Minute
5761 defaultCloudflareTimeout = 10 * time.Second
5862 defaultCacheMaxSize = 100
5963 defaultMaxResponseSize = 1 << 20 // 1 MB
@@ -149,14 +153,21 @@ func (r *CloudflareZoneResolver) FindZone(ctx context.Context, domainName string
149153
150154 func (r *CloudflareZoneResolver) cached(name string) (zoneCacheEntry, bool) {
151155 r.mu.RLock()
152- defer r.mu.RUnlock()
153156 entry, ok := r.cache[name]
154157 if !ok {
158+ r.mu.RUnlock()
155159 return entry, false
156160 }
157- if time.Since(entry.createdAt) > defaultCacheTTL {
161+ if time.Since(entry.createdAt) > entry.ttl {
162+ r.mu.RUnlock()
163+ r.mu.Lock()
164+ if current, ok := r.cache[name]; ok && current.createdAt.Equal(entry.createdAt) {
165+ delete(r.cache, name)
166+ }
167+ r.mu.Unlock()
158168 return zoneCacheEntry{}, false
159169 }
170+ r.mu.RUnlock()
160171 return entry, true
161172 }
162173
@@ -164,7 +175,8 @@ func (r *CloudflareZoneResolver) storeCache(name string, zone out.CloudflareZone
164175 r.mu.Lock()
165176 defer r.mu.Unlock()
166177 if len(r.cache) >= defaultCacheMaxSize {
167- // Evict oldest entry
178+ // Evict oldest entry with an O(n) scan. The cache is deliberately small
179+ // (defaultCacheMaxSize), so this stays simple without an LRU list.
168180 var oldestKey string
169181 var oldestTime time.Time
170182 for k, v := range r.cache {
@@ -175,7 +187,11 @@ func (r *CloudflareZoneResolver) storeCache(name string, zone out.CloudflareZone
175187 }
176188 delete(r.cache, oldestKey)
177189 }
178- r.cache[name] = zoneCacheEntry{zone: zone, err: err, createdAt: time.Now()}
190+ ttl := defaultCacheTTL
191+ if err != nil {
192+ ttl = defaultErrorCacheTTL
193+ }
194+ r.cache[name] = zoneCacheEntry{zone: zone, err: err, createdAt: time.Now(), ttl: ttl}
179195 }
180196
181197 // findZoneByName queries the Cloudflare API for an active zone with the exact given name.
@@ -42,7 +42,9 @@ type CloudflareZoneResolver struct {
42 baseURL string42 baseURL string
43 client *http.Client43 client *http.Client
44 44
45- mu sync.RWMutex45+ mu sync.RWMutex
46+ // cache uses a small fixed maximum size; eviction scans the map to keep the
47+ // resolver simple because defaultCacheMaxSize is intentionally low.
46 cache map[string]zoneCacheEntry48 cache map[string]zoneCacheEntry
47 }49 }
48 50
@@ -50,10 +52,12 @@ type zoneCacheEntry struct {
50 zone out.CloudflareZone52 zone out.CloudflareZone
51 err error53 err error
52 createdAt time.Time54 createdAt time.Time
55+ ttl time.Duration
53 }56 }
54 57
55 const (58 const (
56 defaultCacheTTL = 10 * time.Minute59 defaultCacheTTL = 10 * time.Minute
60+ defaultErrorCacheTTL = time.Minute
57 defaultCloudflareTimeout = 10 * time.Second61 defaultCloudflareTimeout = 10 * time.Second
58 defaultCacheMaxSize = 10062 defaultCacheMaxSize = 100
59 defaultMaxResponseSize = 1 << 20 // 1 MB63 defaultMaxResponseSize = 1 << 20 // 1 MB
@@ -149,14 +153,21 @@ func (r *CloudflareZoneResolver) FindZone(ctx context.Context, domainName string
149 153
150 func (r *CloudflareZoneResolver) cached(name string) (zoneCacheEntry, bool) {154 func (r *CloudflareZoneResolver) cached(name string) (zoneCacheEntry, bool) {
151 r.mu.RLock()155 r.mu.RLock()
152- defer r.mu.RUnlock()
153 entry, ok := r.cache[name]156 entry, ok := r.cache[name]
154 if !ok {157 if !ok {
158+ r.mu.RUnlock()
155 return entry, false159 return entry, false
156 }160 }
157- if time.Since(entry.createdAt) > defaultCacheTTL {161+ if time.Since(entry.createdAt) > entry.ttl {
162+ r.mu.RUnlock()
163+ r.mu.Lock()
164+ if current, ok := r.cache[name]; ok && current.createdAt.Equal(entry.createdAt) {
165+ delete(r.cache, name)
166+ }
167+ r.mu.Unlock()
158 return zoneCacheEntry{}, false168 return zoneCacheEntry{}, false
159 }169 }
170+ r.mu.RUnlock()
160 return entry, true171 return entry, true
161 }172 }
162 173
@@ -164,7 +175,8 @@ func (r *CloudflareZoneResolver) storeCache(name string, zone out.CloudflareZone
164 r.mu.Lock()175 r.mu.Lock()
165 defer r.mu.Unlock()176 defer r.mu.Unlock()
166 if len(r.cache) >= defaultCacheMaxSize {177 if len(r.cache) >= defaultCacheMaxSize {
167- // Evict oldest entry178+ // Evict oldest entry with an O(n) scan. The cache is deliberately small
179+ // (defaultCacheMaxSize), so this stays simple without an LRU list.
168 var oldestKey string180 var oldestKey string
169 var oldestTime time.Time181 var oldestTime time.Time
170 for k, v := range r.cache {182 for k, v := range r.cache {
@@ -175,7 +187,11 @@ func (r *CloudflareZoneResolver) storeCache(name string, zone out.CloudflareZone
175 }187 }
176 delete(r.cache, oldestKey)188 delete(r.cache, oldestKey)
177 }189 }
178- r.cache[name] = zoneCacheEntry{zone: zone, err: err, createdAt: time.Now()}190+ ttl := defaultCacheTTL
191+ if err != nil {
192+ ttl = defaultErrorCacheTTL
193+ }
194+ r.cache[name] = zoneCacheEntry{zone: zone, err: err, createdAt: time.Now(), ttl: ttl}
179 }195 }
180 196
181 // findZoneByName queries the Cloudflare API for an active zone with the exact given name.197 // findZoneByName queries the Cloudflare API for an active zone with the exact given name.
M internal/adapters/out/acmelego/cloudflare_test.go +11 -0
@@ -3,6 +3,7 @@ package acmelego
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "net/http"
78 "net/http/httptest"
89 "testing"
@@ -136,6 +137,16 @@ func TestCloudflareZoneResolverNoZoneFound(t *testing.T) {
136137 assert.Contains(t, err.Error(), "no active zone found")
137138 }
138139
140+func TestCloudflareZoneResolverCachesErrorsWithShortTTL(t *testing.T) {
141+ resolver := NewCloudflareZoneResolver("token")
142+ resolver.storeCache("example.com", out.CloudflareZone{}, errors.New("temporary failure"))
143+
144+ entry, ok := resolver.cached("example.com")
145+ require.True(t, ok)
146+ assert.Error(t, entry.err)
147+ assert.Equal(t, defaultErrorCacheTTL, entry.ttl)
148+}
149+
139150 func TestCloudflareZoneResolverUnsuccessfulAPI(t *testing.T) {
140151 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
141152 w.Header().Set("Content-Type", "application/json")
@@ -3,6 +3,7 @@ package acmelego
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "errors"
6 "net/http"7 "net/http"
7 "net/http/httptest"8 "net/http/httptest"
8 "testing"9 "testing"
@@ -136,6 +137,16 @@ func TestCloudflareZoneResolverNoZoneFound(t *testing.T) {
136 assert.Contains(t, err.Error(), "no active zone found")137 assert.Contains(t, err.Error(), "no active zone found")
137 }138 }
138 139
140+func TestCloudflareZoneResolverCachesErrorsWithShortTTL(t *testing.T) {
141+ resolver := NewCloudflareZoneResolver("token")
142+ resolver.storeCache("example.com", out.CloudflareZone{}, errors.New("temporary failure"))
143+
144+ entry, ok := resolver.cached("example.com")
145+ require.True(t, ok)
146+ assert.Error(t, entry.err)
147+ assert.Equal(t, defaultErrorCacheTTL, entry.ttl)
148+}
149+
139 func TestCloudflareZoneResolverUnsuccessfulAPI(t *testing.T) {150 func TestCloudflareZoneResolverUnsuccessfulAPI(t *testing.T) {
140 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {151 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
141 w.Header().Set("Content-Type", "application/json")152 w.Header().Set("Content-Type", "application/json")
M internal/adapters/out/acmelego/http_provider.go +7 -5
@@ -1,7 +1,11 @@
11 // Package acmelego implements ACME certificate operations using the lego library.
22 package acmelego
33
4-import "github.com/bnema/gordon/internal/boundaries/out"
4+import (
5+ "fmt"
6+
7+ "github.com/bnema/gordon/internal/boundaries/out"
8+)
59
610 // HTTPProvider adapts an out.HTTPChallengeSink to the lego challenge.Provider
711 // interface for HTTP-01 challenge solving.
@@ -15,11 +19,9 @@ func NewHTTPProvider(sink out.HTTPChallengeSink) *HTTPProvider {
1519 }
1620
1721 // Present stores the key authorization for the given token via the sink.
18-// If the sink is nil, it returns nil (no-op) to allow graceful degradation
19-// in test or misconfigured scenarios.
2022 func (p *HTTPProvider) Present(_domain, token, keyAuth string) error {
2123 if p.sink == nil {
22- return nil
24+ return fmt.Errorf("http-01 sink is nil: cannot Present")
2325 }
2426 p.sink.Present(token, keyAuth)
2527 return nil
@@ -28,7 +30,7 @@ func (p *HTTPProvider) Present(_domain, token, keyAuth string) error {
2830 // CleanUp removes the key authorization for the given token via the sink.
2931 func (p *HTTPProvider) CleanUp(_domain, token, _keyAuth string) error {
3032 if p.sink == nil {
31- return nil
33+ return fmt.Errorf("http-01 sink is nil: cannot CleanUp")
3234 }
3335 p.sink.CleanUp(token)
3436 return nil
@@ -1,7 +1,11 @@
1 // Package acmelego implements ACME certificate operations using the lego library.1 // Package acmelego implements ACME certificate operations using the lego library.
2 package acmelego2 package acmelego
3 3
4-import "github.com/bnema/gordon/internal/boundaries/out"4+import (
5+ "fmt"
6+
7+ "github.com/bnema/gordon/internal/boundaries/out"
8+)
5 9
6 // HTTPProvider adapts an out.HTTPChallengeSink to the lego challenge.Provider10 // HTTPProvider adapts an out.HTTPChallengeSink to the lego challenge.Provider
7 // interface for HTTP-01 challenge solving.11 // interface for HTTP-01 challenge solving.
@@ -15,11 +19,9 @@ func NewHTTPProvider(sink out.HTTPChallengeSink) *HTTPProvider {
15 }19 }
16 20
17 // Present stores the key authorization for the given token via the sink.21 // Present stores the key authorization for the given token via the sink.
18-// If the sink is nil, it returns nil (no-op) to allow graceful degradation
19-// in test or misconfigured scenarios.
20 func (p *HTTPProvider) Present(_domain, token, keyAuth string) error {22 func (p *HTTPProvider) Present(_domain, token, keyAuth string) error {
21 if p.sink == nil {23 if p.sink == nil {
22- return nil24+ return fmt.Errorf("http-01 sink is nil: cannot Present")
23 }25 }
24 p.sink.Present(token, keyAuth)26 p.sink.Present(token, keyAuth)
25 return nil27 return nil
@@ -28,7 +30,7 @@ func (p *HTTPProvider) Present(_domain, token, keyAuth string) error {
28 // CleanUp removes the key authorization for the given token via the sink.30 // CleanUp removes the key authorization for the given token via the sink.
29 func (p *HTTPProvider) CleanUp(_domain, token, _keyAuth string) error {31 func (p *HTTPProvider) CleanUp(_domain, token, _keyAuth string) error {
30 if p.sink == nil {32 if p.sink == nil {
31- return nil33+ return fmt.Errorf("http-01 sink is nil: cannot CleanUp")
32 }34 }
33 p.sink.CleanUp(token)35 p.sink.CleanUp(token)
34 return nil36 return nil
M internal/adapters/out/acmelego/http_provider_test.go +11 -22
@@ -4,43 +4,32 @@ import (
44 "testing"
55
66 "github.com/stretchr/testify/assert"
7-)
8-
9-// challengeSink is a test sink that stores challenge tokens in a map.
10-type challengeSink struct {
11- values map[string]string
12-}
13-
14-func (s *challengeSink) Present(token, keyAuth string) {
15- if s.values == nil {
16- s.values = make(map[string]string)
17- }
18- s.values[token] = keyAuth
19-}
207
21-func (s *challengeSink) CleanUp(token string) {
22- delete(s.values, token)
23-}
8+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
9+)
2410
2511 func TestHTTPProviderStoresAndCleansChallenge(t *testing.T) {
26- sink := &challengeSink{}
27- provider := NewHTTPProvider(sink)
12+ mockSink := outmocks.NewMockHTTPChallengeSink(t)
13+ mockSink.EXPECT().Present("token", "key-auth").Return()
14+ mockSink.EXPECT().CleanUp("token").Return()
15+
16+ provider := NewHTTPProvider(mockSink)
2817
2918 err := provider.Present("example.com", "token", "key-auth")
3019 assert.NoError(t, err)
31- assert.Equal(t, "key-auth", sink.values["token"])
3220
3321 err = provider.CleanUp("example.com", "token", "key-auth")
3422 assert.NoError(t, err)
35- assert.Empty(t, sink.values["token"])
3623 }
3724
3825 func TestHTTPProviderNilSink(t *testing.T) {
3926 provider := NewHTTPProvider(nil)
4027
4128 err := provider.Present("example.com", "token", "key-auth")
42- assert.NoError(t, err)
29+ assert.Error(t, err)
30+ assert.Contains(t, err.Error(), "sink is nil")
4331
4432 err = provider.CleanUp("example.com", "token", "key-auth")
45- assert.NoError(t, err)
33+ assert.Error(t, err)
34+ assert.Contains(t, err.Error(), "sink is nil")
4635 }
@@ -4,43 +4,32 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/stretchr/testify/assert"6 "github.com/stretchr/testify/assert"
7-)
8-
9-// challengeSink is a test sink that stores challenge tokens in a map.
10-type challengeSink struct {
11- values map[string]string
12-}
13-
14-func (s *challengeSink) Present(token, keyAuth string) {
15- if s.values == nil {
16- s.values = make(map[string]string)
17- }
18- s.values[token] = keyAuth
19-}
20 7
21-func (s *challengeSink) CleanUp(token string) {8+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
22- delete(s.values, token)9+)
23-}
24 10
25 func TestHTTPProviderStoresAndCleansChallenge(t *testing.T) {11 func TestHTTPProviderStoresAndCleansChallenge(t *testing.T) {
26- sink := &challengeSink{}12+ mockSink := outmocks.NewMockHTTPChallengeSink(t)
27- provider := NewHTTPProvider(sink)13+ mockSink.EXPECT().Present("token", "key-auth").Return()
14+ mockSink.EXPECT().CleanUp("token").Return()
15+
16+ provider := NewHTTPProvider(mockSink)
28 17
29 err := provider.Present("example.com", "token", "key-auth")18 err := provider.Present("example.com", "token", "key-auth")
30 assert.NoError(t, err)19 assert.NoError(t, err)
31- assert.Equal(t, "key-auth", sink.values["token"])
32 20
33 err = provider.CleanUp("example.com", "token", "key-auth")21 err = provider.CleanUp("example.com", "token", "key-auth")
34 assert.NoError(t, err)22 assert.NoError(t, err)
35- assert.Empty(t, sink.values["token"])
36 }23 }
37 24
38 func TestHTTPProviderNilSink(t *testing.T) {25 func TestHTTPProviderNilSink(t *testing.T) {
39 provider := NewHTTPProvider(nil)26 provider := NewHTTPProvider(nil)
40 27
41 err := provider.Present("example.com", "token", "key-auth")28 err := provider.Present("example.com", "token", "key-auth")
42- assert.NoError(t, err)29+ assert.Error(t, err)
30+ assert.Contains(t, err.Error(), "sink is nil")
43 31
44 err = provider.CleanUp("example.com", "token", "key-auth")32 err = provider.CleanUp("example.com", "token", "key-auth")
45- assert.NoError(t, err)33+ assert.Error(t, err)
34+ assert.Contains(t, err.Error(), "sink is nil")
46 }35 }
M internal/adapters/out/acmelego/issuer.go +9 -7
@@ -8,6 +8,7 @@ import (
88 "encoding/json"
99 "fmt"
1010 "net/http"
11+ "strings"
1112 "sync"
1213 "time"
1314
@@ -63,6 +64,9 @@ func NewIssuer(cfg Config) (*Issuer, error) {
6364 if cfg.Store == nil {
6465 return nil, fmt.Errorf("acmelego: %w", domain.ErrCertificateStoreRequired)
6566 }
67+ if cfg.CADirectoryURL != "" && !strings.HasPrefix(cfg.CADirectoryURL, "https://") {
68+ return nil, fmt.Errorf("acmelego: CADirectoryURL must use HTTPS, got %q", cfg.CADirectoryURL)
69+ }
6670 if cfg.Challenge == "" {
6771 cfg.Challenge = domain.ACMEChallengeHTTP01
6872 }
@@ -110,13 +114,13 @@ func (i *Issuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.St
110114 return nil, err
111115 }
112116
113- domain := ""
117+ domainName := ""
114118 if len(cert.Names) > 0 {
115- domain = cert.Names[0]
119+ domainName = cert.Names[0]
116120 }
117121
118122 legoCert := certificate.Resource{
119- Domain: domain,
123+ Domain: domainName,
120124 PrivateKey: cert.PrivateKeyPEM,
121125 Certificate: cert.CertPEM,
122126 IssuerCertificate: cert.ChainPEM,
@@ -168,10 +172,8 @@ func (i *Issuer) ensureClient(ctx context.Context) error {
168172 // Set up challenge providers
169173 switch i.cfg.Challenge {
170174 case domain.ACMEChallengeHTTP01:
171- if i.cfg.HTTPChallengeSink != nil {
172- if err := client.Challenge.SetHTTP01Provider(NewHTTPProvider(i.cfg.HTTPChallengeSink)); err != nil {
173- return fmt.Errorf("set http-01 provider: %w", err)
174- }
175+ if err := client.Challenge.SetHTTP01Provider(NewHTTPProvider(i.cfg.HTTPChallengeSink)); err != nil {
176+ return fmt.Errorf("set http-01 provider: %w", err)
175177 }
176178 case domain.ACMEChallengeCloudflareDNS01:
177179 cfCfg := cloudflare.NewDefaultConfig()
@@ -8,6 +8,7 @@ import (
8 "encoding/json"8 "encoding/json"
9 "fmt"9 "fmt"
10 "net/http"10 "net/http"
11+ "strings"
11 "sync"12 "sync"
12 "time"13 "time"
13 14
@@ -63,6 +64,9 @@ func NewIssuer(cfg Config) (*Issuer, error) {
63 if cfg.Store == nil {64 if cfg.Store == nil {
64 return nil, fmt.Errorf("acmelego: %w", domain.ErrCertificateStoreRequired)65 return nil, fmt.Errorf("acmelego: %w", domain.ErrCertificateStoreRequired)
65 }66 }
67+ if cfg.CADirectoryURL != "" && !strings.HasPrefix(cfg.CADirectoryURL, "https://") {
68+ return nil, fmt.Errorf("acmelego: CADirectoryURL must use HTTPS, got %q", cfg.CADirectoryURL)
69+ }
66 if cfg.Challenge == "" {70 if cfg.Challenge == "" {
67 cfg.Challenge = domain.ACMEChallengeHTTP0171 cfg.Challenge = domain.ACMEChallengeHTTP01
68 }72 }
@@ -110,13 +114,13 @@ func (i *Issuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.St
110 return nil, err114 return nil, err
111 }115 }
112 116
113- domain := ""117+ domainName := ""
114 if len(cert.Names) > 0 {118 if len(cert.Names) > 0 {
115- domain = cert.Names[0]119+ domainName = cert.Names[0]
116 }120 }
117 121
118 legoCert := certificate.Resource{122 legoCert := certificate.Resource{
119- Domain: domain,123+ Domain: domainName,
120 PrivateKey: cert.PrivateKeyPEM,124 PrivateKey: cert.PrivateKeyPEM,
121 Certificate: cert.CertPEM,125 Certificate: cert.CertPEM,
122 IssuerCertificate: cert.ChainPEM,126 IssuerCertificate: cert.ChainPEM,
@@ -168,10 +172,8 @@ func (i *Issuer) ensureClient(ctx context.Context) error {
168 // Set up challenge providers172 // Set up challenge providers
169 switch i.cfg.Challenge {173 switch i.cfg.Challenge {
170 case domain.ACMEChallengeHTTP01:174 case domain.ACMEChallengeHTTP01:
171- if i.cfg.HTTPChallengeSink != nil {175+ if err := client.Challenge.SetHTTP01Provider(NewHTTPProvider(i.cfg.HTTPChallengeSink)); err != nil {
172- if err := client.Challenge.SetHTTP01Provider(NewHTTPProvider(i.cfg.HTTPChallengeSink)); err != nil {176+ return fmt.Errorf("set http-01 provider: %w", err)
173- return fmt.Errorf("set http-01 provider: %w", err)
174- }
175 }177 }
176 case domain.ACMEChallengeCloudflareDNS01:178 case domain.ACMEChallengeCloudflareDNS01:
177 cfCfg := cloudflare.NewDefaultConfig()179 cfCfg := cloudflare.NewDefaultConfig()
M internal/adapters/out/acmelego/issuer_test.go +22 -0
@@ -69,6 +69,28 @@ func TestNewIssuerValidatesCloudflareToken(t *testing.T) {
6969 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
7070 }
7171
72+func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) {
73+ _, err := NewIssuer(Config{
74+ Email: "test@example.com",
75+ Challenge: domain.ACMEChallengeHTTP01,
76+ Store: outmocks.NewMockCertificateStore(t),
77+ HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
78+ CADirectoryURL: "http://acme.test/directory",
79+ })
80+ require.Error(t, err)
81+ assert.Contains(t, err.Error(), "must use HTTPS")
82+
83+ // HTTPS should be accepted
84+ _, err = NewIssuer(Config{
85+ Email: "test@example.com",
86+ Challenge: domain.ACMEChallengeHTTP01,
87+ Store: outmocks.NewMockCertificateStore(t),
88+ HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
89+ CADirectoryURL: "https://acme.test/directory",
90+ })
91+ require.NoError(t, err)
92+}
93+
7294 func TestNewIssuerValidWithDefaults(t *testing.T) {
7395 issuer, err := NewIssuer(Config{
7496 Email: "test@example.com",
@@ -69,6 +69,28 @@ func TestNewIssuerValidatesCloudflareToken(t *testing.T) {
69 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)69 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
70 }70 }
71 71
72+func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) {
73+ _, err := NewIssuer(Config{
74+ Email: "test@example.com",
75+ Challenge: domain.ACMEChallengeHTTP01,
76+ Store: outmocks.NewMockCertificateStore(t),
77+ HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
78+ CADirectoryURL: "http://acme.test/directory",
79+ })
80+ require.Error(t, err)
81+ assert.Contains(t, err.Error(), "must use HTTPS")
82+
83+ // HTTPS should be accepted
84+ _, err = NewIssuer(Config{
85+ Email: "test@example.com",
86+ Challenge: domain.ACMEChallengeHTTP01,
87+ Store: outmocks.NewMockCertificateStore(t),
88+ HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
89+ CADirectoryURL: "https://acme.test/directory",
90+ })
91+ require.NoError(t, err)
92+}
93+
72 func TestNewIssuerValidWithDefaults(t *testing.T) {94 func TestNewIssuerValidWithDefaults(t *testing.T) {
73 issuer, err := NewIssuer(Config{95 issuer, err := NewIssuer(Config{
74 Email: "test@example.com",96 Email: "test@example.com",
M internal/adapters/out/acmestore/store.go +5 -4
@@ -263,9 +263,10 @@ func writeCertificateFiles(dir string, cert out.StoredCertificate) error {
263263 }
264264
265265 // Lock acquires an exclusive lock using a lock file at <root>/.lock.
266-// The lock is released by calling the returned unlock function.
267-// If the lock file already exists, Lock returns an error.
268-// Context cancellation is respected before attempting the lock.
266+// It is fail-fast and non-blocking: if the lock file already exists, Lock
267+// returns an error immediately without waiting. Context cancellation is
268+// respected before attempting the lock acquisition. The returned unlock
269+// function removes the lock file to clean up.
269270 func (s *Store) Lock(ctx context.Context) (func() error, error) {
270271 select {
271272 case <-ctx.Done():
@@ -296,7 +297,7 @@ func (s *Store) Lock(ctx context.Context) (func() error, error) {
296297
297298 // safeID rejects IDs that could cause path traversal.
298299 func safeID(id string) bool {
299- return id != "" && !strings.Contains(id, "/") && !strings.Contains(id, "\\") && !strings.Contains(id, "..") && !strings.Contains(id, "\x00")
300+ return id != "" && !strings.HasPrefix(id, ".") && !strings.Contains(id, "/") && !strings.Contains(id, "\\") && !strings.Contains(id, "..") && !strings.Contains(id, "\x00")
300301 }
301302
302303 // writeAtomic writes data to path atomically by writing to a temporary file
@@ -263,9 +263,10 @@ func writeCertificateFiles(dir string, cert out.StoredCertificate) error {
263 }263 }
264 264
265 // Lock acquires an exclusive lock using a lock file at <root>/.lock.265 // Lock acquires an exclusive lock using a lock file at <root>/.lock.
266-// The lock is released by calling the returned unlock function.266+// It is fail-fast and non-blocking: if the lock file already exists, Lock
267-// If the lock file already exists, Lock returns an error.267+// returns an error immediately without waiting. Context cancellation is
268-// Context cancellation is respected before attempting the lock.268+// respected before attempting the lock acquisition. The returned unlock
269+// function removes the lock file to clean up.
269 func (s *Store) Lock(ctx context.Context) (func() error, error) {270 func (s *Store) Lock(ctx context.Context) (func() error, error) {
270 select {271 select {
271 case <-ctx.Done():272 case <-ctx.Done():
@@ -296,7 +297,7 @@ func (s *Store) Lock(ctx context.Context) (func() error, error) {
296 297
297 // safeID rejects IDs that could cause path traversal.298 // safeID rejects IDs that could cause path traversal.
298 func safeID(id string) bool {299 func safeID(id string) bool {
299- return id != "" && !strings.Contains(id, "/") && !strings.Contains(id, "\\") && !strings.Contains(id, "..") && !strings.Contains(id, "\x00")300+ return id != "" && !strings.HasPrefix(id, ".") && !strings.Contains(id, "/") && !strings.Contains(id, "\\") && !strings.Contains(id, "..") && !strings.Contains(id, "\x00")
300 }301 }
301 302
302 // writeAtomic writes data to path atomically by writing to a temporary file303 // writeAtomic writes data to path atomically by writing to a temporary file
M internal/adapters/out/acmestore/store_test.go +11 -7
@@ -128,14 +128,18 @@ func TestStoreRejectsUnsafeID(t *testing.T) {
128128 store, err := New(root)
129129 require.NoError(t, err)
130130
131- cert := out.StoredCertificate{
132- ID: "../escape",
133- PrivateKeyPEM: []byte("key"),
131+ for _, id := range []string{"../escape", ".", ".hidden"} {
132+ t.Run(id, func(t *testing.T) {
133+ cert := out.StoredCertificate{
134+ ID: id,
135+ PrivateKeyPEM: []byte("key"),
136+ }
137+
138+ err = store.Save(ctx, cert)
139+ require.Error(t, err)
140+ assert.Contains(t, err.Error(), "unsafe certificate id")
141+ })
134142 }
135-
136- err = store.Save(ctx, cert)
137- require.Error(t, err)
138- assert.Contains(t, err.Error(), "unsafe certificate id")
139143 }
140144
141145 func TestStoreSaveLoadAccount(t *testing.T) {
@@ -128,14 +128,18 @@ func TestStoreRejectsUnsafeID(t *testing.T) {
128 store, err := New(root)128 store, err := New(root)
129 require.NoError(t, err)129 require.NoError(t, err)
130 130
131- cert := out.StoredCertificate{131+ for _, id := range []string{"../escape", ".", ".hidden"} {
132- ID: "../escape",132+ t.Run(id, func(t *testing.T) {
133- PrivateKeyPEM: []byte("key"),133+ cert := out.StoredCertificate{
134+ ID: id,
135+ PrivateKeyPEM: []byte("key"),
136+ }
137+
138+ err = store.Save(ctx, cert)
139+ require.Error(t, err)
140+ assert.Contains(t, err.Error(), "unsafe certificate id")
141+ })
134 }142 }
135-
136- err = store.Save(ctx, cert)
137- require.Error(t, err)
138- assert.Contains(t, err.Error(), "unsafe certificate id")
139 }143 }
140 144
141 func TestStoreSaveLoadAccount(t *testing.T) {145 func TestStoreSaveLoadAccount(t *testing.T) {
M internal/adapters/out/secrets/public_tls_resolver.go +8 -1
@@ -122,8 +122,15 @@ func readFileWithTimeout(ctx context.Context, path string, timeout time.Duration
122122
123123 ch := make(chan result, 1)
124124 go func() {
125+ // os.ReadFile is not context-cancellable. The buffered channel ensures
126+ // this goroutine can finish without blocking if readCtx expires first;
127+ // for local config files this trade-off is acceptable, though reads on
128+ // slow NFS/FUSE mounts may continue after cancellation.
125129 data, err := os.ReadFile(path)
126- ch <- result{data: data, err: err}
130+ select {
131+ case ch <- result{data: data, err: err}:
132+ case <-readCtx.Done():
133+ }
127134 }()
128135
129136 select {
@@ -122,8 +122,15 @@ func readFileWithTimeout(ctx context.Context, path string, timeout time.Duration
122 122
123 ch := make(chan result, 1)123 ch := make(chan result, 1)
124 go func() {124 go func() {
125+ // os.ReadFile is not context-cancellable. The buffered channel ensures
126+ // this goroutine can finish without blocking if readCtx expires first;
127+ // for local config files this trade-off is acceptable, though reads on
128+ // slow NFS/FUSE mounts may continue after cancellation.
125 data, err := os.ReadFile(path)129 data, err := os.ReadFile(path)
126- ch <- result{data: data, err: err}130+ select {
131+ case ch <- result{data: data, err: err}:
132+ case <-readCtx.Done():
133+ }
127 }()134 }()
128 135
129 select {136 select {
M internal/app/migrate_env_test.go +2 -2
@@ -32,7 +32,7 @@ func requirePass(t *testing.T) {
3232 }
3333 }
3434
35-func cleanupPassDomain(_ *testing.T, domainName string, keys []string) {
35+func cleanupPassDomain(domainName string, keys []string) {
3636 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
3737 if err != nil {
3838 return
@@ -60,7 +60,7 @@ func TestMigrateEnvFile(t *testing.T) {
6060
6161 domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano())
6262 keys := []string{"API_KEY", "DB_PASSWORD"}
63- defer cleanupPassDomain(t, domainName, keys)
63+ defer cleanupPassDomain(domainName, keys)
6464
6565 envContent := "API_KEY=secret123\nDB_PASSWORD=pass456\n"
6666 envFileName := domainName + ".env"
@@ -32,7 +32,7 @@ func requirePass(t *testing.T) {
32 }32 }
33 }33 }
34 34
35-func cleanupPassDomain(_ *testing.T, domainName string, keys []string) {35+func cleanupPassDomain(domainName string, keys []string) {
36 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)36 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
37 if err != nil {37 if err != nil {
38 return38 return
@@ -60,7 +60,7 @@ func TestMigrateEnvFile(t *testing.T) {
60 60
61 domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano())61 domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano())
62 keys := []string{"API_KEY", "DB_PASSWORD"}62 keys := []string{"API_KEY", "DB_PASSWORD"}
63- defer cleanupPassDomain(t, domainName, keys)63+ defer cleanupPassDomain(domainName, keys)
64 64
65 envContent := "API_KEY=secret123\nDB_PASSWORD=pass456\n"65 envContent := "API_KEY=secret123\nDB_PASSWORD=pass456\n"
66 envFileName := domainName + ".env"66 envFileName := domainName + ".env"
M internal/domain/errors.go +9 -0
@@ -91,4 +91,13 @@ var (
9191 ErrAttachmentImageEmpty = errors.New("attachment image cannot be empty")
9292 ErrAttachmentTargetEmpty = errors.New("attachment target (domain or group) cannot be empty")
9393 ErrAttachmentNotDeployed = errors.New("configured attachment is not deployed; run gordon deploy to create it")
94+
95+ // TLS / ACME errors
96+ ErrACMEDisabled = errors.New("acme disabled")
97+ ErrACMEEmailRequired = errors.New("acme email required")
98+ ErrACMEChallengeInvalid = errors.New("acme challenge invalid")
99+ ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")
100+ ErrCertificateStoreRequired = errors.New("certificate store required")
101+ ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required")
102+ ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate")
94103 )
@@ -91,4 +91,13 @@ var (
91 ErrAttachmentImageEmpty = errors.New("attachment image cannot be empty")91 ErrAttachmentImageEmpty = errors.New("attachment image cannot be empty")
92 ErrAttachmentTargetEmpty = errors.New("attachment target (domain or group) cannot be empty")92 ErrAttachmentTargetEmpty = errors.New("attachment target (domain or group) cannot be empty")
93 ErrAttachmentNotDeployed = errors.New("configured attachment is not deployed; run gordon deploy to create it")93 ErrAttachmentNotDeployed = errors.New("configured attachment is not deployed; run gordon deploy to create it")
94+
95+ // TLS / ACME errors
96+ ErrACMEDisabled = errors.New("acme disabled")
97+ ErrACMEEmailRequired = errors.New("acme email required")
98+ ErrACMEChallengeInvalid = errors.New("acme challenge invalid")
99+ ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")
100+ ErrCertificateStoreRequired = errors.New("certificate store required")
101+ ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required")
102+ ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate")
94 )103 )
M internal/domain/public_tls.go +0 -11
@@ -1,7 +1,6 @@
11 package domain
22
33 import (
4- "errors"
54 "fmt"
65 "strings"
76 "time"
@@ -15,16 +14,6 @@ const (
1514 ACMEChallengeCloudflareDNS01 ACMEChallengeMode = "cloudflare-dns-01"
1615 )
1716
18-var (
19- ErrACMEDisabled = errors.New("acme disabled")
20- ErrACMEEmailRequired = errors.New("acme email required")
21- ErrACMEChallengeInvalid = errors.New("acme challenge invalid")
22- ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")
23- ErrCertificateStoreRequired = errors.New("certificate store required")
24- ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required")
25- ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate")
26-)
27-
2817 func ParseACMEChallengeMode(value string) (ACMEChallengeMode, error) {
2918 switch strings.TrimSpace(value) {
3019 case "", string(ACMEChallengeAuto):
@@ -1,7 +1,6 @@
1 package domain1 package domain
2 2
3 import (3 import (
4- "errors"
5 "fmt"4 "fmt"
6 "strings"5 "strings"
7 "time"6 "time"
@@ -15,16 +14,6 @@ const (
15 ACMEChallengeCloudflareDNS01 ACMEChallengeMode = "cloudflare-dns-01"14 ACMEChallengeCloudflareDNS01 ACMEChallengeMode = "cloudflare-dns-01"
16 )15 )
17 16
18-var (
19- ErrACMEDisabled = errors.New("acme disabled")
20- ErrACMEEmailRequired = errors.New("acme email required")
21- ErrACMEChallengeInvalid = errors.New("acme challenge invalid")
22- ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")
23- ErrCertificateStoreRequired = errors.New("certificate store required")
24- ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required")
25- ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate")
26-)
27-
28 func ParseACMEChallengeMode(value string) (ACMEChallengeMode, error) {17 func ParseACMEChallengeMode(value string) (ACMEChallengeMode, error) {
29 switch strings.TrimSpace(value) {18 switch strings.TrimSpace(value) {
30 case "", string(ACMEChallengeAuto):19 case "", string(ACMEChallengeAuto):
M internal/usecase/publictls/config.go +4 -7
@@ -3,7 +3,6 @@ package publictls
33 import (
44 "context"
55 "fmt"
6- "strings"
76
87 "github.com/bnema/gordon/internal/boundaries/out"
98 "github.com/bnema/gordon/internal/domain"
@@ -117,11 +116,10 @@ func resolveOptionalToken(ctx context.Context, resolver out.SecretResolver) (str
117116 if err != nil {
118117 return "", domain.ACMETokenSourceNone
119118 }
120- token := strings.TrimSpace(sv.Value)
121- if token == "" {
119+ if sv.Value == "" {
122120 return "", domain.ACMETokenSourceNone
123121 }
124- return token, sv.Source
122+ return sv.Value, sv.Source
125123 }
126124
127125 // resolveToken resolves the Cloudflare token via the resolver, propagating errors.
@@ -134,9 +132,8 @@ func resolveToken(ctx context.Context, resolver out.SecretResolver) (string, dom
134132 if err != nil {
135133 return "", domain.ACMETokenSourceNone, err
136134 }
137- token := strings.TrimSpace(sv.Value)
138- if token == "" {
135+ if sv.Value == "" {
139136 return "", domain.ACMETokenSourceNone, nil
140137 }
141- return token, sv.Source, nil
138+ return sv.Value, sv.Source, nil
142139 }
@@ -3,7 +3,6 @@ package publictls
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "strings"
7 6
8 "github.com/bnema/gordon/internal/boundaries/out"7 "github.com/bnema/gordon/internal/boundaries/out"
9 "github.com/bnema/gordon/internal/domain"8 "github.com/bnema/gordon/internal/domain"
@@ -117,11 +116,10 @@ func resolveOptionalToken(ctx context.Context, resolver out.SecretResolver) (str
117 if err != nil {116 if err != nil {
118 return "", domain.ACMETokenSourceNone117 return "", domain.ACMETokenSourceNone
119 }118 }
120- token := strings.TrimSpace(sv.Value)119+ if sv.Value == "" {
121- if token == "" {
122 return "", domain.ACMETokenSourceNone120 return "", domain.ACMETokenSourceNone
123 }121 }
124- return token, sv.Source122+ return sv.Value, sv.Source
125 }123 }
126 124
127 // resolveToken resolves the Cloudflare token via the resolver, propagating errors.125 // resolveToken resolves the Cloudflare token via the resolver, propagating errors.
@@ -134,9 +132,8 @@ func resolveToken(ctx context.Context, resolver out.SecretResolver) (string, dom
134 if err != nil {132 if err != nil {
135 return "", domain.ACMETokenSourceNone, err133 return "", domain.ACMETokenSourceNone, err
136 }134 }
137- token := strings.TrimSpace(sv.Value)135+ if sv.Value == "" {
138- if token == "" {
139 return "", domain.ACMETokenSourceNone, nil136 return "", domain.ACMETokenSourceNone, nil
140 }137 }
141- return token, sv.Source, nil138+ return sv.Value, sv.Source, nil
142 }139 }
M internal/usecase/publictls/http01.go +5 -1
@@ -31,8 +31,12 @@ func (c *HTTP01Challenges) Present(token, keyAuth string) {
3131 c.data[token] = keyAuth
3232 }
3333
34-// CleanUp removes the stored key authorization for the given token.
34+// CleanUp removes the stored key authorization for the given token. It silently
35+// ignores unsafe tokens (see safeHTTP01Token).
3536 func (c *HTTP01Challenges) CleanUp(token string) {
37+ if !safeHTTP01Token(token) {
38+ return
39+ }
3640 c.mu.Lock()
3741 defer c.mu.Unlock()
3842 delete(c.data, token)
@@ -31,8 +31,12 @@ func (c *HTTP01Challenges) Present(token, keyAuth string) {
31 c.data[token] = keyAuth31 c.data[token] = keyAuth
32 }32 }
33 33
34-// CleanUp removes the stored key authorization for the given token.34+// CleanUp removes the stored key authorization for the given token. It silently
35+// ignores unsafe tokens (see safeHTTP01Token).
35 func (c *HTTP01Challenges) CleanUp(token string) {36 func (c *HTTP01Challenges) CleanUp(token string) {
37+ if !safeHTTP01Token(token) {
38+ return
39+ }
36 c.mu.Lock()40 c.mu.Lock()
37 defer c.mu.Unlock()41 defer c.mu.Unlock()
38 delete(c.data, token)42 delete(c.data, token)
M internal/usecase/publictls/renewal.go +5 -4
@@ -100,18 +100,19 @@ func (s *Service) renewDueCertificates(ctx context.Context, now time.Time) error
100100 }
101101 defer func() { _ = unlock() }()
102102
103- // Collect due certs under lock, then release before making external calls.
103+ // Collect due certs under lock, copying values before releasing the lock so
104+ // renewal uses a stable snapshot even if the cache changes concurrently.
104105 s.mu.Lock()
105- due := make([]*out.StoredCertificate, 0)
106+ due := make([]out.StoredCertificate, 0)
106107 for _, cert := range s.certs {
107108 if ShouldRenew(*cert, now) {
108- due = append(due, cert)
109+ due = append(due, *cert)
109110 }
110111 }
111112 s.mu.Unlock()
112113
113114 for _, cert := range due {
114- renewed, err := s.deps.Issuer.Renew(ctx, *cert)
115+ renewed, err := s.deps.Issuer.Renew(ctx, cert)
115116 if err != nil {
116117 s.mu.Lock()
117118 s.lastErr[cert.ID] = fmt.Sprintf("renew: %v", err)
@@ -100,18 +100,19 @@ func (s *Service) renewDueCertificates(ctx context.Context, now time.Time) error
100 }100 }
101 defer func() { _ = unlock() }()101 defer func() { _ = unlock() }()
102 102
103- // Collect due certs under lock, then release before making external calls.103+ // Collect due certs under lock, copying values before releasing the lock so
104+ // renewal uses a stable snapshot even if the cache changes concurrently.
104 s.mu.Lock()105 s.mu.Lock()
105- due := make([]*out.StoredCertificate, 0)106+ due := make([]out.StoredCertificate, 0)
106 for _, cert := range s.certs {107 for _, cert := range s.certs {
107 if ShouldRenew(*cert, now) {108 if ShouldRenew(*cert, now) {
108- due = append(due, cert)109+ due = append(due, *cert)
109 }110 }
110 }111 }
111 s.mu.Unlock()112 s.mu.Unlock()
112 113
113 for _, cert := range due {114 for _, cert := range due {
114- renewed, err := s.deps.Issuer.Renew(ctx, *cert)115+ renewed, err := s.deps.Issuer.Renew(ctx, cert)
115 if err != nil {116 if err != nil {
116 s.mu.Lock()117 s.mu.Lock()
117 s.lastErr[cert.ID] = fmt.Sprintf("renew: %v", err)118 s.lastErr[cert.ID] = fmt.Sprintf("renew: %v", err)
M internal/usecase/publictls/service.go +7 -2
@@ -9,9 +9,10 @@ import (
99 "sync"
1010 "time"
1111
12+ "github.com/bnema/zerowrap"
13+
1214 "github.com/bnema/gordon/internal/boundaries/out"
1315 "github.com/bnema/gordon/internal/domain"
14- "github.com/bnema/zerowrap"
1516 )
1617
1718 // RouteSource provides routes from which certificate targets are derived.
@@ -182,7 +183,11 @@ func (s *Service) Reconcile(ctx context.Context) error {
182183 if err != nil {
183184 return fmt.Errorf("acquire store lock: %w", err)
184185 }
185- defer func() { _ = unlock() }()
186+ defer func() {
187+ if err := unlock(); err != nil {
188+ log.Warn().Err(err).Msg("failed to release store lock")
189+ }
190+ }()
186191
187192 // Under mu: update requiredHosts (with target names) and compute missing targets.
188193 s.mu.Lock()
@@ -9,9 +9,10 @@ import (
9 "sync"9 "sync"
10 "time"10 "time"
11 11
12+ "github.com/bnema/zerowrap"
13+
12 "github.com/bnema/gordon/internal/boundaries/out"14 "github.com/bnema/gordon/internal/boundaries/out"
13 "github.com/bnema/gordon/internal/domain"15 "github.com/bnema/gordon/internal/domain"
14- "github.com/bnema/zerowrap"
15 )16 )
16 17
17 // RouteSource provides routes from which certificate targets are derived.18 // RouteSource provides routes from which certificate targets are derived.
@@ -182,7 +183,11 @@ func (s *Service) Reconcile(ctx context.Context) error {
182 if err != nil {183 if err != nil {
183 return fmt.Errorf("acquire store lock: %w", err)184 return fmt.Errorf("acquire store lock: %w", err)
184 }185 }
185- defer func() { _ = unlock() }()186+ defer func() {
187+ if err := unlock(); err != nil {
188+ log.Warn().Err(err).Msg("failed to release store lock")
189+ }
190+ }()
186 191
187 // Under mu: update requiredHosts (with target names) and compute missing targets.192 // Under mu: update requiredHosts (with target names) and compute missing targets.
188 s.mu.Lock()193 s.mu.Lock()
M internal/usecase/publictls/service_test.go +13 -14
@@ -42,17 +42,17 @@ type fakeRoutes struct {
4242 func (f *fakeRoutes) GetRoutes(_ context.Context) []domain.Route {
4343 f.mu.Lock()
4444 defer f.mu.Unlock()
45- out := make([]domain.Route, len(f.routes))
46- copy(out, f.routes)
47- return out
45+ routesCopy := make([]domain.Route, len(f.routes))
46+ copy(routesCopy, f.routes)
47+ return routesCopy
4848 }
4949
5050 func (f *fakeRoutes) GetExternalRoutes() map[string]string {
5151 f.mu.Lock()
5252 defer f.mu.Unlock()
53- out := make(map[string]string, len(f.external))
54- maps.Copy(out, f.external)
55- return out
53+ externalCopy := make(map[string]string, len(f.external))
54+ maps.Copy(externalCopy, f.external)
55+ return externalCopy
5656 }
5757
5858 type mockIssuerRecorder struct {
@@ -426,13 +426,6 @@ func TestServiceGetCertificateReturnsErrTLSRouteNotCovered(t *testing.T) {
426426 assert.Contains(t, err.Error(), "app.example.com")
427427 }
428428
429-// brokenZoneResolver always returns an error.
430-type brokenZoneResolver struct{}
431-
432-func (brokenZoneResolver) FindZone(_ context.Context, host string) (out.CloudflareZone, error) {
433- return out.CloudflareZone{}, fmt.Errorf("zone resolver unavailable for %s", host)
434-}
435-
436429 func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *testing.T) {
437430 ctx := context.Background()
438431
@@ -444,6 +437,12 @@ func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *test
444437 issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
445438 store, _ := newMockCertificateStore(t)
446439
440+ // Use generated mock that always returns an error.
441+ zoneResolver := outmocks.NewMockCloudflareZoneResolver(t)
442+ zoneResolver.EXPECT().FindZone(mock.Anything, "app.example.com").Return(
443+ out.CloudflareZone{}, fmt.Errorf("zone resolver unavailable for app.example.com"),
444+ )
445+
447446 cfg := Config{
448447 Enabled: true,
449448 Email: "admin@example.com",
@@ -457,7 +456,7 @@ func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *test
457456 Routes: routes,
458457 Issuer: issuer,
459458 Store: store,
460- ZoneResolver: brokenZoneResolver{}, // broken: always fails
459+ ZoneResolver: zoneResolver, // generated mock that always fails
461460 Challenges: NewHTTP01Challenges(),
462461 Effective: EffectiveChallenge{
463462 ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,
@@ -42,17 +42,17 @@ type fakeRoutes struct {
42 func (f *fakeRoutes) GetRoutes(_ context.Context) []domain.Route {42 func (f *fakeRoutes) GetRoutes(_ context.Context) []domain.Route {
43 f.mu.Lock()43 f.mu.Lock()
44 defer f.mu.Unlock()44 defer f.mu.Unlock()
45- out := make([]domain.Route, len(f.routes))45+ routesCopy := make([]domain.Route, len(f.routes))
46- copy(out, f.routes)46+ copy(routesCopy, f.routes)
47- return out47+ return routesCopy
48 }48 }
49 49
50 func (f *fakeRoutes) GetExternalRoutes() map[string]string {50 func (f *fakeRoutes) GetExternalRoutes() map[string]string {
51 f.mu.Lock()51 f.mu.Lock()
52 defer f.mu.Unlock()52 defer f.mu.Unlock()
53- out := make(map[string]string, len(f.external))53+ externalCopy := make(map[string]string, len(f.external))
54- maps.Copy(out, f.external)54+ maps.Copy(externalCopy, f.external)
55- return out55+ return externalCopy
56 }56 }
57 57
58 type mockIssuerRecorder struct {58 type mockIssuerRecorder struct {
@@ -426,13 +426,6 @@ func TestServiceGetCertificateReturnsErrTLSRouteNotCovered(t *testing.T) {
426 assert.Contains(t, err.Error(), "app.example.com")426 assert.Contains(t, err.Error(), "app.example.com")
427 }427 }
428 428
429-// brokenZoneResolver always returns an error.
430-type brokenZoneResolver struct{}
431-
432-func (brokenZoneResolver) FindZone(_ context.Context, host string) (out.CloudflareZone, error) {
433- return out.CloudflareZone{}, fmt.Errorf("zone resolver unavailable for %s", host)
434-}
435-
436 func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *testing.T) {429 func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *testing.T) {
437 ctx := context.Background()430 ctx := context.Background()
438 431
@@ -444,6 +437,12 @@ func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *test
444 issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)437 issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
445 store, _ := newMockCertificateStore(t)438 store, _ := newMockCertificateStore(t)
446 439
440+ // Use generated mock that always returns an error.
441+ zoneResolver := outmocks.NewMockCloudflareZoneResolver(t)
442+ zoneResolver.EXPECT().FindZone(mock.Anything, "app.example.com").Return(
443+ out.CloudflareZone{}, fmt.Errorf("zone resolver unavailable for app.example.com"),
444+ )
445+
447 cfg := Config{446 cfg := Config{
448 Enabled: true,447 Enabled: true,
449 Email: "admin@example.com",448 Email: "admin@example.com",
@@ -457,7 +456,7 @@ func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *test
457 Routes: routes,456 Routes: routes,
458 Issuer: issuer,457 Issuer: issuer,
459 Store: store,458 Store: store,
460- ZoneResolver: brokenZoneResolver{}, // broken: always fails459+ ZoneResolver: zoneResolver, // generated mock that always fails
461 Challenges: NewHTTP01Challenges(),460 Challenges: NewHTTP01Challenges(),
462 Effective: EffectiveChallenge{461 Effective: EffectiveChallenge{
463 ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,462 ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,
M internal/usecase/publictls/status.go +25 -9
@@ -119,7 +119,8 @@ func (s *Service) findCoverageLocked(routeDomain string, managedCerts []domain.M
119119 case domain.TLSCertificateStatusExpired, domain.TLSCertificateStatusMissing, domain.TLSCertificateStatusError:
120120 continue
121121 }
122- // Double-check expiry from the raw stored cert.
122+ // Double-check the raw stored cert expiry to guard against stale
123+ // ManagedCertificate.Status values that may not reflect real expiry.
123124 if sc, ok := s.certs[mc.ID]; ok {
124125 if !sc.NotAfter.IsZero() && !now.Before(sc.NotAfter) {
125126 continue
@@ -130,12 +131,26 @@ func (s *Service) findCoverageLocked(routeDomain string, managedCerts []domain.M
130131 return false, ""
131132 }
132133
133-// keyEqValuePattern matches key=value patterns for known sensitive keys
134-// in a case-insensitive manner.
135-var keyEqValuePattern = regexp.MustCompile(`(?i)(token|secret|key|password|pass|api_key|apikey|auth)=[^\s]+`)
134+// Sensitive key names that may appear in error messages.
135+var sensitiveKeys = `(token|secret|key|password|pass|api_key|apikey|auth)`
136+
137+// Patterns for redacting sensitive values in common formats.
138+var (
139+ // key=value (unquoted, case-insensitive)
140+ keyEqValueRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `=[^\s"']+`)
141+ // key="value" (double-quoted)
142+ keyEqQuotedRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `="[^"]*"`)
143+ // key='value' (single-quoted)
144+ keyEqSingleQuotedRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `='[^']*'`)
145+ // JSON "key": "value"
146+ jsonKeyValueRe = regexp.MustCompile(`(?i)"` + sensitiveKeys + `"\s*:\s*"[^"]*"`)
147+ // YAML key: value (start of line or after indent)
148+ yamlKeyValueRe = regexp.MustCompile(`(?i)(?m)^\s*` + sensitiveKeys + `\s*:\s+\S+`)
149+)
136150
137151 // sanitizeError removes obvious sensitive strings from error messages.
138-// It redacts key=value patterns for known sensitive keys while preserving
152+// It redacts key=value, key="value", key='value', JSON "key": "value",
153+// and YAML key: value patterns for known sensitive keys while preserving
139154 // safe context. It does not blanket-redact messages just because they
140155 // contain the word "secret" or similar.
141156 func sanitizeError(err string) string {
@@ -143,9 +158,10 @@ func sanitizeError(err string) string {
143158 return ""
144159 }
145160
146- result := keyEqValuePattern.ReplaceAllString(err, "$1=redacted")
147- if result == "" {
148- return "certificate error"
149- }
161+ result := keyEqValueRe.ReplaceAllString(err, "$1=redacted")
162+ result = keyEqQuotedRe.ReplaceAllString(result, `$1="redacted"`)
163+ result = keyEqSingleQuotedRe.ReplaceAllString(result, "$1='redacted'")
164+ result = jsonKeyValueRe.ReplaceAllString(result, `"$1":"redacted"`)
165+ result = yamlKeyValueRe.ReplaceAllString(result, "$1: redacted")
150166 return result
151167 }
@@ -119,7 +119,8 @@ func (s *Service) findCoverageLocked(routeDomain string, managedCerts []domain.M
119 case domain.TLSCertificateStatusExpired, domain.TLSCertificateStatusMissing, domain.TLSCertificateStatusError:119 case domain.TLSCertificateStatusExpired, domain.TLSCertificateStatusMissing, domain.TLSCertificateStatusError:
120 continue120 continue
121 }121 }
122- // Double-check expiry from the raw stored cert.122+ // Double-check the raw stored cert expiry to guard against stale
123+ // ManagedCertificate.Status values that may not reflect real expiry.
123 if sc, ok := s.certs[mc.ID]; ok {124 if sc, ok := s.certs[mc.ID]; ok {
124 if !sc.NotAfter.IsZero() && !now.Before(sc.NotAfter) {125 if !sc.NotAfter.IsZero() && !now.Before(sc.NotAfter) {
125 continue126 continue
@@ -130,12 +131,26 @@ func (s *Service) findCoverageLocked(routeDomain string, managedCerts []domain.M
130 return false, ""131 return false, ""
131 }132 }
132 133
133-// keyEqValuePattern matches key=value patterns for known sensitive keys134+// Sensitive key names that may appear in error messages.
134-// in a case-insensitive manner.135+var sensitiveKeys = `(token|secret|key|password|pass|api_key|apikey|auth)`
135-var keyEqValuePattern = regexp.MustCompile(`(?i)(token|secret|key|password|pass|api_key|apikey|auth)=[^\s]+`)136+
137+// Patterns for redacting sensitive values in common formats.
138+var (
139+ // key=value (unquoted, case-insensitive)
140+ keyEqValueRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `=[^\s"']+`)
141+ // key="value" (double-quoted)
142+ keyEqQuotedRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `="[^"]*"`)
143+ // key='value' (single-quoted)
144+ keyEqSingleQuotedRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `='[^']*'`)
145+ // JSON "key": "value"
146+ jsonKeyValueRe = regexp.MustCompile(`(?i)"` + sensitiveKeys + `"\s*:\s*"[^"]*"`)
147+ // YAML key: value (start of line or after indent)
148+ yamlKeyValueRe = regexp.MustCompile(`(?i)(?m)^\s*` + sensitiveKeys + `\s*:\s+\S+`)
149+)
136 150
137 // sanitizeError removes obvious sensitive strings from error messages.151 // sanitizeError removes obvious sensitive strings from error messages.
138-// It redacts key=value patterns for known sensitive keys while preserving152+// It redacts key=value, key="value", key='value', JSON "key": "value",
153+// and YAML key: value patterns for known sensitive keys while preserving
139 // safe context. It does not blanket-redact messages just because they154 // safe context. It does not blanket-redact messages just because they
140 // contain the word "secret" or similar.155 // contain the word "secret" or similar.
141 func sanitizeError(err string) string {156 func sanitizeError(err string) string {
@@ -143,9 +158,10 @@ func sanitizeError(err string) string {
143 return ""158 return ""
144 }159 }
145 160
146- result := keyEqValuePattern.ReplaceAllString(err, "$1=redacted")161+ result := keyEqValueRe.ReplaceAllString(err, "$1=redacted")
147- if result == "" {162+ result = keyEqQuotedRe.ReplaceAllString(result, `$1="redacted"`)
148- return "certificate error"163+ result = keyEqSingleQuotedRe.ReplaceAllString(result, "$1='redacted'")
149- }164+ result = jsonKeyValueRe.ReplaceAllString(result, `"$1":"redacted"`)
165+ result = yamlKeyValueRe.ReplaceAllString(result, "$1: redacted")
150 return result166 return result
151 }167 }
A internal/usecase/publictls/status_test.go +77 -0
@@ -0,0 +1,77 @@
1+package publictls
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestSanitizeError_Empty(t *testing.T) {
10+ assert.Equal(t, "", sanitizeError(""))
11+}
12+
13+func TestSanitizeError_NoSensitiveData(t *testing.T) {
14+ msg := "failed to obtain certificate: acme server returned 429"
15+ assert.Equal(t, msg, sanitizeError(msg))
16+}
17+
18+func TestSanitizeError_KeyEqValue(t *testing.T) {
19+ result := sanitizeError("token=sk-secret-goes-here provider said invalid")
20+ assert.NotContains(t, result, "sk-secret-goes-here")
21+ assert.Contains(t, result, "token=redacted")
22+}
23+
24+func TestSanitizeError_KeyEqQuotedValue(t *testing.T) {
25+ result := sanitizeError(`secret="my-secret-value" is invalid`)
26+ assert.NotContains(t, result, "my-secret-value")
27+ assert.Contains(t, result, `secret="redacted"`)
28+}
29+
30+func TestSanitizeError_KeyEqSingleQuotedValue(t *testing.T) {
31+ result := sanitizeError(`key='my-api-key-12345' not found`)
32+ assert.NotContains(t, result, "my-api-key-12345")
33+ assert.Contains(t, result, "key='redacted'")
34+}
35+
36+func TestSanitizeError_JSONKeyValue(t *testing.T) {
37+ result := sanitizeError(`{"token":"s3kr1t","domain":"example.com"}`)
38+ assert.NotContains(t, result, "s3kr1t")
39+ assert.Contains(t, result, `"token":"redacted"`)
40+ // Non-sensitive fields should be preserved
41+ assert.Contains(t, result, `"domain":"example.com"`)
42+}
43+
44+func TestSanitizeError_YAMLKeyValue(t *testing.T) {
45+ result := sanitizeError("configuration error:\n api_key: abcdef123456\n password: hunter2")
46+ assert.NotContains(t, result, "abcdef123456")
47+ assert.NotContains(t, result, "hunter2")
48+ assert.Contains(t, result, "api_key: redacted")
49+ assert.Contains(t, result, "password: redacted")
50+ // Non-sensitive context preserved
51+ assert.Contains(t, result, "configuration error:")
52+}
53+
54+func TestSanitizeError_CaseInsensitive(t *testing.T) {
55+ result := sanitizeError("Token=sk-live-abc123 and SECRET=top-secret-value")
56+ assert.NotContains(t, result, "sk-live-abc123")
57+ assert.NotContains(t, result, "top-secret-value")
58+ assert.Contains(t, result, "Token=redacted")
59+ assert.Contains(t, result, "SECRET=redacted")
60+}
61+
62+func TestSanitizeError_MixedFormats(t *testing.T) {
63+ result := sanitizeError(`token=plain, secret="quoted", auth='single-quoted', and json {"password":"p4ss"}`)
64+ assert.NotContains(t, result, "plain")
65+ assert.NotContains(t, result, "quoted")
66+ assert.NotContains(t, result, "single-quoted")
67+ assert.NotContains(t, result, "p4ss")
68+ assert.Contains(t, result, "token=redacted")
69+ assert.Contains(t, result, `secret="redacted"`)
70+ assert.Contains(t, result, "auth='redacted'")
71+ assert.Contains(t, result, `"password":"redacted"`)
72+}
73+
74+func TestSanitizeError_PreservesSafeContext(t *testing.T) {
75+ msg := "certificate for example.com failed: acme error 403"
76+ assert.Equal(t, msg, sanitizeError(msg))
77+}
@@ -0,0 +1,77 @@
1+package publictls
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestSanitizeError_Empty(t *testing.T) {
10+ assert.Equal(t, "", sanitizeError(""))
11+}
12+
13+func TestSanitizeError_NoSensitiveData(t *testing.T) {
14+ msg := "failed to obtain certificate: acme server returned 429"
15+ assert.Equal(t, msg, sanitizeError(msg))
16+}
17+
18+func TestSanitizeError_KeyEqValue(t *testing.T) {
19+ result := sanitizeError("token=sk-secret-goes-here provider said invalid")
20+ assert.NotContains(t, result, "sk-secret-goes-here")
21+ assert.Contains(t, result, "token=redacted")
22+}
23+
24+func TestSanitizeError_KeyEqQuotedValue(t *testing.T) {
25+ result := sanitizeError(`secret="my-secret-value" is invalid`)
26+ assert.NotContains(t, result, "my-secret-value")
27+ assert.Contains(t, result, `secret="redacted"`)
28+}
29+
30+func TestSanitizeError_KeyEqSingleQuotedValue(t *testing.T) {
31+ result := sanitizeError(`key='my-api-key-12345' not found`)
32+ assert.NotContains(t, result, "my-api-key-12345")
33+ assert.Contains(t, result, "key='redacted'")
34+}
35+
36+func TestSanitizeError_JSONKeyValue(t *testing.T) {
37+ result := sanitizeError(`{"token":"s3kr1t","domain":"example.com"}`)
38+ assert.NotContains(t, result, "s3kr1t")
39+ assert.Contains(t, result, `"token":"redacted"`)
40+ // Non-sensitive fields should be preserved
41+ assert.Contains(t, result, `"domain":"example.com"`)
42+}
43+
44+func TestSanitizeError_YAMLKeyValue(t *testing.T) {
45+ result := sanitizeError("configuration error:\n api_key: abcdef123456\n password: hunter2")
46+ assert.NotContains(t, result, "abcdef123456")
47+ assert.NotContains(t, result, "hunter2")
48+ assert.Contains(t, result, "api_key: redacted")
49+ assert.Contains(t, result, "password: redacted")
50+ // Non-sensitive context preserved
51+ assert.Contains(t, result, "configuration error:")
52+}
53+
54+func TestSanitizeError_CaseInsensitive(t *testing.T) {
55+ result := sanitizeError("Token=sk-live-abc123 and SECRET=top-secret-value")
56+ assert.NotContains(t, result, "sk-live-abc123")
57+ assert.NotContains(t, result, "top-secret-value")
58+ assert.Contains(t, result, "Token=redacted")
59+ assert.Contains(t, result, "SECRET=redacted")
60+}
61+
62+func TestSanitizeError_MixedFormats(t *testing.T) {
63+ result := sanitizeError(`token=plain, secret="quoted", auth='single-quoted', and json {"password":"p4ss"}`)
64+ assert.NotContains(t, result, "plain")
65+ assert.NotContains(t, result, "quoted")
66+ assert.NotContains(t, result, "single-quoted")
67+ assert.NotContains(t, result, "p4ss")
68+ assert.Contains(t, result, "token=redacted")
69+ assert.Contains(t, result, `secret="redacted"`)
70+ assert.Contains(t, result, "auth='redacted'")
71+ assert.Contains(t, result, `"password":"redacted"`)
72+}
73+
74+func TestSanitizeError_PreservesSafeContext(t *testing.T) {
75+ msg := "certificate for example.com failed: acme error 403"
76+ assert.Equal(t, msg, sanitizeError(msg))
77+}
M internal/usecase/publictls/targets.go +3 -0
@@ -117,6 +117,9 @@ func deriveDNS01Targets(ctx context.Context, hosts []string, resolver out.Cloudf
117117 }
118118
119119 // wildcardBase determines the wildcard base for a host within a zone.
120+// This is used by deriveDNS01Targets to group hosts under a common
121+// wildcard certificate (e.g. example.com + *.example.com). Hosts that
122+// share the same wildcard base are combined into a single DNS-01 target.
120123 //
121124 // If host == zone, returns zone.
122125 // If host is a direct child of zone, returns zone.
@@ -117,6 +117,9 @@ func deriveDNS01Targets(ctx context.Context, hosts []string, resolver out.Cloudf
117 }117 }
118 118
119 // wildcardBase determines the wildcard base for a host within a zone.119 // wildcardBase determines the wildcard base for a host within a zone.
120+// This is used by deriveDNS01Targets to group hosts under a common
121+// wildcard certificate (e.g. example.com + *.example.com). Hosts that
122+// share the same wildcard base are combined into a single DNS-01 target.
120 //123 //
121 // If host == zone, returns zone.124 // If host == zone, returns zone.
122 // If host is a direct child of zone, returns zone.125 // If host is a direct child of zone, returns zone.

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