simplify public ACME TLS implementation
- commit
- b85bb4ef045181424f7a92831448cbeeb7c74f89
- author
- brice · · 2026-04-29 07:35 UTC
- parent
- ed49430
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +152 -194
M internal/adapters/dto/tls.go +43 -1
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package dto | |
| 2 | 2 | ||
| 3 | - | import "time" | |
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | // TLSStatusResponse represents the public TLS/ACME status for the admin API. | |
| 6 | 10 | type TLSStatusResponse struct { | |
| @@ -33,3 +37,41 @@ type TLSRouteCoverage struct { | |||
| 33 | 37 | RequiredACME bool `json:"required_acme"` | |
| 34 | 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 | + | } | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package dto | 1 | 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 | |||
| 339 | 339 | } | |
| 340 | 340 | ||
| 341 | 341 | status := l.publicTLSSvc.Status(ctx) | |
| 342 | - | result := toLocalTLSStatusResponse(status) | |
| 342 | + | result := dto.TLSStatusFromDomain(status) | |
| 343 | 343 | return &result, nil | |
| 344 | 344 | } | |
| 345 | 345 | ||
| @@ -637,44 +637,6 @@ func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob { | |||
| 637 | 637 | 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 | 640 | func toDTOBackupJob(job domain.BackupJob) dto.BackupJob { | |
| 679 | 641 | var startedAt *time.Time | |
| 680 | 642 | 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, nil | 343 | return &result, nil | ||
| 344 | } | 344 | } | ||
| 345 | 345 | ||||
| @@ -637,44 +637,6 @@ func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob { | |||||
| 637 | return out | 637 | 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.Time | 641 | 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 | |||
| 387 | 387 | } | |
| 388 | 388 | ||
| 389 | 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 | 391 | // For local container logs, we need Docker access which requires | |
| 392 | 392 | // the runtime to be initialized. For now, suggest using remote mode | |
| 393 | 393 | // 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 requires | 391 | // For local container logs, we need Docker access which requires | ||
| 392 | // the runtime to be initialized. For now, suggest using remote mode | 392 | // 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 ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "net/http" | |
| 8 | 8 | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 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 | 68 | // safeToken validates that the token does not contain path traversal or special | |
| 67 | 69 | // characters that could be used to access files outside the challenge directory. | |
| 68 | 70 | 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) | |
| 82 | 72 | } | |
| @@ -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 special | 68 | // 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 | |||
| 545 | 545 | return | |
| 546 | 546 | } | |
| 547 | 547 | ||
| 548 | - | if strings.HasSuffix(routeDomain, "/attachments") { | |
| 549 | - | parentDomain := strings.TrimSuffix(routeDomain, "/attachments") | |
| 548 | + | if parentDomain, ok := strings.CutSuffix(routeDomain, "/attachments"); ok { | |
| 550 | 549 | if parentDomain == "" { | |
| 551 | 550 | h.sendError(w, http.StatusBadRequest, "domain required in path") | |
| 552 | 551 | return | |
| @@ -1325,8 +1324,7 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||
| 1325 | 1324 | container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route) | |
| 1326 | 1325 | if err != nil { | |
| 1327 | 1326 | 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 { | |
| 1330 | 1328 | response := dto.DeployErrorResponse{ | |
| 1331 | 1329 | Error: deployErr.Error(), | |
| 1332 | 1330 | Cause: deployErr.Cause, | |
| @@ -1931,43 +1929,5 @@ func (h *Handler) handleTLSStatus(w http.ResponseWriter, r *http.Request) { | |||
| 1931 | 1929 | } | |
| 1932 | 1930 | ||
| 1933 | 1931 | 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)) | |
| 1973 | 1933 | } | |
| @@ -545,8 +545,7 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD | |||||
| 545 | return | 545 | 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 | return | 551 | 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.DeployFailureError | 1327 | + | 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 ( | |||
| 8 | 8 | "net/http" | |
| 9 | 9 | "net/url" | |
| 10 | 10 | "strings" | |
| 11 | + | "sync" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 13 | 14 | ) | |
| @@ -38,6 +39,14 @@ type CloudflareZoneResolver struct { | |||
| 38 | 39 | token string | |
| 39 | 40 | baseURL string | |
| 40 | 41 | 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 | 52 | // CloudflareZoneResolverOption configures a CloudflareZoneResolver. | |
| @@ -63,6 +72,7 @@ func NewCloudflareZoneResolver(token string, opts ...CloudflareZoneResolverOptio | |||
| 63 | 72 | token: token, | |
| 64 | 73 | baseURL: defaultCloudflareBaseURL, | |
| 65 | 74 | client: http.DefaultClient, | |
| 75 | + | cache: make(map[string]zoneCacheEntry), | |
| 66 | 76 | } | |
| 67 | 77 | for _, opt := range opts { | |
| 68 | 78 | opt(r) | |
| @@ -83,6 +93,10 @@ func (r *CloudflareZoneResolver) FindZone(ctx context.Context, domainName string | |||
| 83 | 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 | 100 | parts := strings.Split(domainName, ".") | |
| 87 | 101 | ||
| 88 | 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 | 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 | 123 | zone, err := r.findZoneByName(ctx, candidate) | |
| 124 | + | r.storeCache(candidate, zone, err) | |
| 103 | 125 | if err != nil { | |
| 104 | 126 | continue | |
| 105 | 127 | } | |
| 128 | + | r.storeCache(domainName, zone, nil) | |
| 106 | 129 | 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 | 150 | // 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 string | 39 | token string | ||
| 39 | baseURL string | 40 | baseURL string | ||
| 40 | client *http.Client | 41 | 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 | continue | 126 | continue | ||
| 105 | } | 127 | } | ||
| 128 | + | r.storeCache(domainName, zone, nil) | |||
| 106 | return zone, nil | 129 | 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) { | |||
| 278 | 278 | 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 | 281 | // resourceToStored converts a lego certificate.Resource to out.StoredCertificate. | |
| 291 | 282 | func resourceToStored(order out.CertificateOrder, res *certificate.Resource) *out.StoredCertificate { | |
| 292 | 283 | var fullchainPEM []byte | |
| @@ -278,15 +278,6 @@ func restoreAccount(stored *out.ACMEAccount) (*AccountUser, error) { | |||||
| 278 | return NewAccountUser(stored.Email, privateKey, reg), nil | 278 | 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 []byte | 283 | var fullchainPEM []byte | ||
M internal/adapters/out/acmelego/issuer_test.go +3 -2
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "testing" | |
| 16 | 16 | "time" | |
| 17 | 17 | ||
| 18 | + | "github.com/go-acme/lego/v4/acme" | |
| 18 | 19 | "github.com/go-acme/lego/v4/certcrypto" | |
| 19 | 20 | "github.com/go-acme/lego/v4/certificate" | |
| 20 | 21 | "github.com/stretchr/testify/assert" | |
| @@ -194,7 +195,7 @@ func TestResourceConversionNoIssuer(t *testing.T) { | |||
| 194 | 195 | ||
| 195 | 196 | func TestAccountJSONRoundTrip(t *testing.T) { | |
| 196 | 197 | // Simulate an ACME account with BodyJSON containing the serialized acme.Account | |
| 197 | - | body := acmeAccount{ | |
| 198 | + | body := acme.Account{ | |
| 198 | 199 | Status: "valid", | |
| 199 | 200 | Contact: []string{"mailto:test@example.com"}, | |
| 200 | 201 | TermsOfServiceAgreed: true, | |
| @@ -204,7 +205,7 @@ func TestAccountJSONRoundTrip(t *testing.T) { | |||
| 204 | 205 | bodyJSON, err := json.Marshal(body) | |
| 205 | 206 | require.NoError(t, err) | |
| 206 | 207 | ||
| 207 | - | var restored acmeAccount | |
| 208 | + | var restored acme.Account | |
| 208 | 209 | err = json.Unmarshal(bodyJSON, &restored) | |
| 209 | 210 | require.NoError(t, err) | |
| 210 | 211 | 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.Account | 197 | // 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 acmeAccount | 208 | + | 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 ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "os" | |
| 10 | 10 | "path/filepath" | |
| 11 | - | "strings" | |
| 12 | 11 | "time" | |
| 13 | 12 | ||
| 14 | 13 | "github.com/bnema/gordon/internal/boundaries/out" | |
| @@ -20,7 +19,7 @@ type certMetadata struct { | |||
| 20 | 19 | ID string `json:"id"` | |
| 21 | 20 | Names []string `json:"names"` | |
| 22 | 21 | Challenge string `json:"challenge"` | |
| 23 | - | NotAfter time.Time `json:"not_after,omitempty"` | |
| 22 | + | NotAfter time.Time `json:"not_after"` | |
| 24 | 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 | 256 | // safeID rejects IDs that could cause path traversal. | |
| 258 | 257 | 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) | |
| 266 | 259 | } | |
| 267 | 260 | ||
| 268 | 261 | // 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 file | 261 | // 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 ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | - | "os/exec" | |
| 9 | 8 | "strings" | |
| 10 | - | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zerowrap" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 13 | 13 | "github.com/bnema/gordon/internal/domain" | |
| @@ -96,18 +96,5 @@ func defaultPassLookup(ctx context.Context, name string) (string, error) { | |||
| 96 | 96 | return "", fmt.Errorf("pass lookup: disallowed name %q", name) | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 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) | |
| 113 | 100 | } | |
| @@ -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) { | |||
| 82 | 82 | publicCert := generateTestCert(t, "static.example.com") | |
| 83 | 83 | ||
| 84 | 84 | selector := &certificateSelector{ | |
| 85 | - | staticCerts: []tls.Certificate{staticCert}, | |
| 85 | + | staticCerts: prepareStaticTLSCertificates([]tls.Certificate{staticCert}), | |
| 86 | 86 | publicTLS: &stubPublicTLS{ | |
| 87 | 87 | getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |
| 88 | 88 | 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, nil | 88 | return &publicCert, nil | ||
M internal/app/run.go +32 -14
| @@ -2754,11 +2754,16 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||
| 2754 | 2754 | // certificateSelector implements a multi-source TLS certificate lookup. | |
| 2755 | 2755 | // Priority: static certs → public ACME TLS → local PKI (internal CA). | |
| 2756 | 2756 | type certificateSelector struct { | |
| 2757 | - | staticCerts []tls.Certificate | |
| 2757 | + | staticCerts []staticTLSCertificate | |
| 2758 | 2758 | publicTLS in.PublicTLSService | |
| 2759 | 2759 | localPKI *pkiusecase.Service | |
| 2760 | 2760 | } | |
| 2761 | 2761 | ||
| 2762 | + | type staticTLSCertificate struct { | |
| 2763 | + | cert tls.Certificate | |
| 2764 | + | leaf *x509.Certificate | |
| 2765 | + | } | |
| 2766 | + | ||
| 2762 | 2767 | // GetCertificate selects a TLS certificate based on the ClientHello SNI. | |
| 2763 | 2768 | // | |
| 2764 | 2769 | // Priority: | |
| @@ -2768,7 +2773,7 @@ type certificateSelector struct { | |||
| 2768 | 2773 | // 4. nil, nil — if no source can serve the host | |
| 2769 | 2774 | func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |
| 2770 | 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 | 2777 | return cert, nil | |
| 2773 | 2778 | } | |
| 2774 | 2779 | ||
| @@ -2793,28 +2798,41 @@ func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.C | |||
| 2793 | 2798 | return nil, nil | |
| 2794 | 2799 | } | |
| 2795 | 2800 | ||
| 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 { | |
| 2799 | 2816 | if serverName == "" { | |
| 2800 | 2817 | return nil | |
| 2801 | 2818 | } | |
| 2802 | 2819 | for i := range certs { | |
| 2803 | - | cert := &certs[i] | |
| 2804 | - | if len(cert.Certificate) == 0 { | |
| 2820 | + | if certs[i].leaf == nil { | |
| 2805 | 2821 | continue | |
| 2806 | 2822 | } | |
| 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 | |
| 2813 | 2825 | } | |
| 2814 | 2826 | } | |
| 2815 | 2827 | 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 | 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 | 2837 | // HTTP listener (Cloudflare proxy + onboarding for direct clients) | |
| 2820 | 2838 | var httpProtos http.Protocols | |
| @@ -2849,7 +2867,7 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv | |||
| 2849 | 2867 | } | |
| 2850 | 2868 | ||
| 2851 | 2869 | selector := &certificateSelector{ | |
| 2852 | - | staticCerts: staticCerts, | |
| 2870 | + | staticCerts: prepareStaticTLSCertificates(staticCerts), | |
| 2853 | 2871 | publicTLS: publicTLS, | |
| 2854 | 2872 | localPKI: pkiSvc, | |
| 2855 | 2873 | } | |
| @@ -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.Certificate | 2757 | + | staticCerts []staticTLSCertificate |
| 2758 | publicTLS in.PublicTLSService | 2758 | publicTLS in.PublicTLSService | ||
| 2759 | localPKI *pkiusecase.Service | 2759 | 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 host | 2773 | // 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, nil | 2777 | return cert, nil | ||
| 2773 | } | 2778 | } | ||
| 2774 | 2779 | ||||
| @@ -2793,28 +2798,41 @@ func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.C | |||||
| 2793 | return nil, nil | 2798 | return nil, nil | ||
| 2794 | } | 2799 | } | ||
| 2795 | 2800 | ||||
| 2796 | - | // matchingStaticCert returns a pointer to the first static certificate whose | 2801 | + | 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 nil | 2817 | 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 | continue | 2821 | 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 nil | 2827 | 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.Protocols | 2838 | 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 { | |||
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | 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 | 79 | host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") | |
| 70 | 80 | if host == "" { | |
| 71 | 81 | return false | |
| 72 | 82 | } | |
| 73 | - | for _, name := range c.Names { | |
| 83 | + | for _, name := range names { | |
| 74 | 84 | name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".") | |
| 75 | 85 | if name == host { | |
| 76 | 86 | return true | |
| 77 | 87 | } | |
| 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 | |
| 85 | 92 | } | |
| 86 | 93 | } | |
| 87 | 94 | } | |
| @@ -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 false | 81 | 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 true | 86 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "strings" | |
| 6 | 5 | "sync" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | // 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 | 56 | // It rejects empty tokens, tokens containing "/" or "\", and tokens | |
| 56 | 57 | // equal to "..", which could allow path traversal. | |
| 57 | 58 | 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) | |
| 71 | 60 | } | |
| @@ -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 key | 10 | // 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 tokens | 56 | // 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) | |||
| 61 | 61 | ticker := time.NewTicker(interval) | |
| 62 | 62 | defer ticker.Stop() | |
| 63 | 63 | ||
| 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. | |
| 66 | 66 | _ = s.renewDueCertificates(loopCtx, time.Now()) | |
| 67 | 67 | ||
| 68 | 68 | for { | |
| @@ -70,7 +70,6 @@ func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration) | |||
| 70 | 70 | case <-loopCtx.Done(): | |
| 71 | 71 | return | |
| 72 | 72 | case <-ticker.C: | |
| 73 | - | _ = s.Reconcile(loopCtx) | |
| 74 | 73 | _ = s.renewDueCertificates(loopCtx, time.Now()) | |
| 75 | 74 | } | |
| 76 | 75 | } | |
| @@ -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 | return | 71 | 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 { | |||
| 255 | 255 | // hostMatchesCert checks if any name in the given list matches host, | |
| 256 | 256 | // including wildcard matching. | |
| 257 | 257 | 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) | |
| 278 | 259 | } | |
| 279 | 260 | ||
| 280 | 261 | // 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. | ||