detached ae10fc7

Normalize DNS resolver validation

commit
ae10fc74f944812a0b5264c86783d9efc461c342
author
brice · · 2026-05-01 17:25 UTC
parent
f4879b5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +63 -12
M internal/adapters/out/acmelego/issuer.go +34 -2
@@ -7,7 +7,9 @@ import (
77 "crypto/x509"
88 "encoding/json"
99 "fmt"
10+ "net"
1011 "net/http"
12+ "strconv"
1113 "strings"
1214 "sync"
1315 "time"
@@ -91,9 +93,11 @@ func NewIssuer(cfg Config) (*Issuer, error) {
9193 if cfg.Token == "" {
9294 return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)
9395 }
94- if len(cfg.DNSResolvers) == 0 {
95- return nil, fmt.Errorf("acmelego: %w: DNSResolvers must contain at least one resolver", domain.ErrDNSConfigInvalid)
96+ resolvers, err := normalizeDNSResolvers(cfg.DNSResolvers)
97+ if err != nil {
98+ return nil, err
9699 }
100+ cfg.DNSResolvers = resolvers
97101 if cfg.DNSPropagationTimeout <= 0 {
98102 return nil, fmt.Errorf("acmelego: %w: DNSPropagationTimeout must be positive", domain.ErrDNSConfigInvalid)
99103 }
@@ -110,6 +114,34 @@ func NewIssuer(cfg Config) (*Issuer, error) {
110114 return &Issuer{cfg: cfg}, nil
111115 }
112116
117+func normalizeDNSResolvers(resolvers []string) ([]string, error) {
118+ if len(resolvers) == 0 {
119+ return nil, fmt.Errorf("acmelego: %w: DNSResolvers must contain at least one resolver", domain.ErrDNSConfigInvalid)
120+ }
121+
122+ normalized := make([]string, len(resolvers))
123+ for i, resolver := range resolvers {
124+ trimmed := strings.TrimSpace(resolver)
125+ if trimmed == "" {
126+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry", domain.ErrDNSConfigInvalid)
127+ }
128+ host, port, err := net.SplitHostPort(trimmed)
129+ if err != nil {
130+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry", domain.ErrDNSConfigInvalid)
131+ }
132+ if host == "" {
133+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry", domain.ErrDNSConfigInvalid)
134+ }
135+ portNumber, err := strconv.Atoi(port)
136+ if err != nil || portNumber < 1 || portNumber > 65535 {
137+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry", domain.ErrDNSConfigInvalid)
138+ }
139+ normalized[i] = trimmed
140+ }
141+
142+ return normalized, nil
143+}
144+
113145 // compile-time interface check
114146 var _ out.PublicCertificateIssuer = (*Issuer)(nil)
115147
@@ -7,7 +7,9 @@ import (
7 "crypto/x509"7 "crypto/x509"
8 "encoding/json"8 "encoding/json"
9 "fmt"9 "fmt"
10+ "net"
10 "net/http"11 "net/http"
12+ "strconv"
11 "strings"13 "strings"
12 "sync"14 "sync"
13 "time"15 "time"
@@ -91,9 +93,11 @@ func NewIssuer(cfg Config) (*Issuer, error) {
91 if cfg.Token == "" {93 if cfg.Token == "" {
92 return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)94 return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)
93 }95 }
94- if len(cfg.DNSResolvers) == 0 {96+ resolvers, err := normalizeDNSResolvers(cfg.DNSResolvers)
95- return nil, fmt.Errorf("acmelego: %w: DNSResolvers must contain at least one resolver", domain.ErrDNSConfigInvalid)97+ if err != nil {
98+ return nil, err
96 }99 }
100+ cfg.DNSResolvers = resolvers
97 if cfg.DNSPropagationTimeout <= 0 {101 if cfg.DNSPropagationTimeout <= 0 {
98 return nil, fmt.Errorf("acmelego: %w: DNSPropagationTimeout must be positive", domain.ErrDNSConfigInvalid)102 return nil, fmt.Errorf("acmelego: %w: DNSPropagationTimeout must be positive", domain.ErrDNSConfigInvalid)
99 }103 }
@@ -110,6 +114,34 @@ func NewIssuer(cfg Config) (*Issuer, error) {
110 return &Issuer{cfg: cfg}, nil114 return &Issuer{cfg: cfg}, nil
111 }115 }
112 116
117+func normalizeDNSResolvers(resolvers []string) ([]string, error) {
118+ if len(resolvers) == 0 {
119+ return nil, fmt.Errorf("acmelego: %w: DNSResolvers must contain at least one resolver", domain.ErrDNSConfigInvalid)
120+ }
121+
122+ normalized := make([]string, len(resolvers))
123+ for i, resolver := range resolvers {
124+ trimmed := strings.TrimSpace(resolver)
125+ if trimmed == "" {
126+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry", domain.ErrDNSConfigInvalid)
127+ }
128+ host, port, err := net.SplitHostPort(trimmed)
129+ if err != nil {
130+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry", domain.ErrDNSConfigInvalid)
131+ }
132+ if host == "" {
133+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry", domain.ErrDNSConfigInvalid)
134+ }
135+ portNumber, err := strconv.Atoi(port)
136+ if err != nil || portNumber < 1 || portNumber > 65535 {
137+ return nil, fmt.Errorf("acmelego: %w: invalid DNSResolvers entry", domain.ErrDNSConfigInvalid)
138+ }
139+ normalized[i] = trimmed
140+ }
141+
142+ return normalized, nil
143+}
144+
113 // compile-time interface check145 // compile-time interface check
114 var _ out.PublicCertificateIssuer = (*Issuer)(nil)146 var _ out.PublicCertificateIssuer = (*Issuer)(nil)
115 147
M internal/adapters/out/acmelego/issuer_test.go +16 -2
@@ -83,6 +83,19 @@ func TestNewIssuerValidatesCloudflareDNSConfig(t *testing.T) {
8383 assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
8484 assert.Contains(t, err.Error(), "DNSResolvers")
8585
86+ _, err = NewIssuer(Config{
87+ Email: "test@example.com",
88+ Challenge: domain.ACMEChallengeCloudflareDNS01,
89+ Token: "token",
90+ Store: outmocks.NewMockCertificateStore(t),
91+ DNSResolvers: []string{"1.1.1.1:abc"},
92+ DNSPropagationTimeout: 5 * time.Minute,
93+ DNSPollingInterval: 5 * time.Second,
94+ })
95+ require.Error(t, err)
96+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
97+ assert.Contains(t, err.Error(), "invalid DNSResolvers entry")
98+
8699 _, err = NewIssuer(Config{
87100 Email: "test@example.com",
88101 Challenge: domain.ACMEChallengeCloudflareDNS01,
@@ -96,16 +109,17 @@ func TestNewIssuerValidatesCloudflareDNSConfig(t *testing.T) {
96109 assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
97110 assert.Contains(t, err.Error(), "DNSPollingInterval")
98111
99- _, err = NewIssuer(Config{
112+ issuer, err := NewIssuer(Config{
100113 Email: "test@example.com",
101114 Challenge: domain.ACMEChallengeCloudflareDNS01,
102115 Token: "token",
103116 Store: outmocks.NewMockCertificateStore(t),
104- DNSResolvers: []string{"1.1.1.1:53"},
117+ DNSResolvers: []string{" 1.1.1.1:53 ", "\t8.8.8.8:53\n"},
105118 DNSPropagationTimeout: 5 * time.Minute,
106119 DNSPollingInterval: 5 * time.Second,
107120 })
108121 require.NoError(t, err)
122+ assert.Equal(t, []string{"1.1.1.1:53", "8.8.8.8:53"}, issuer.cfg.DNSResolvers)
109123 }
110124
111125 func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) {
@@ -83,6 +83,19 @@ func TestNewIssuerValidatesCloudflareDNSConfig(t *testing.T) {
83 assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)83 assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
84 assert.Contains(t, err.Error(), "DNSResolvers")84 assert.Contains(t, err.Error(), "DNSResolvers")
85 85
86+ _, err = NewIssuer(Config{
87+ Email: "test@example.com",
88+ Challenge: domain.ACMEChallengeCloudflareDNS01,
89+ Token: "token",
90+ Store: outmocks.NewMockCertificateStore(t),
91+ DNSResolvers: []string{"1.1.1.1:abc"},
92+ DNSPropagationTimeout: 5 * time.Minute,
93+ DNSPollingInterval: 5 * time.Second,
94+ })
95+ require.Error(t, err)
96+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
97+ assert.Contains(t, err.Error(), "invalid DNSResolvers entry")
98+
86 _, err = NewIssuer(Config{99 _, err = NewIssuer(Config{
87 Email: "test@example.com",100 Email: "test@example.com",
88 Challenge: domain.ACMEChallengeCloudflareDNS01,101 Challenge: domain.ACMEChallengeCloudflareDNS01,
@@ -96,16 +109,17 @@ func TestNewIssuerValidatesCloudflareDNSConfig(t *testing.T) {
96 assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)109 assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
97 assert.Contains(t, err.Error(), "DNSPollingInterval")110 assert.Contains(t, err.Error(), "DNSPollingInterval")
98 111
99- _, err = NewIssuer(Config{112+ issuer, err := NewIssuer(Config{
100 Email: "test@example.com",113 Email: "test@example.com",
101 Challenge: domain.ACMEChallengeCloudflareDNS01,114 Challenge: domain.ACMEChallengeCloudflareDNS01,
102 Token: "token",115 Token: "token",
103 Store: outmocks.NewMockCertificateStore(t),116 Store: outmocks.NewMockCertificateStore(t),
104- DNSResolvers: []string{"1.1.1.1:53"},117+ DNSResolvers: []string{" 1.1.1.1:53 ", "\t8.8.8.8:53\n"},
105 DNSPropagationTimeout: 5 * time.Minute,118 DNSPropagationTimeout: 5 * time.Minute,
106 DNSPollingInterval: 5 * time.Second,119 DNSPollingInterval: 5 * time.Second,
107 })120 })
108 require.NoError(t, err)121 require.NoError(t, err)
122+ assert.Equal(t, []string{"1.1.1.1:53", "8.8.8.8:53"}, issuer.cfg.DNSResolvers)
109 }123 }
110 124
111 func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) {125 func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) {
M internal/usecase/publictls/dns_config.go +10 -8
@@ -6,6 +6,8 @@ import (
66 "strconv"
77 "strings"
88 "time"
9+
10+ "github.com/bnema/gordon/internal/domain"
911 )
1012
1113 const (
@@ -36,34 +38,34 @@ func DefaultDNSConfig() DNSConfig {
3638 // Validate checks DNSConfig for ACME DNS-01 usage and normalizes resolver whitespace.
3739 func (c *DNSConfig) Validate() error {
3840 if len(c.Resolvers) == 0 {
39- return fmt.Errorf("dns.resolvers must contain at least one resolver")
41+ return fmt.Errorf("%w: dns.resolvers must contain at least one resolver", domain.ErrDNSConfigInvalid)
4042 }
4143 for i, resolver := range c.Resolvers {
4244 trimmed := strings.TrimSpace(resolver)
4345 if trimmed == "" {
44- return fmt.Errorf("invalid dns.resolvers[%d]: empty resolver", i)
46+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: empty resolver", domain.ErrDNSConfigInvalid, i)
4547 }
4648 host, port, err := net.SplitHostPort(trimmed)
4749 if err != nil {
48- return fmt.Errorf("invalid dns.resolvers[%d]: %q: expected host:port: %w", i, trimmed, err)
50+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: %q: expected host:port: %v", domain.ErrDNSConfigInvalid, i, trimmed, err)
4951 }
5052 if host == "" {
51- return fmt.Errorf("invalid dns.resolvers[%d]: %q: host must not be empty", i, trimmed)
53+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: %q: host must not be empty", domain.ErrDNSConfigInvalid, i, trimmed)
5254 }
5355 portNumber, err := strconv.Atoi(port)
5456 if err != nil || portNumber < 1 || portNumber > 65535 {
55- return fmt.Errorf("invalid dns.resolvers[%d]: %q: port must be a number between 1 and 65535", i, trimmed)
57+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: %q: port must be a number between 1 and 65535", domain.ErrDNSConfigInvalid, i, trimmed)
5658 }
5759 c.Resolvers[i] = trimmed
5860 }
5961 if c.PropagationTimeout <= 0 {
60- return fmt.Errorf("dns.propagation_timeout must be positive")
62+ return fmt.Errorf("%w: dns.propagation_timeout must be positive", domain.ErrDNSConfigInvalid)
6163 }
6264 if c.PollingInterval <= 0 {
63- return fmt.Errorf("dns.polling_interval must be positive")
65+ return fmt.Errorf("%w: dns.polling_interval must be positive", domain.ErrDNSConfigInvalid)
6466 }
6567 if c.PollingInterval >= c.PropagationTimeout {
66- return fmt.Errorf("dns.polling_interval must be less than dns.propagation_timeout")
68+ return fmt.Errorf("%w: dns.polling_interval must be less than dns.propagation_timeout", domain.ErrDNSConfigInvalid)
6769 }
6870 return nil
6971 }
@@ -6,6 +6,8 @@ import (
6 "strconv"6 "strconv"
7 "strings"7 "strings"
8 "time"8 "time"
9+
10+ "github.com/bnema/gordon/internal/domain"
9 )11 )
10 12
11 const (13 const (
@@ -36,34 +38,34 @@ func DefaultDNSConfig() DNSConfig {
36 // Validate checks DNSConfig for ACME DNS-01 usage and normalizes resolver whitespace.38 // Validate checks DNSConfig for ACME DNS-01 usage and normalizes resolver whitespace.
37 func (c *DNSConfig) Validate() error {39 func (c *DNSConfig) Validate() error {
38 if len(c.Resolvers) == 0 {40 if len(c.Resolvers) == 0 {
39- return fmt.Errorf("dns.resolvers must contain at least one resolver")41+ return fmt.Errorf("%w: dns.resolvers must contain at least one resolver", domain.ErrDNSConfigInvalid)
40 }42 }
41 for i, resolver := range c.Resolvers {43 for i, resolver := range c.Resolvers {
42 trimmed := strings.TrimSpace(resolver)44 trimmed := strings.TrimSpace(resolver)
43 if trimmed == "" {45 if trimmed == "" {
44- return fmt.Errorf("invalid dns.resolvers[%d]: empty resolver", i)46+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: empty resolver", domain.ErrDNSConfigInvalid, i)
45 }47 }
46 host, port, err := net.SplitHostPort(trimmed)48 host, port, err := net.SplitHostPort(trimmed)
47 if err != nil {49 if err != nil {
48- return fmt.Errorf("invalid dns.resolvers[%d]: %q: expected host:port: %w", i, trimmed, err)50+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: %q: expected host:port: %v", domain.ErrDNSConfigInvalid, i, trimmed, err)
49 }51 }
50 if host == "" {52 if host == "" {
51- return fmt.Errorf("invalid dns.resolvers[%d]: %q: host must not be empty", i, trimmed)53+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: %q: host must not be empty", domain.ErrDNSConfigInvalid, i, trimmed)
52 }54 }
53 portNumber, err := strconv.Atoi(port)55 portNumber, err := strconv.Atoi(port)
54 if err != nil || portNumber < 1 || portNumber > 65535 {56 if err != nil || portNumber < 1 || portNumber > 65535 {
55- return fmt.Errorf("invalid dns.resolvers[%d]: %q: port must be a number between 1 and 65535", i, trimmed)57+ return fmt.Errorf("%w: invalid dns.resolvers[%d]: %q: port must be a number between 1 and 65535", domain.ErrDNSConfigInvalid, i, trimmed)
56 }58 }
57 c.Resolvers[i] = trimmed59 c.Resolvers[i] = trimmed
58 }60 }
59 if c.PropagationTimeout <= 0 {61 if c.PropagationTimeout <= 0 {
60- return fmt.Errorf("dns.propagation_timeout must be positive")62+ return fmt.Errorf("%w: dns.propagation_timeout must be positive", domain.ErrDNSConfigInvalid)
61 }63 }
62 if c.PollingInterval <= 0 {64 if c.PollingInterval <= 0 {
63- return fmt.Errorf("dns.polling_interval must be positive")65+ return fmt.Errorf("%w: dns.polling_interval must be positive", domain.ErrDNSConfigInvalid)
64 }66 }
65 if c.PollingInterval >= c.PropagationTimeout {67 if c.PollingInterval >= c.PropagationTimeout {
66- return fmt.Errorf("dns.polling_interval must be less than dns.propagation_timeout")68+ return fmt.Errorf("%w: dns.polling_interval must be less than dns.propagation_timeout", domain.ErrDNSConfigInvalid)
67 }69 }
68 return nil70 return nil
69 }71 }
M internal/usecase/publictls/dns_config_test.go +3 -0
@@ -6,6 +6,8 @@ import (
66
77 "github.com/stretchr/testify/assert"
88 "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/domain"
911 )
1012
1113 func TestDefaultDNSConfig(t *testing.T) {
@@ -114,6 +116,7 @@ func TestDNSConfigValidate(t *testing.T) {
114116 return
115117 }
116118 require.Error(t, err)
119+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
117120 assert.Contains(t, err.Error(), tt.wantErr)
118121 })
119122 }
@@ -6,6 +6,8 @@ import (
6 6
7 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/domain"
9 )11 )
10 12
11 func TestDefaultDNSConfig(t *testing.T) {13 func TestDefaultDNSConfig(t *testing.T) {
@@ -114,6 +116,7 @@ func TestDNSConfigValidate(t *testing.T) {
114 return116 return
115 }117 }
116 require.Error(t, err)118 require.Error(t, err)
119+ assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
117 assert.Contains(t, err.Error(), tt.wantErr)120 assert.Contains(t, err.Error(), tt.wantErr)
118 })121 })
119 }122 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close