Skip to content

Commit 4a94f28

Browse files
authored
feat: add ip address validation for A and AAAA records (#194)
* feat: add ip address validation for A and AAAA records * test: add test cases for ip validation function * test: add test cases for record ressource * feat: add provider toggle for IP address validation in A and AAAA records
1 parent 5f975a9 commit 4a94f28

File tree

6 files changed

+173
-2
lines changed

6 files changed

+173
-2
lines changed

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,6 @@ resource "hetznerdns_record" "web" {
4040
### Optional
4141

4242
- `api_token` (String, Sensitive) The Hetzner DNS API token. You can pass it using the env variable `HETZNER_DNS_TOKEN` as well. The old env variable `HETZNER_DNS_API_TOKEN` is deprecated and will be removed in a future release.
43+
- `enable_ip_validation` (Boolean) `Default: true` Toggles the validation of IP addresses in A and AAAA records. You can pass it using the env variable `HETZNER_DNS_ENABLE_IP_VALIDATION` as well.
4344
- `enable_txt_formatter` (Boolean) `Default: true` Toggles the automatic formatter for TXT record values. Values greater than 255 bytes get split into multiple quoted chunks ([RFC4408](https://datatracker.ietf.org/doc/html/rfc4408#section-3.1.3)). You can pass it using the env variable `HETZNER_DNS_ENABLE_TXT_FORMATTER` as well.
4445
- `max_retries` (Number) `Default: 1` The maximum number of retries to perform when an API request fails. You can pass it using the env variable `HETZNER_DNS_MAX_RETRIES` as well.

internal/provider/provider.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@ type hetznerDNSProviderModel struct {
3535
ApiToken types.String `tfsdk:"api_token"`
3636
MaxRetries types.Int64 `tfsdk:"max_retries"`
3737
EnableTxtFormatter types.Bool `tfsdk:"enable_txt_formatter"`
38+
EnableIPValidation types.Bool `tfsdk:"enable_ip_validation"`
3839
}
3940

4041
type providerClient struct {
4142
apiClient *api.Client
4243
maxRetries int64
4344
txtFormatter bool
45+
ipValidation bool
4446
}
4547

4648
func (p *hetznerDNSProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
@@ -73,6 +75,11 @@ func (p *hetznerDNSProvider) Schema(_ context.Context, _ provider.SchemaRequest,
7375
"You can pass it using the env variable `HETZNER_DNS_ENABLE_TXT_FORMATTER` as well.",
7476
Optional: true,
7577
},
78+
"enable_ip_validation": schema.BoolAttribute{
79+
Description: "`Default: true` Toggles the validation of IP addresses in A and AAAA records. " +
80+
"You can pass it using the env variable `HETZNER_DNS_ENABLE_IP_VALIDATION` as well.",
81+
Optional: true,
82+
},
7683
},
7784
}
7885
}
@@ -122,6 +129,11 @@ func (p *hetznerDNSProvider) Configure(ctx context.Context, req provider.Configu
122129
resp.Diagnostics.AddAttributeError(path.Root("enable_txt_formatter"), "must be a boolean", err.Error())
123130
}
124131

132+
client.ipValidation, err = utils.ConfigureBoolAttribute(data.EnableIPValidation, "HETZNER_DNS_ENABLE_IP_VALIDATION", true)
133+
if err != nil {
134+
resp.Diagnostics.AddAttributeError(path.Root("enable_ip_validation"), "must be a boolean", err.Error())
135+
}
136+
125137
if resp.Diagnostics.HasError() {
126138
return
127139
}

internal/provider/record_resource.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,15 @@ func (r *recordResource) Create(ctx context.Context, req resource.CreateRequest,
179179
}
180180
}
181181

182+
if (plan.Type.ValueString() == "A" || plan.Type.ValueString() == "AAAA") && r.provider.ipValidation {
183+
err := utils.CheckIPAddress(value)
184+
if err != nil {
185+
resp.Diagnostics.AddError("Invalid IP address", err.Error())
186+
187+
return
188+
}
189+
}
190+
182191
var (
183192
err error
184193
record *api.Record
@@ -302,6 +311,15 @@ func (r *recordResource) Update(ctx context.Context, req resource.UpdateRequest,
302311
value = utils.PlainToTXTRecordValue(value)
303312
}
304313

314+
if (plan.Type.ValueString() == "A" || plan.Type.ValueString() == "AAAA") && r.provider.ipValidation {
315+
err := utils.CheckIPAddress(value)
316+
if err != nil {
317+
resp.Diagnostics.AddError("Invalid IP address", err.Error())
318+
319+
return
320+
}
321+
}
322+
305323
if !plan.Name.Equal(state.Name) || !plan.TTL.Equal(state.TTL) || !plan.Type.Equal(state.Type) || !plan.Value.Equal(state.Value) {
306324
updateTimeout, diags := plan.Timeouts.Update(ctx, 5*time.Minute)
307325
resp.Diagnostics.Append(diags...)

internal/provider/record_resource_test.go

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ func TestAccRecord_ResourcesWithDeprecatedApiToken(t *testing.T) {
162162
})
163163
}
164164

165-
func TestAccRecord_Invalid(t *testing.T) {
165+
func TestAccRecord_InvalidIP(t *testing.T) {
166166
zoneName := acctest.RandString(10) + ".online"
167167
aZoneTTL := 60
168168

@@ -183,7 +183,63 @@ func TestAccRecord_Invalid(t *testing.T) {
183183
testAccRecordResourceConfigWithTTL("record1", aName, aType, value, ttl),
184184
}, "\n",
185185
),
186-
ExpectError: regexp.MustCompile("invalid A record"),
186+
ExpectError: regexp.MustCompile(utils.ErrInvalidIPAddress.Error()),
187+
},
188+
// Delete testing automatically occurs in TestCase
189+
},
190+
})
191+
}
192+
193+
func TestAccRecord_InvalidIPv4(t *testing.T) {
194+
zoneName := acctest.RandString(10) + ".online"
195+
aZoneTTL := 60
196+
197+
value := "9.9.9.999"
198+
aName := acctest.RandString(10)
199+
aType := "A"
200+
ttl := aZoneTTL * 2
201+
202+
resource.Test(t, resource.TestCase{
203+
PreCheck: func() { testAccPreCheck(t) },
204+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
205+
Steps: []resource.TestStep{
206+
// Create and Read testing
207+
{
208+
Config: strings.Join(
209+
[]string{
210+
testAccZoneResourceConfig("test", zoneName, aZoneTTL),
211+
testAccRecordResourceConfigWithTTL("record1", aName, aType, value, ttl),
212+
}, "\n",
213+
),
214+
ExpectError: regexp.MustCompile(utils.ErrInvalidIPAddress.Error()),
215+
},
216+
// Delete testing automatically occurs in TestCase
217+
},
218+
})
219+
}
220+
221+
func TestAccRecord_InvalidIPv6(t *testing.T) {
222+
zoneName := acctest.RandString(10) + ".online"
223+
aZoneTTL := 60
224+
225+
value := "2001:4860:4860:::8888"
226+
aName := acctest.RandString(10)
227+
aType := "A"
228+
ttl := aZoneTTL * 2
229+
230+
resource.Test(t, resource.TestCase{
231+
PreCheck: func() { testAccPreCheck(t) },
232+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
233+
Steps: []resource.TestStep{
234+
// Create and Read testing
235+
{
236+
Config: strings.Join(
237+
[]string{
238+
testAccZoneResourceConfig("test", zoneName, aZoneTTL),
239+
testAccRecordResourceConfigWithTTL("record1", aName, aType, value, ttl),
240+
}, "\n",
241+
),
242+
ExpectError: regexp.MustCompile(utils.ErrInvalidIPAddress.Error()),
187243
},
188244
// Delete testing automatically occurs in TestCase
189245
},

internal/utils/ip.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package utils
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net"
7+
)
8+
9+
var ErrInvalidIPAddress = errors.New("invalid IP address")
10+
11+
// CheckIPAddress checks if the given string is a valid IP address.
12+
func CheckIPAddress(ip string) error {
13+
parsedIP := net.ParseIP(ip)
14+
if parsedIP == nil {
15+
return fmt.Errorf("%w: %s", ErrInvalidIPAddress, ip)
16+
}
17+
18+
return nil
19+
}

internal/utils/ip_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package utils_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/germanbrew/terraform-provider-hetznerdns/internal/utils"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestCheckIPAddress(t *testing.T) {
11+
t.Parallel()
12+
13+
for _, tc := range []struct {
14+
name string
15+
ip string
16+
isValid bool
17+
}{
18+
{
19+
name: "valid IPv4",
20+
ip: "9.9.9.9",
21+
isValid: true,
22+
},
23+
{
24+
name: "invalid IPv4",
25+
ip: "9.9.9.999",
26+
isValid: false,
27+
},
28+
{
29+
name: "invalid IPv4 with space",
30+
ip: "9.9.9.9 ",
31+
isValid: false,
32+
},
33+
{
34+
name: "valid IPv6",
35+
ip: "2001:4860:4860::8888",
36+
isValid: true,
37+
},
38+
{
39+
name: "invalid IPv6",
40+
ip: "2001:4860:4860:::8888",
41+
isValid: false,
42+
},
43+
{
44+
name: "invalid IPv6 with space",
45+
ip: "2001:4860:4860::8888 ",
46+
isValid: false,
47+
},
48+
{
49+
name: "invalid IP",
50+
ip: "invalid",
51+
isValid: false,
52+
},
53+
} {
54+
t.Run(tc.name, func(t *testing.T) {
55+
t.Parallel()
56+
57+
err := utils.CheckIPAddress(tc.ip)
58+
if tc.isValid {
59+
require.NoError(t, err)
60+
} else {
61+
require.Error(t, err)
62+
}
63+
})
64+
}
65+
}

0 commit comments

Comments
 (0)