detached b85bb4e

simplify public ACME TLS implementation

commit
b85bb4ef045181424f7a92831448cbeeb7c74f89
author
brice · · 2026-04-29 07:35 UTC
parent
ed49430
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +152 -194
M internal/adapters/dto/tls.go +43 -1
@@ -1,6 +1,10 @@
11 package dto
22
3-import "time"
3+import (
4+ "time"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+)
48
59 // TLSStatusResponse represents the public TLS/ACME status for the admin API.
610 type TLSStatusResponse struct {
@@ -33,3 +37,41 @@ type TLSRouteCoverage struct {
3337 RequiredACME bool `json:"required_acme"`
3438 Error string `json:"error,omitempty"`
3539 }
40+
41+// TLSStatusFromDomain converts a public TLS domain status to its transport DTO.
42+func TLSStatusFromDomain(s domain.PublicTLSStatus) TLSStatusResponse {
43+ certs := make([]TLSCertificateEntry, 0, len(s.Certificates))
44+ for _, c := range s.Certificates {
45+ certs = append(certs, TLSCertificateEntry{
46+ ID: c.ID,
47+ Names: c.Names,
48+ Challenge: string(c.Challenge),
49+ Status: string(c.Status),
50+ NotAfter: c.NotAfter,
51+ LastError: c.LastError,
52+ RenewalPending: c.RenewalPending,
53+ })
54+ }
55+
56+ routes := make([]TLSRouteCoverage, 0, len(s.Routes))
57+ for _, r := range s.Routes {
58+ routes = append(routes, TLSRouteCoverage{
59+ Domain: r.Domain,
60+ Covered: r.Covered,
61+ CoveredBy: r.CoveredBy,
62+ RequiredACME: r.RequiredACME,
63+ Error: r.Error,
64+ })
65+ }
66+
67+ return TLSStatusResponse{
68+ ACMEEnabled: s.ACMEEnabled,
69+ ConfiguredMode: string(s.ConfiguredMode),
70+ EffectiveMode: string(s.EffectiveMode),
71+ SelectionReason: s.SelectionReason,
72+ TokenSource: string(s.TokenSource),
73+ Certificates: certs,
74+ Routes: routes,
75+ Errors: s.Errors,
76+ }
77+}
@@ -1,6 +1,10 @@
1 package dto1 package dto
2 2
3-import "time"3+import (
4+ "time"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+)
4 8
5 // TLSStatusResponse represents the public TLS/ACME status for the admin API.9 // TLSStatusResponse represents the public TLS/ACME status for the admin API.
6 type TLSStatusResponse struct {10 type TLSStatusResponse struct {
@@ -33,3 +37,41 @@ type TLSRouteCoverage struct {
33 RequiredACME bool `json:"required_acme"`37 RequiredACME bool `json:"required_acme"`
34 Error string `json:"error,omitempty"`38 Error string `json:"error,omitempty"`
35 }39 }
40+
41+// TLSStatusFromDomain converts a public TLS domain status to its transport DTO.
42+func TLSStatusFromDomain(s domain.PublicTLSStatus) TLSStatusResponse {
43+ certs := make([]TLSCertificateEntry, 0, len(s.Certificates))
44+ for _, c := range s.Certificates {
45+ certs = append(certs, TLSCertificateEntry{
46+ ID: c.ID,
47+ Names: c.Names,
48+ Challenge: string(c.Challenge),
49+ Status: string(c.Status),
50+ NotAfter: c.NotAfter,
51+ LastError: c.LastError,
52+ RenewalPending: c.RenewalPending,
53+ })
54+ }
55+
56+ routes := make([]TLSRouteCoverage, 0, len(s.Routes))
57+ for _, r := range s.Routes {
58+ routes = append(routes, TLSRouteCoverage{
59+ Domain: r.Domain,
60+ Covered: r.Covered,
61+ CoveredBy: r.CoveredBy,
62+ RequiredACME: r.RequiredACME,
63+ Error: r.Error,
64+ })
65+ }
66+
67+ return TLSStatusResponse{
68+ ACMEEnabled: s.ACMEEnabled,
69+ ConfiguredMode: string(s.ConfiguredMode),
70+ EffectiveMode: string(s.EffectiveMode),
71+ SelectionReason: s.SelectionReason,
72+ TokenSource: string(s.TokenSource),
73+ Certificates: certs,
74+ Routes: routes,
75+ Errors: s.Errors,
76+ }
77+}
M internal/adapters/in/cli/controlplane_local.go +1 -39
@@ -339,7 +339,7 @@ func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusRes
339339 }
340340
341341 status := l.publicTLSSvc.Status(ctx)
342- result := toLocalTLSStatusResponse(status)
342+ result := dto.TLSStatusFromDomain(status)
343343 return &result, nil
344344 }
345345
@@ -637,44 +637,6 @@ func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob {
637637 return out
638638 }
639639
640-// toLocalTLSStatusResponse converts a domain.PublicTLSStatus to a dto.TLSStatusResponse.
641-func toLocalTLSStatusResponse(s domain.PublicTLSStatus) dto.TLSStatusResponse {
642- certs := make([]dto.TLSCertificateEntry, 0, len(s.Certificates))
643- for _, c := range s.Certificates {
644- certs = append(certs, dto.TLSCertificateEntry{
645- ID: c.ID,
646- Names: c.Names,
647- Challenge: string(c.Challenge),
648- Status: string(c.Status),
649- NotAfter: c.NotAfter,
650- LastError: c.LastError,
651- RenewalPending: c.RenewalPending,
652- })
653- }
654-
655- routes := make([]dto.TLSRouteCoverage, 0, len(s.Routes))
656- for _, r := range s.Routes {
657- routes = append(routes, dto.TLSRouteCoverage{
658- Domain: r.Domain,
659- Covered: r.Covered,
660- CoveredBy: r.CoveredBy,
661- RequiredACME: r.RequiredACME,
662- Error: r.Error,
663- })
664- }
665-
666- return dto.TLSStatusResponse{
667- ACMEEnabled: s.ACMEEnabled,
668- ConfiguredMode: string(s.ConfiguredMode),
669- EffectiveMode: string(s.EffectiveMode),
670- SelectionReason: s.SelectionReason,
671- TokenSource: string(s.TokenSource),
672- Certificates: certs,
673- Routes: routes,
674- Errors: s.Errors,
675- }
676-}
677-
678640 func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {
679641 var startedAt *time.Time
680642 if !job.StartedAt.IsZero() {
@@ -339,7 +339,7 @@ func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusRes
339 }339 }
340 340
341 status := l.publicTLSSvc.Status(ctx)341 status := l.publicTLSSvc.Status(ctx)
342- result := toLocalTLSStatusResponse(status)342+ result := dto.TLSStatusFromDomain(status)
343 return &result, nil343 return &result, nil
344 }344 }
345 345
@@ -637,44 +637,6 @@ func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob {
637 return out637 return out
638 }638 }
639 639
640-// toLocalTLSStatusResponse converts a domain.PublicTLSStatus to a dto.TLSStatusResponse.
641-func toLocalTLSStatusResponse(s domain.PublicTLSStatus) dto.TLSStatusResponse {
642- certs := make([]dto.TLSCertificateEntry, 0, len(s.Certificates))
643- for _, c := range s.Certificates {
644- certs = append(certs, dto.TLSCertificateEntry{
645- ID: c.ID,
646- Names: c.Names,
647- Challenge: string(c.Challenge),
648- Status: string(c.Status),
649- NotAfter: c.NotAfter,
650- LastError: c.LastError,
651- RenewalPending: c.RenewalPending,
652- })
653- }
654-
655- routes := make([]dto.TLSRouteCoverage, 0, len(s.Routes))
656- for _, r := range s.Routes {
657- routes = append(routes, dto.TLSRouteCoverage{
658- Domain: r.Domain,
659- Covered: r.Covered,
660- CoveredBy: r.CoveredBy,
661- RequiredACME: r.RequiredACME,
662- Error: r.Error,
663- })
664- }
665-
666- return dto.TLSStatusResponse{
667- ACMEEnabled: s.ACMEEnabled,
668- ConfiguredMode: string(s.ConfiguredMode),
669- EffectiveMode: string(s.EffectiveMode),
670- SelectionReason: s.SelectionReason,
671- TokenSource: string(s.TokenSource),
672- Certificates: certs,
673- Routes: routes,
674- Errors: s.Errors,
675- }
676-}
677-
678 func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {640 func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {
679 var startedAt *time.Time641 var startedAt *time.Time
680 if !job.StartedAt.IsZero() {642 if !job.StartedAt.IsZero() {
M internal/adapters/in/cli/root.go +1 -1
@@ -387,7 +387,7 @@ func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) erro
387387 }
388388
389389 // showContainerLogsLocal shows container logs using local Docker access.
390-func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
390+func showContainerLogsLocal(_ string, logDomain string, follow bool, lines int) error {
391391 // For local container logs, we need Docker access which requires
392392 // the runtime to be initialized. For now, suggest using remote mode
393393 // or direct docker logs command.
@@ -387,7 +387,7 @@ func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) erro
387 }387 }
388 388
389 // showContainerLogsLocal shows container logs using local Docker access.389 // showContainerLogsLocal shows container logs using local Docker access.
390-func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {390+func showContainerLogsLocal(_ string, logDomain string, follow bool, lines int) error {
391 // For local container logs, we need Docker access which requires391 // For local container logs, we need Docker access which requires
392 // the runtime to be initialized. For now, suggest using remote mode392 // the runtime to be initialized. For now, suggest using remote mode
393 // or direct docker logs command.393 // or direct docker logs command.
M internal/adapters/in/http/acme/handler.go +3 -13
@@ -6,6 +6,8 @@ import (
66 "context"
77 "net/http"
88 "strings"
9+
10+ "github.com/bnema/gordon/internal/domain"
911 )
1012
1113 // Prefix is the path prefix for ACME HTTP-01 challenge requests.
@@ -66,17 +68,5 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6668 // safeToken validates that the token does not contain path traversal or special
6769 // characters that could be used to access files outside the challenge directory.
6870 func safeToken(token string) bool {
69- if token == "" {
70- return false
71- }
72- if strings.Contains(token, "/") {
73- return false
74- }
75- if strings.Contains(token, "\\") {
76- return false
77- }
78- if strings.Contains(token, "..") {
79- return false
80- }
81- return true
71+ return domain.SafePathComponent(token)
8272 }
@@ -6,6 +6,8 @@ import (
6 "context"6 "context"
7 "net/http"7 "net/http"
8 "strings"8 "strings"
9+
10+ "github.com/bnema/gordon/internal/domain"
9 )11 )
10 12
11 // Prefix is the path prefix for ACME HTTP-01 challenge requests.13 // Prefix is the path prefix for ACME HTTP-01 challenge requests.
@@ -66,17 +68,5 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
66 // safeToken validates that the token does not contain path traversal or special68 // safeToken validates that the token does not contain path traversal or special
67 // characters that could be used to access files outside the challenge directory.69 // characters that could be used to access files outside the challenge directory.
68 func safeToken(token string) bool {70 func safeToken(token string) bool {
69- if token == "" {71+ return domain.SafePathComponent(token)
70- return false
71- }
72- if strings.Contains(token, "/") {
73- return false
74- }
75- if strings.Contains(token, "\\") {
76- return false
77- }
78- if strings.Contains(token, "..") {
79- return false
80- }
81- return true
82 }72 }
M internal/adapters/in/http/admin/handler.go +3 -43
@@ -545,8 +545,7 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
545545 return
546546 }
547547
548- if strings.HasSuffix(routeDomain, "/attachments") {
549- parentDomain := strings.TrimSuffix(routeDomain, "/attachments")
548+ if parentDomain, ok := strings.CutSuffix(routeDomain, "/attachments"); ok {
550549 if parentDomain == "" {
551550 h.sendError(w, http.StatusBadRequest, "domain required in path")
552551 return
@@ -1325,8 +1324,7 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
13251324 container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
13261325 if err != nil {
13271326 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
1328- var deployErr *domain.DeployFailureError
1329- if errors.As(err, &deployErr) {
1327+ if deployErr, ok := errors.AsType[*domain.DeployFailureError](err); ok {
13301328 response := dto.DeployErrorResponse{
13311329 Error: deployErr.Error(),
13321330 Cause: deployErr.Cause,
@@ -1931,43 +1929,5 @@ func (h *Handler) handleTLSStatus(w http.ResponseWriter, r *http.Request) {
19311929 }
19321930
19331931 status := h.publicTLSSvc.Status(ctx)
1934- h.sendJSON(w, http.StatusOK, toTLSStatusResponse(status))
1935-}
1936-
1937-// toTLSStatusResponse converts a domain.PublicTLSStatus to a dto.TLSStatusResponse.
1938-func toTLSStatusResponse(s domain.PublicTLSStatus) dto.TLSStatusResponse {
1939- certs := make([]dto.TLSCertificateEntry, 0, len(s.Certificates))
1940- for _, c := range s.Certificates {
1941- certs = append(certs, dto.TLSCertificateEntry{
1942- ID: c.ID,
1943- Names: c.Names,
1944- Challenge: string(c.Challenge),
1945- Status: string(c.Status),
1946- NotAfter: c.NotAfter,
1947- LastError: c.LastError,
1948- RenewalPending: c.RenewalPending,
1949- })
1950- }
1951-
1952- routes := make([]dto.TLSRouteCoverage, 0, len(s.Routes))
1953- for _, r := range s.Routes {
1954- routes = append(routes, dto.TLSRouteCoverage{
1955- Domain: r.Domain,
1956- Covered: r.Covered,
1957- CoveredBy: r.CoveredBy,
1958- RequiredACME: r.RequiredACME,
1959- Error: r.Error,
1960- })
1961- }
1962-
1963- return dto.TLSStatusResponse{
1964- ACMEEnabled: s.ACMEEnabled,
1965- ConfiguredMode: string(s.ConfiguredMode),
1966- EffectiveMode: string(s.EffectiveMode),
1967- SelectionReason: s.SelectionReason,
1968- TokenSource: string(s.TokenSource),
1969- Certificates: certs,
1970- Routes: routes,
1971- Errors: s.Errors,
1972- }
1932+ h.sendJSON(w, http.StatusOK, dto.TLSStatusFromDomain(status))
19731933 }
@@ -545,8 +545,7 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
545 return545 return
546 }546 }
547 547
548- if strings.HasSuffix(routeDomain, "/attachments") {548+ if parentDomain, ok := strings.CutSuffix(routeDomain, "/attachments"); ok {
549- parentDomain := strings.TrimSuffix(routeDomain, "/attachments")
550 if parentDomain == "" {549 if parentDomain == "" {
551 h.sendError(w, http.StatusBadRequest, "domain required in path")550 h.sendError(w, http.StatusBadRequest, "domain required in path")
552 return551 return
@@ -1325,8 +1324,7 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
1325 container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)1324 container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
1326 if err != nil {1325 if err != nil {
1327 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")1326 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
1328- var deployErr *domain.DeployFailureError1327+ if deployErr, ok := errors.AsType[*domain.DeployFailureError](err); ok {
1329- if errors.As(err, &deployErr) {
1330 response := dto.DeployErrorResponse{1328 response := dto.DeployErrorResponse{
1331 Error: deployErr.Error(),1329 Error: deployErr.Error(),
1332 Cause: deployErr.Cause,1330 Cause: deployErr.Cause,
@@ -1931,43 +1929,5 @@ func (h *Handler) handleTLSStatus(w http.ResponseWriter, r *http.Request) {
1931 }1929 }
1932 1930
1933 status := h.publicTLSSvc.Status(ctx)1931 status := h.publicTLSSvc.Status(ctx)
1934- h.sendJSON(w, http.StatusOK, toTLSStatusResponse(status))1932+ h.sendJSON(w, http.StatusOK, dto.TLSStatusFromDomain(status))
1935-}
1936-
1937-// toTLSStatusResponse converts a domain.PublicTLSStatus to a dto.TLSStatusResponse.
1938-func toTLSStatusResponse(s domain.PublicTLSStatus) dto.TLSStatusResponse {
1939- certs := make([]dto.TLSCertificateEntry, 0, len(s.Certificates))
1940- for _, c := range s.Certificates {
1941- certs = append(certs, dto.TLSCertificateEntry{
1942- ID: c.ID,
1943- Names: c.Names,
1944- Challenge: string(c.Challenge),
1945- Status: string(c.Status),
1946- NotAfter: c.NotAfter,
1947- LastError: c.LastError,
1948- RenewalPending: c.RenewalPending,
1949- })
1950- }
1951-
1952- routes := make([]dto.TLSRouteCoverage, 0, len(s.Routes))
1953- for _, r := range s.Routes {
1954- routes = append(routes, dto.TLSRouteCoverage{
1955- Domain: r.Domain,
1956- Covered: r.Covered,
1957- CoveredBy: r.CoveredBy,
1958- RequiredACME: r.RequiredACME,
1959- Error: r.Error,
1960- })
1961- }
1962-
1963- return dto.TLSStatusResponse{
1964- ACMEEnabled: s.ACMEEnabled,
1965- ConfiguredMode: string(s.ConfiguredMode),
1966- EffectiveMode: string(s.EffectiveMode),
1967- SelectionReason: s.SelectionReason,
1968- TokenSource: string(s.TokenSource),
1969- Certificates: certs,
1970- Routes: routes,
1971- Errors: s.Errors,
1972- }
1973 }1933 }
M internal/adapters/out/acmelego/cloudflare.go +39 -1
@@ -8,6 +8,7 @@ import (
88 "net/http"
99 "net/url"
1010 "strings"
11+ "sync"
1112
1213 "github.com/bnema/gordon/internal/boundaries/out"
1314 )
@@ -38,6 +39,14 @@ type CloudflareZoneResolver struct {
3839 token string
3940 baseURL string
4041 client *http.Client
42+
43+ mu sync.RWMutex
44+ cache map[string]zoneCacheEntry
45+}
46+
47+type zoneCacheEntry struct {
48+ zone out.CloudflareZone
49+ err error
4150 }
4251
4352 // CloudflareZoneResolverOption configures a CloudflareZoneResolver.
@@ -63,6 +72,7 @@ func NewCloudflareZoneResolver(token string, opts ...CloudflareZoneResolverOptio
6372 token: token,
6473 baseURL: defaultCloudflareBaseURL,
6574 client: http.DefaultClient,
75+ cache: make(map[string]zoneCacheEntry),
6676 }
6777 for _, opt := range opts {
6878 opt(r)
@@ -83,6 +93,10 @@ func (r *CloudflareZoneResolver) FindZone(ctx context.Context, domainName string
8393 return out.CloudflareZone{}, fmt.Errorf("cloudflare zone resolver: empty domain")
8494 }
8595
96+ if cached, ok := r.cached(domainName); ok {
97+ return cached.zone, cached.err
98+ }
99+
86100 parts := strings.Split(domainName, ".")
87101
88102 // candidateCount is the total possible candidates (full domain down to TLD).
@@ -99,14 +113,38 @@ func (r *CloudflareZoneResolver) FindZone(ctx context.Context, domainName string
99113 }
100114
101115 for _, candidate := range candidates {
116+ if cached, ok := r.cached(candidate); ok {
117+ if cached.err == nil {
118+ r.storeCache(domainName, cached.zone, nil)
119+ return cached.zone, nil
120+ }
121+ continue
122+ }
102123 zone, err := r.findZoneByName(ctx, candidate)
124+ r.storeCache(candidate, zone, err)
103125 if err != nil {
104126 continue
105127 }
128+ r.storeCache(domainName, zone, nil)
106129 return zone, nil
107130 }
108131
109- return out.CloudflareZone{}, fmt.Errorf("cloudflare zone resolver: no active zone found for %q", domainName)
132+ err := fmt.Errorf("cloudflare zone resolver: no active zone found for %q", domainName)
133+ r.storeCache(domainName, out.CloudflareZone{}, err)
134+ return out.CloudflareZone{}, err
135+}
136+
137+func (r *CloudflareZoneResolver) cached(name string) (zoneCacheEntry, bool) {
138+ r.mu.RLock()
139+ defer r.mu.RUnlock()
140+ entry, ok := r.cache[name]
141+ return entry, ok
142+}
143+
144+func (r *CloudflareZoneResolver) storeCache(name string, zone out.CloudflareZone, err error) {
145+ r.mu.Lock()
146+ defer r.mu.Unlock()
147+ r.cache[name] = zoneCacheEntry{zone: zone, err: err}
110148 }
111149
112150 // findZoneByName queries the Cloudflare API for an active zone with the exact given name.
@@ -8,6 +8,7 @@ import (
8 "net/http"8 "net/http"
9 "net/url"9 "net/url"
10 "strings"10 "strings"
11+ "sync"
11 12
12 "github.com/bnema/gordon/internal/boundaries/out"13 "github.com/bnema/gordon/internal/boundaries/out"
13 )14 )
@@ -38,6 +39,14 @@ type CloudflareZoneResolver struct {
38 token string39 token string
39 baseURL string40 baseURL string
40 client *http.Client41 client *http.Client
42+
43+ mu sync.RWMutex
44+ cache map[string]zoneCacheEntry
45+}
46+
47+type zoneCacheEntry struct {
48+ zone out.CloudflareZone
49+ err error
41 }50 }
42 51
43 // CloudflareZoneResolverOption configures a CloudflareZoneResolver.52 // CloudflareZoneResolverOption configures a CloudflareZoneResolver.
@@ -63,6 +72,7 @@ func NewCloudflareZoneResolver(token string, opts ...CloudflareZoneResolverOptio
63 token: token,72 token: token,
64 baseURL: defaultCloudflareBaseURL,73 baseURL: defaultCloudflareBaseURL,
65 client: http.DefaultClient,74 client: http.DefaultClient,
75+ cache: make(map[string]zoneCacheEntry),
66 }76 }
67 for _, opt := range opts {77 for _, opt := range opts {
68 opt(r)78 opt(r)
@@ -83,6 +93,10 @@ func (r *CloudflareZoneResolver) FindZone(ctx context.Context, domainName string
83 return out.CloudflareZone{}, fmt.Errorf("cloudflare zone resolver: empty domain")93 return out.CloudflareZone{}, fmt.Errorf("cloudflare zone resolver: empty domain")
84 }94 }
85 95
96+ if cached, ok := r.cached(domainName); ok {
97+ return cached.zone, cached.err
98+ }
99+
86 parts := strings.Split(domainName, ".")100 parts := strings.Split(domainName, ".")
87 101
88 // candidateCount is the total possible candidates (full domain down to TLD).102 // candidateCount is the total possible candidates (full domain down to TLD).
@@ -99,14 +113,38 @@ func (r *CloudflareZoneResolver) FindZone(ctx context.Context, domainName string
99 }113 }
100 114
101 for _, candidate := range candidates {115 for _, candidate := range candidates {
116+ if cached, ok := r.cached(candidate); ok {
117+ if cached.err == nil {
118+ r.storeCache(domainName, cached.zone, nil)
119+ return cached.zone, nil
120+ }
121+ continue
122+ }
102 zone, err := r.findZoneByName(ctx, candidate)123 zone, err := r.findZoneByName(ctx, candidate)
124+ r.storeCache(candidate, zone, err)
103 if err != nil {125 if err != nil {
104 continue126 continue
105 }127 }
128+ r.storeCache(domainName, zone, nil)
106 return zone, nil129 return zone, nil
107 }130 }
108 131
109- return out.CloudflareZone{}, fmt.Errorf("cloudflare zone resolver: no active zone found for %q", domainName)132+ err := fmt.Errorf("cloudflare zone resolver: no active zone found for %q", domainName)
133+ r.storeCache(domainName, out.CloudflareZone{}, err)
134+ return out.CloudflareZone{}, err
135+}
136+
137+func (r *CloudflareZoneResolver) cached(name string) (zoneCacheEntry, bool) {
138+ r.mu.RLock()
139+ defer r.mu.RUnlock()
140+ entry, ok := r.cache[name]
141+ return entry, ok
142+}
143+
144+func (r *CloudflareZoneResolver) storeCache(name string, zone out.CloudflareZone, err error) {
145+ r.mu.Lock()
146+ defer r.mu.Unlock()
147+ r.cache[name] = zoneCacheEntry{zone: zone, err: err}
110 }148 }
111 149
112 // findZoneByName queries the Cloudflare API for an active zone with the exact given name.150 // findZoneByName queries the Cloudflare API for an active zone with the exact given name.
M internal/adapters/out/acmelego/issuer.go +0 -9
@@ -278,15 +278,6 @@ func restoreAccount(stored *out.ACMEAccount) (*AccountUser, error) {
278278 return NewAccountUser(stored.Email, privateKey, reg), nil
279279 }
280280
281-// acmeAccount mirrors acme.Account for JSON round-trip without importing acme
282-// in the test file.
283-type acmeAccount struct {
284- Status string `json:"status,omitempty"`
285- Contact []string `json:"contact,omitempty"`
286- TermsOfServiceAgreed bool `json:"termsOfServiceAgreed,omitempty"`
287- Orders string `json:"orders,omitempty"`
288-}
289-
290281 // resourceToStored converts a lego certificate.Resource to out.StoredCertificate.
291282 func resourceToStored(order out.CertificateOrder, res *certificate.Resource) *out.StoredCertificate {
292283 var fullchainPEM []byte
@@ -278,15 +278,6 @@ func restoreAccount(stored *out.ACMEAccount) (*AccountUser, error) {
278 return NewAccountUser(stored.Email, privateKey, reg), nil278 return NewAccountUser(stored.Email, privateKey, reg), nil
279 }279 }
280 280
281-// acmeAccount mirrors acme.Account for JSON round-trip without importing acme
282-// in the test file.
283-type acmeAccount struct {
284- Status string `json:"status,omitempty"`
285- Contact []string `json:"contact,omitempty"`
286- TermsOfServiceAgreed bool `json:"termsOfServiceAgreed,omitempty"`
287- Orders string `json:"orders,omitempty"`
288-}
289-
290 // resourceToStored converts a lego certificate.Resource to out.StoredCertificate.281 // resourceToStored converts a lego certificate.Resource to out.StoredCertificate.
291 func resourceToStored(order out.CertificateOrder, res *certificate.Resource) *out.StoredCertificate {282 func resourceToStored(order out.CertificateOrder, res *certificate.Resource) *out.StoredCertificate {
292 var fullchainPEM []byte283 var fullchainPEM []byte
M internal/adapters/out/acmelego/issuer_test.go +3 -2
@@ -15,6 +15,7 @@ import (
1515 "testing"
1616 "time"
1717
18+ "github.com/go-acme/lego/v4/acme"
1819 "github.com/go-acme/lego/v4/certcrypto"
1920 "github.com/go-acme/lego/v4/certificate"
2021 "github.com/stretchr/testify/assert"
@@ -194,7 +195,7 @@ func TestResourceConversionNoIssuer(t *testing.T) {
194195
195196 func TestAccountJSONRoundTrip(t *testing.T) {
196197 // Simulate an ACME account with BodyJSON containing the serialized acme.Account
197- body := acmeAccount{
198+ body := acme.Account{
198199 Status: "valid",
199200 Contact: []string{"mailto:test@example.com"},
200201 TermsOfServiceAgreed: true,
@@ -204,7 +205,7 @@ func TestAccountJSONRoundTrip(t *testing.T) {
204205 bodyJSON, err := json.Marshal(body)
205206 require.NoError(t, err)
206207
207- var restored acmeAccount
208+ var restored acme.Account
208209 err = json.Unmarshal(bodyJSON, &restored)
209210 require.NoError(t, err)
210211 assert.Equal(t, body.Status, restored.Status)
@@ -15,6 +15,7 @@ import (
15 "testing"15 "testing"
16 "time"16 "time"
17 17
18+ "github.com/go-acme/lego/v4/acme"
18 "github.com/go-acme/lego/v4/certcrypto"19 "github.com/go-acme/lego/v4/certcrypto"
19 "github.com/go-acme/lego/v4/certificate"20 "github.com/go-acme/lego/v4/certificate"
20 "github.com/stretchr/testify/assert"21 "github.com/stretchr/testify/assert"
@@ -194,7 +195,7 @@ func TestResourceConversionNoIssuer(t *testing.T) {
194 195
195 func TestAccountJSONRoundTrip(t *testing.T) {196 func TestAccountJSONRoundTrip(t *testing.T) {
196 // Simulate an ACME account with BodyJSON containing the serialized acme.Account197 // Simulate an ACME account with BodyJSON containing the serialized acme.Account
197- body := acmeAccount{198+ body := acme.Account{
198 Status: "valid",199 Status: "valid",
199 Contact: []string{"mailto:test@example.com"},200 Contact: []string{"mailto:test@example.com"},
200 TermsOfServiceAgreed: true,201 TermsOfServiceAgreed: true,
@@ -204,7 +205,7 @@ func TestAccountJSONRoundTrip(t *testing.T) {
204 bodyJSON, err := json.Marshal(body)205 bodyJSON, err := json.Marshal(body)
205 require.NoError(t, err)206 require.NoError(t, err)
206 207
207- var restored acmeAccount208+ var restored acme.Account
208 err = json.Unmarshal(bodyJSON, &restored)209 err = json.Unmarshal(bodyJSON, &restored)
209 require.NoError(t, err)210 require.NoError(t, err)
210 assert.Equal(t, body.Status, restored.Status)211 assert.Equal(t, body.Status, restored.Status)
M internal/adapters/out/acmestore/store.go +2 -9
@@ -8,7 +8,6 @@ import (
88 "fmt"
99 "os"
1010 "path/filepath"
11- "strings"
1211 "time"
1312
1413 "github.com/bnema/gordon/internal/boundaries/out"
@@ -20,7 +19,7 @@ type certMetadata struct {
2019 ID string `json:"id"`
2120 Names []string `json:"names"`
2221 Challenge string `json:"challenge"`
23- NotAfter time.Time `json:"not_after,omitempty"`
22+ NotAfter time.Time `json:"not_after"`
2423 LastError string `json:"last_error,omitempty"`
2524 }
2625
@@ -256,13 +255,7 @@ func (s *Store) Lock(ctx context.Context) (func() error, error) {
256255
257256 // safeID rejects IDs that could cause path traversal.
258257 func safeID(id string) bool {
259- if id == "" {
260- return false
261- }
262- if strings.Contains(id, "/") || strings.Contains(id, "\\") || strings.Contains(id, "..") {
263- return false
264- }
265- return true
258+ return domain.SafePathComponent(id)
266259 }
267260
268261 // writeAtomic writes data to path atomically by writing to a temporary file
@@ -8,7 +8,6 @@ import (
8 "fmt"8 "fmt"
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11- "strings"
12 "time"11 "time"
13 12
14 "github.com/bnema/gordon/internal/boundaries/out"13 "github.com/bnema/gordon/internal/boundaries/out"
@@ -20,7 +19,7 @@ type certMetadata struct {
20 ID string `json:"id"`19 ID string `json:"id"`
21 Names []string `json:"names"`20 Names []string `json:"names"`
22 Challenge string `json:"challenge"`21 Challenge string `json:"challenge"`
23- NotAfter time.Time `json:"not_after,omitempty"`22+ NotAfter time.Time `json:"not_after"`
24 LastError string `json:"last_error,omitempty"`23 LastError string `json:"last_error,omitempty"`
25 }24 }
26 25
@@ -256,13 +255,7 @@ func (s *Store) Lock(ctx context.Context) (func() error, error) {
256 255
257 // safeID rejects IDs that could cause path traversal.256 // safeID rejects IDs that could cause path traversal.
258 func safeID(id string) bool {257 func safeID(id string) bool {
259- if id == "" {258+ return domain.SafePathComponent(id)
260- return false
261- }
262- if strings.Contains(id, "/") || strings.Contains(id, "\\") || strings.Contains(id, "..") {
263- return false
264- }
265- return true
266 }259 }
267 260
268 // writeAtomic writes data to path atomically by writing to a temporary file261 // writeAtomic writes data to path atomically by writing to a temporary file
M internal/adapters/out/secrets/public_tls_resolver.go +3 -16
@@ -5,9 +5,9 @@ import (
55 "errors"
66 "fmt"
77 "os"
8- "os/exec"
98 "strings"
10- "time"
9+
10+ "github.com/bnema/zerowrap"
1111
1212 "github.com/bnema/gordon/internal/boundaries/out"
1313 "github.com/bnema/gordon/internal/domain"
@@ -96,18 +96,5 @@ func defaultPassLookup(ctx context.Context, name string) (string, error) {
9696 return "", fmt.Errorf("pass lookup: disallowed name %q", name)
9797 }
9898
99- passCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
100- defer cancel()
101-
102- // #nosec G204 — name is validated above as the allowlisted constant
103- // defaultCloudflareTokenPassName; no shell is used.
104- cmd := exec.CommandContext(passCtx, "pass", "show", name)
105- output, err := cmd.Output()
106- if err != nil {
107- if passCtx.Err() == context.DeadlineExceeded {
108- return "", context.DeadlineExceeded
109- }
110- return "", err
111- }
112- return string(output), nil
99+ return NewPassProvider(zerowrap.New(zerowrap.Config{Level: "disabled"})).GetSecret(ctx, name)
113100 }
@@ -5,9 +5,9 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "os"7 "os"
8- "os/exec"
9 "strings"8 "strings"
10- "time"9+
10+ "github.com/bnema/zerowrap"
11 11
12 "github.com/bnema/gordon/internal/boundaries/out"12 "github.com/bnema/gordon/internal/boundaries/out"
13 "github.com/bnema/gordon/internal/domain"13 "github.com/bnema/gordon/internal/domain"
@@ -96,18 +96,5 @@ func defaultPassLookup(ctx context.Context, name string) (string, error) {
96 return "", fmt.Errorf("pass lookup: disallowed name %q", name)96 return "", fmt.Errorf("pass lookup: disallowed name %q", name)
97 }97 }
98 98
99- passCtx, cancel := context.WithTimeout(ctx, 2*time.Second)99+ return NewPassProvider(zerowrap.New(zerowrap.Config{Level: "disabled"})).GetSecret(ctx, name)
100- defer cancel()
101-
102- // #nosec G204 — name is validated above as the allowlisted constant
103- // defaultCloudflareTokenPassName; no shell is used.
104- cmd := exec.CommandContext(passCtx, "pass", "show", name)
105- output, err := cmd.Output()
106- if err != nil {
107- if passCtx.Err() == context.DeadlineExceeded {
108- return "", context.DeadlineExceeded
109- }
110- return "", err
111- }
112- return string(output), nil
113 }100 }
M internal/app/public_tls_wiring_test.go +1 -1
@@ -82,7 +82,7 @@ func TestCertificateSelector_StaticCertWins(t *testing.T) {
8282 publicCert := generateTestCert(t, "static.example.com")
8383
8484 selector := &certificateSelector{
85- staticCerts: []tls.Certificate{staticCert},
85+ staticCerts: prepareStaticTLSCertificates([]tls.Certificate{staticCert}),
8686 publicTLS: &stubPublicTLS{
8787 getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
8888 return &publicCert, nil
@@ -82,7 +82,7 @@ func TestCertificateSelector_StaticCertWins(t *testing.T) {
82 publicCert := generateTestCert(t, "static.example.com")82 publicCert := generateTestCert(t, "static.example.com")
83 83
84 selector := &certificateSelector{84 selector := &certificateSelector{
85- staticCerts: []tls.Certificate{staticCert},85+ staticCerts: prepareStaticTLSCertificates([]tls.Certificate{staticCert}),
86 publicTLS: &stubPublicTLS{86 publicTLS: &stubPublicTLS{
87 getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {87 getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
88 return &publicCert, nil88 return &publicCert, nil
M internal/app/run.go +32 -14
@@ -2754,11 +2754,16 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
27542754 // certificateSelector implements a multi-source TLS certificate lookup.
27552755 // Priority: static certs → public ACME TLS → local PKI (internal CA).
27562756 type certificateSelector struct {
2757- staticCerts []tls.Certificate
2757+ staticCerts []staticTLSCertificate
27582758 publicTLS in.PublicTLSService
27592759 localPKI *pkiusecase.Service
27602760 }
27612761
2762+type staticTLSCertificate struct {
2763+ cert tls.Certificate
2764+ leaf *x509.Certificate
2765+}
2766+
27622767 // GetCertificate selects a TLS certificate based on the ClientHello SNI.
27632768 //
27642769 // Priority:
@@ -2768,7 +2773,7 @@ type certificateSelector struct {
27682773 // 4. nil, nil — if no source can serve the host
27692774 func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
27702775 // 1. Static certs — exact match via leaf VerifyHostname.
2771- if cert := matchingStaticCert(s.staticCerts, hello.ServerName); cert != nil {
2776+ if cert := matchingPreparedStaticCert(s.staticCerts, hello.ServerName); cert != nil {
27722777 return cert, nil
27732778 }
27742779
@@ -2793,28 +2798,41 @@ func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.C
27932798 return nil, nil
27942799 }
27952800
2796-// matchingStaticCert returns a pointer to the first static certificate whose
2797-// leaf verifies the given serverName. Returns nil if no match is found.
2798-func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certificate {
2801+func prepareStaticTLSCertificates(certs []tls.Certificate) []staticTLSCertificate {
2802+ prepared := make([]staticTLSCertificate, 0, len(certs))
2803+ for _, cert := range certs {
2804+ if cert.Leaf == nil && len(cert.Certificate) > 0 {
2805+ leaf, err := x509.ParseCertificate(cert.Certificate[0])
2806+ if err == nil {
2807+ cert.Leaf = leaf
2808+ }
2809+ }
2810+ prepared = append(prepared, staticTLSCertificate{cert: cert, leaf: cert.Leaf})
2811+ }
2812+ return prepared
2813+}
2814+
2815+func matchingPreparedStaticCert(certs []staticTLSCertificate, serverName string) *tls.Certificate {
27992816 if serverName == "" {
28002817 return nil
28012818 }
28022819 for i := range certs {
2803- cert := &certs[i]
2804- if len(cert.Certificate) == 0 {
2820+ if certs[i].leaf == nil {
28052821 continue
28062822 }
2807- leaf, err := x509.ParseCertificate(cert.Certificate[0])
2808- if err != nil {
2809- continue
2810- }
2811- if err := leaf.VerifyHostname(serverName); err == nil {
2812- return cert
2823+ if err := certs[i].leaf.VerifyHostname(serverName); err == nil {
2824+ return &certs[i].cert
28132825 }
28142826 }
28152827 return nil
28162828 }
28172829
2830+// matchingStaticCert returns a pointer to the first static certificate whose
2831+// leaf verifies the given serverName. Returns nil if no match is found.
2832+func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certificate {
2833+ return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName)
2834+}
2835+
28182836 func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
28192837 // HTTP listener (Cloudflare proxy + onboarding for direct clients)
28202838 var httpProtos http.Protocols
@@ -2849,7 +2867,7 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv
28492867 }
28502868
28512869 selector := &certificateSelector{
2852- staticCerts: staticCerts,
2870+ staticCerts: prepareStaticTLSCertificates(staticCerts),
28532871 publicTLS: publicTLS,
28542872 localPKI: pkiSvc,
28552873 }
@@ -2754,11 +2754,16 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
2754 // certificateSelector implements a multi-source TLS certificate lookup.2754 // certificateSelector implements a multi-source TLS certificate lookup.
2755 // Priority: static certs → public ACME TLS → local PKI (internal CA).2755 // Priority: static certs → public ACME TLS → local PKI (internal CA).
2756 type certificateSelector struct {2756 type certificateSelector struct {
2757- staticCerts []tls.Certificate2757+ staticCerts []staticTLSCertificate
2758 publicTLS in.PublicTLSService2758 publicTLS in.PublicTLSService
2759 localPKI *pkiusecase.Service2759 localPKI *pkiusecase.Service
2760 }2760 }
2761 2761
2762+type staticTLSCertificate struct {
2763+ cert tls.Certificate
2764+ leaf *x509.Certificate
2765+}
2766+
2762 // GetCertificate selects a TLS certificate based on the ClientHello SNI.2767 // GetCertificate selects a TLS certificate based on the ClientHello SNI.
2763 //2768 //
2764 // Priority:2769 // Priority:
@@ -2768,7 +2773,7 @@ type certificateSelector struct {
2768 // 4. nil, nil — if no source can serve the host2773 // 4. nil, nil — if no source can serve the host
2769 func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {2774 func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
2770 // 1. Static certs — exact match via leaf VerifyHostname.2775 // 1. Static certs — exact match via leaf VerifyHostname.
2771- if cert := matchingStaticCert(s.staticCerts, hello.ServerName); cert != nil {2776+ if cert := matchingPreparedStaticCert(s.staticCerts, hello.ServerName); cert != nil {
2772 return cert, nil2777 return cert, nil
2773 }2778 }
2774 2779
@@ -2793,28 +2798,41 @@ func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.C
2793 return nil, nil2798 return nil, nil
2794 }2799 }
2795 2800
2796-// matchingStaticCert returns a pointer to the first static certificate whose2801+func prepareStaticTLSCertificates(certs []tls.Certificate) []staticTLSCertificate {
2797-// leaf verifies the given serverName. Returns nil if no match is found.2802+ prepared := make([]staticTLSCertificate, 0, len(certs))
2798-func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certificate {2803+ for _, cert := range certs {
2804+ if cert.Leaf == nil && len(cert.Certificate) > 0 {
2805+ leaf, err := x509.ParseCertificate(cert.Certificate[0])
2806+ if err == nil {
2807+ cert.Leaf = leaf
2808+ }
2809+ }
2810+ prepared = append(prepared, staticTLSCertificate{cert: cert, leaf: cert.Leaf})
2811+ }
2812+ return prepared
2813+}
2814+
2815+func matchingPreparedStaticCert(certs []staticTLSCertificate, serverName string) *tls.Certificate {
2799 if serverName == "" {2816 if serverName == "" {
2800 return nil2817 return nil
2801 }2818 }
2802 for i := range certs {2819 for i := range certs {
2803- cert := &certs[i]2820+ if certs[i].leaf == nil {
2804- if len(cert.Certificate) == 0 {
2805 continue2821 continue
2806 }2822 }
2807- leaf, err := x509.ParseCertificate(cert.Certificate[0])2823+ if err := certs[i].leaf.VerifyHostname(serverName); err == nil {
2808- if err != nil {2824+ return &certs[i].cert
2809- continue
2810- }
2811- if err := leaf.VerifyHostname(serverName); err == nil {
2812- return cert
2813 }2825 }
2814 }2826 }
2815 return nil2827 return nil
2816 }2828 }
2817 2829
2830+// matchingStaticCert returns a pointer to the first static certificate whose
2831+// leaf verifies the given serverName. Returns nil if no match is found.
2832+func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certificate {
2833+ return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName)
2834+}
2835+
2818 func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {2836 func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
2819 // HTTP listener (Cloudflare proxy + onboarding for direct clients)2837 // HTTP listener (Cloudflare proxy + onboarding for direct clients)
2820 var httpProtos http.Protocols2838 var httpProtos http.Protocols
@@ -2849,7 +2867,7 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv
2849 }2867 }
2850 2868
2851 selector := &certificateSelector{2869 selector := &certificateSelector{
2852- staticCerts: staticCerts,2870+ staticCerts: prepareStaticTLSCertificates(staticCerts),
2853 publicTLS: publicTLS,2871 publicTLS: publicTLS,
2854 localPKI: pkiSvc,2872 localPKI: pkiSvc,
2855 }2873 }
M internal/domain/public_tls.go +15 -8
@@ -66,22 +66,29 @@ type ManagedCertificate struct {
6666 }
6767
6868 func (c ManagedCertificate) Covers(host string) bool {
69+ return CertificateNamesCoverHost(c.Names, host)
70+}
71+
72+// SafePathComponent reports whether value is a single safe path component.
73+func SafePathComponent(value string) bool {
74+ return value != "" && !strings.Contains(value, "/") && !strings.Contains(value, "\\") && !strings.Contains(value, "..")
75+}
76+
77+// CertificateNamesCoverHost reports whether any certificate name covers host.
78+func CertificateNamesCoverHost(names []string, host string) bool {
6979 host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
7080 if host == "" {
7181 return false
7282 }
73- for _, name := range c.Names {
83+ for _, name := range names {
7484 name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
7585 if name == host {
7686 return true
7787 }
78- if strings.HasPrefix(name, "*.") {
79- suffix := strings.TrimPrefix(name, "*.")
80- if strings.HasSuffix(host, "."+suffix) {
81- left := strings.TrimSuffix(host, "."+suffix)
82- if left != "" && !strings.Contains(left, ".") {
83- return true
84- }
88+ if suffix, ok := strings.CutPrefix(name, "*."); ok && strings.HasSuffix(host, "."+suffix) {
89+ left := strings.TrimSuffix(host, "."+suffix)
90+ if left != "" && !strings.Contains(left, ".") {
91+ return true
8592 }
8693 }
8794 }
@@ -66,22 +66,29 @@ type ManagedCertificate struct {
66 }66 }
67 67
68 func (c ManagedCertificate) Covers(host string) bool {68 func (c ManagedCertificate) Covers(host string) bool {
69+ return CertificateNamesCoverHost(c.Names, host)
70+}
71+
72+// SafePathComponent reports whether value is a single safe path component.
73+func SafePathComponent(value string) bool {
74+ return value != "" && !strings.Contains(value, "/") && !strings.Contains(value, "\\") && !strings.Contains(value, "..")
75+}
76+
77+// CertificateNamesCoverHost reports whether any certificate name covers host.
78+func CertificateNamesCoverHost(names []string, host string) bool {
69 host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")79 host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
70 if host == "" {80 if host == "" {
71 return false81 return false
72 }82 }
73- for _, name := range c.Names {83+ for _, name := range names {
74 name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")84 name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
75 if name == host {85 if name == host {
76 return true86 return true
77 }87 }
78- if strings.HasPrefix(name, "*.") {88+ if suffix, ok := strings.CutPrefix(name, "*."); ok && strings.HasSuffix(host, "."+suffix) {
79- suffix := strings.TrimPrefix(name, "*.")89+ left := strings.TrimSuffix(host, "."+suffix)
80- if strings.HasSuffix(host, "."+suffix) {90+ if left != "" && !strings.Contains(left, ".") {
81- left := strings.TrimSuffix(host, "."+suffix)91+ return true
82- if left != "" && !strings.Contains(left, ".") {
83- return true
84- }
85 }92 }
86 }93 }
87 }94 }
M internal/usecase/publictls/http01.go +3 -14
@@ -2,8 +2,9 @@ package publictls
22
33 import (
44 "context"
5- "strings"
65 "sync"
6+
7+ "github.com/bnema/gordon/internal/domain"
78 )
89
910 // HTTP01Challenges stores ACME HTTP-01 challenge tokens and their key
@@ -55,17 +56,5 @@ func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) {
5556 // It rejects empty tokens, tokens containing "/" or "\", and tokens
5657 // equal to "..", which could allow path traversal.
5758 func safeHTTP01Token(token string) bool {
58- if token == "" {
59- return false
60- }
61- if strings.Contains(token, "/") {
62- return false
63- }
64- if strings.Contains(token, "\\") {
65- return false
66- }
67- if token == ".." {
68- return false
69- }
70- return true
59+ return domain.SafePathComponent(token)
7160 }
@@ -2,8 +2,9 @@ package publictls
2 2
3 import (3 import (
4 "context"4 "context"
5- "strings"
6 "sync"5 "sync"
6+
7+ "github.com/bnema/gordon/internal/domain"
7 )8 )
8 9
9 // HTTP01Challenges stores ACME HTTP-01 challenge tokens and their key10 // HTTP01Challenges stores ACME HTTP-01 challenge tokens and their key
@@ -55,17 +56,5 @@ func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) {
55 // It rejects empty tokens, tokens containing "/" or "\", and tokens56 // It rejects empty tokens, tokens containing "/" or "\", and tokens
56 // equal to "..", which could allow path traversal.57 // equal to "..", which could allow path traversal.
57 func safeHTTP01Token(token string) bool {58 func safeHTTP01Token(token string) bool {
58- if token == "" {59+ return domain.SafePathComponent(token)
59- return false
60- }
61- if strings.Contains(token, "/") {
62- return false
63- }
64- if strings.Contains(token, "\\") {
65- return false
66- }
67- if token == ".." {
68- return false
69- }
70- return true
71 }60 }
M internal/usecase/publictls/renewal.go +2 -3
@@ -61,8 +61,8 @@ func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration)
6161 ticker := time.NewTicker(interval)
6262 defer ticker.Stop()
6363
64- // Run once immediately on start.
65- _ = s.Reconcile(loopCtx)
64+ // Startup and reload paths reconcile routes; the periodic loop only renews
65+ // due certificates to avoid repeated zone lookups and ACME ordering work.
6666 _ = s.renewDueCertificates(loopCtx, time.Now())
6767
6868 for {
@@ -70,7 +70,6 @@ func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration)
7070 case <-loopCtx.Done():
7171 return
7272 case <-ticker.C:
73- _ = s.Reconcile(loopCtx)
7473 _ = s.renewDueCertificates(loopCtx, time.Now())
7574 }
7675 }
@@ -61,8 +61,8 @@ func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration)
61 ticker := time.NewTicker(interval)61 ticker := time.NewTicker(interval)
62 defer ticker.Stop()62 defer ticker.Stop()
63 63
64- // Run once immediately on start.64+ // Startup and reload paths reconcile routes; the periodic loop only renews
65- _ = s.Reconcile(loopCtx)65+ // due certificates to avoid repeated zone lookups and ACME ordering work.
66 _ = s.renewDueCertificates(loopCtx, time.Now())66 _ = s.renewDueCertificates(loopCtx, time.Now())
67 67
68 for {68 for {
@@ -70,7 +70,6 @@ func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration)
70 case <-loopCtx.Done():70 case <-loopCtx.Done():
71 return71 return
72 case <-ticker.C:72 case <-ticker.C:
73- _ = s.Reconcile(loopCtx)
74 _ = s.renewDueCertificates(loopCtx, time.Now())73 _ = s.renewDueCertificates(loopCtx, time.Now())
75 }74 }
76 }75 }
M internal/usecase/publictls/service.go +1 -20
@@ -255,26 +255,7 @@ func namesCover(names, required []string) bool {
255255 // hostMatchesCert checks if any name in the given list matches host,
256256 // including wildcard matching.
257257 func hostMatchesCert(names []string, host string) bool {
258- host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
259- if host == "" {
260- return false
261- }
262- for _, name := range names {
263- name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
264- if name == host {
265- return true
266- }
267- if strings.HasPrefix(name, "*.") {
268- suffix := strings.TrimPrefix(name, "*.")
269- if strings.HasSuffix(host, "."+suffix) {
270- left := strings.TrimSuffix(host, "."+suffix)
271- if left != "" && !strings.Contains(left, ".") {
272- return true
273- }
274- }
275- }
276- }
277- return false
258+ return domain.CertificateNamesCoverHost(names, host)
278259 }
279260
280261 // GetCertificate returns a TLS certificate for the given ClientHello.
@@ -255,26 +255,7 @@ func namesCover(names, required []string) bool {
255 // hostMatchesCert checks if any name in the given list matches host,255 // hostMatchesCert checks if any name in the given list matches host,
256 // including wildcard matching.256 // including wildcard matching.
257 func hostMatchesCert(names []string, host string) bool {257 func hostMatchesCert(names []string, host string) bool {
258- host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")258+ return domain.CertificateNamesCoverHost(names, host)
259- if host == "" {
260- return false
261- }
262- for _, name := range names {
263- name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
264- if name == host {
265- return true
266- }
267- if strings.HasPrefix(name, "*.") {
268- suffix := strings.TrimPrefix(name, "*.")
269- if strings.HasSuffix(host, "."+suffix) {
270- left := strings.TrimSuffix(host, "."+suffix)
271- if left != "" && !strings.Contains(left, ".") {
272- return true
273- }
274- }
275- }
276- }
277- return false
278 }259 }
279 260
280 // GetCertificate returns a TLS certificate for the given ClientHello.261 // GetCertificate returns a TLS certificate for the given ClientHello.

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 8

Tags 106

↑↓ moveenter openesc close