Skip to content

Commit 40df28d

Browse files
apricoteEttore Foti
and
Ettore Foti
authored
feat(firewall): set IPs without prefix directly (#874)
This commit allows users to specify allowed IPs directly, without having to awkwardly put an `/32` at the end. If an IP is passed, we only allow this single IP (`/32` for IPv4, `/128` for IPv6). This matches the behavior in Cloud Console. Closes #807 Closes #715 fix(firewall): unnecessary diff if user specified non-minimal IPv6 If the user specified an IPv6 which was not minimal (eg. `::0` instead of `::`), the API would modify this to the minimal form. In the next run terraform reports a diff and tries to update this over and over again. We now do this normalization locally to avoid showing these diffs to users. Closes #870 Co-authored-by: Ettore Foti <ettorefoti@gmail.com>
1 parent 2109a3f commit 40df28d

File tree

5 files changed

+133
-53
lines changed

5 files changed

+133
-53
lines changed

internal/firewall/resource.go

+3-7
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import (
77
"log"
88
"net"
99
"strconv"
10-
"strings"
1110

1211
"github.com/hashicorp/go-cty/cty"
1312
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
1413
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
14+
1515
"github.com/hetznercloud/hcloud-go/hcloud"
1616
"github.com/hetznercloud/terraform-provider-hcloud/internal/control"
1717
"github.com/hetznercloud/terraform-provider-hcloud/internal/util/hcloudutil"
@@ -116,9 +116,7 @@ func Resource() *schema.Resource {
116116
Elem: &schema.Schema{
117117
Type: schema.TypeString,
118118
ValidateDiagFunc: validateIPDiag,
119-
StateFunc: func(i interface{}) string {
120-
return strings.ToLower(i.(string))
121-
},
119+
StateFunc: normalizeIP,
122120
},
123121
Optional: true,
124122
},
@@ -127,9 +125,7 @@ func Resource() *schema.Resource {
127125
Elem: &schema.Schema{
128126
Type: schema.TypeString,
129127
ValidateDiagFunc: validateIPDiag,
130-
StateFunc: func(i interface{}) string {
131-
return strings.ToLower(i.(string))
132-
},
128+
StateFunc: normalizeIP,
133129
},
134130
Optional: true,
135131
},

internal/firewall/resource_test.go

+19-30
Original file line numberDiff line numberDiff line change
@@ -179,49 +179,38 @@ func TestFirewallResource_ApplyTo(t *testing.T) {
179179
})
180180
}
181181

182-
func TestFirewallResource_SourceIPs_IPv6Comparison(t *testing.T) {
182+
func TestFirewallResource_Normalization(t *testing.T) {
183183
var f hcloud.Firewall
184184

185185
res := firewall.NewRData(t, "ipv6-firewall", []firewall.RDataRule{
186186
{
187187
Direction: "in",
188188
Protocol: "tcp",
189+
// Uppercase
189190
SourceIPs: []string{"Aaaa:aaaa:aaaa:aaaa::/64"},
190191
Port: "22",
191192
},
192-
}, nil)
193-
tmplMan := testtemplate.Manager{}
194-
195-
// TODO: Move to parallel test once API endpoint supports higher parallelism
196-
resource.Test(t, resource.TestCase{
197-
PreCheck: teste2e.PreCheck(t),
198-
ProtoV6ProviderFactories: teste2e.ProtoV6ProviderFactories(),
199-
CheckDestroy: testsupport.CheckResourcesDestroyed(firewall.ResourceType, firewall.ByID(t, &f)),
200-
Steps: []resource.TestStep{
201-
{
202-
Config: tmplMan.Render(t, "testdata/r/hcloud_firewall", res),
203-
Check: resource.ComposeTestCheckFunc(
204-
testsupport.CheckResourceExists(res.TFID(), firewall.ByID(t, &f)),
205-
),
206-
},
207-
{
208-
Config: tmplMan.Render(t, "testdata/r/hcloud_firewall", res),
209-
PlanOnly: true,
210-
},
211-
},
212-
})
213-
}
214-
215-
func TestFirewallResource_DestinationIPs_IPv6Comparison(t *testing.T) {
216-
var f hcloud.Firewall
217-
218-
res := firewall.NewRData(t, "ipv6-firewall", []firewall.RDataRule{
219193
{
220-
Direction: "out",
221-
Protocol: "tcp",
194+
Direction: "out",
195+
Protocol: "tcp",
196+
// Uppercase
222197
DestinationIPs: []string{"Aaaa:aaaa:aaaa:aaaa::/64"},
223198
Port: "22",
224199
},
200+
{
201+
Direction: "in",
202+
Protocol: "tcp",
203+
// Avoidable 0
204+
SourceIPs: []string{"aaaa:aaaa:aaaa:0::/64"},
205+
Port: "80",
206+
},
207+
{
208+
Direction: "out",
209+
Protocol: "tcp",
210+
// Avoidable 0
211+
DestinationIPs: []string{"aaaa:aaaa:aaaa:0::/64"},
212+
Port: "80",
213+
},
225214
}, nil)
226215
tmplMan := testtemplate.Manager{}
227216

internal/firewall/validation.go

+47
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@ import (
55

66
"github.com/hashicorp/go-cty/cty"
77
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
8+
89
"github.com/hetznercloud/terraform-provider-hcloud/internal/util/hcloudutil"
910
)
1011

12+
var (
13+
// These masks are also used in the Cloud Console when a user enters an IP without range.
14+
defaultMaskIPv4 = net.CIDRMask(32, 32)
15+
defaultMaskIPv6 = net.CIDRMask(128, 128)
16+
)
17+
1118
func validateIPDiag(i interface{}, _ cty.Path) diag.Diagnostics {
19+
i = normalizeIP(i)
20+
1221
ipS := i.(string)
1322
ip, n, err := net.ParseCIDR(ipS)
1423
if err != nil {
@@ -19,3 +28,41 @@ func validateIPDiag(i interface{}, _ cty.Path) diag.Diagnostics {
1928
}
2029
return nil
2130
}
31+
32+
// normalizeIP implements two closely related functions:
33+
// 1. It normalizes an IP address or CIDR block to a CIDR block. To allow users to specify the IP directly.
34+
// 2. The API modifies CIDRs to lower case and IPv6 to its minimal form. This function does the same to
35+
// have clean diffs, even if the user input does not match the desired format by the API.
36+
func normalizeIP(i interface{}) string {
37+
input := i.(string)
38+
39+
ip, ipnet, err := net.ParseCIDR(input)
40+
if err == nil {
41+
// net.ParseCIDR removes any set host bits. We want to show an error to the user instead,
42+
// to avoid making any assumptions about their intent.
43+
// By setting the parse IP in the ipnet, the returned string will be the same as the input, only normalized & lower cased.
44+
ipnet.IP = ip
45+
} else {
46+
ip = net.ParseIP(input)
47+
if ip == nil {
48+
// No CIDR or IP, just return the input string
49+
return input
50+
}
51+
if ip.To4() != nil {
52+
// IPv4
53+
54+
ipnet = &net.IPNet{
55+
IP: ip,
56+
Mask: defaultMaskIPv4,
57+
}
58+
} else {
59+
// If To4 returns nil, IP is not IPv4 => IPv6
60+
ipnet = &net.IPNet{
61+
IP: ip,
62+
Mask: defaultMaskIPv6,
63+
}
64+
}
65+
}
66+
67+
return ipnet.String()
68+
}

internal/firewall/validation_test.go

+56-11
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,6 @@ func TestValidateIPDiag(t *testing.T) {
2929
ip: "test",
3030
err: diag.Diagnostics{diag.Diagnostic{Severity: 0, Summary: "invalid CIDR address: test", Detail: "", AttributePath: cty.Path(nil)}},
3131
},
32-
{
33-
name: "Missing CIDR notation (IPv4)",
34-
ip: "10.0.0.0",
35-
err: diag.Diagnostics{diag.Diagnostic{Severity: 0, Summary: "invalid CIDR address: 10.0.0.0", Detail: "", AttributePath: cty.Path(nil)}},
36-
},
37-
{
38-
name: "Missing CIDR notation (IPv6)",
39-
ip: "fe80::",
40-
err: diag.Diagnostics{diag.Diagnostic{Severity: 0, Summary: "invalid CIDR address: fe80::", Detail: "", AttributePath: cty.Path(nil)}},
41-
},
4232
{
4333
name: "Host bit set (IPv4)",
4434
ip: "10.0.0.5/8",
@@ -58,8 +48,63 @@ func TestValidateIPDiag(t *testing.T) {
5848
}
5949

6050
if test.err != nil {
61-
assert.Equal(t, err, test.err)
51+
assert.Equal(t, test.err, err)
6252
}
6353
})
6454
}
6555
}
56+
57+
func Test_normalizeIP(t *testing.T) {
58+
tests := []struct {
59+
name string
60+
input string
61+
want string
62+
}{
63+
{
64+
name: "Valid CIDR (IPv4)",
65+
input: "192.0.2.0/24",
66+
want: "192.0.2.0/24",
67+
},
68+
{
69+
name: "IP Address (IPv4)",
70+
input: "192.0.2.31",
71+
want: "192.0.2.31/32",
72+
},
73+
{
74+
name: "Valid CIDR (IPv6)",
75+
input: "2001:db8:123:4567::/64",
76+
want: "2001:db8:123:4567::/64",
77+
},
78+
{
79+
name: "Unreduced CIDR (IPv6)",
80+
input: "2001:0db8:0123:4567::0/64",
81+
want: "2001:db8:123:4567::/64",
82+
},
83+
{
84+
name: "Uppercase CIDR (IPv6)",
85+
input: "2001:DB8:123:4567::/64",
86+
want: "2001:db8:123:4567::/64",
87+
},
88+
{
89+
name: "IP Address (IPv6)",
90+
input: "2001:db8:123:4567::",
91+
want: "2001:db8:123:4567::/128",
92+
},
93+
{
94+
name: "Unreduced Matching-All CIDR (IPv6)",
95+
input: "::0/0",
96+
want: "::/0",
97+
},
98+
{
99+
name: "Badly formatted IP returns input",
100+
input: "foobar",
101+
want: "foobar",
102+
},
103+
}
104+
for _, tt := range tests {
105+
t.Run(tt.name, func(t *testing.T) {
106+
got := normalizeIP(tt.input)
107+
assert.Equalf(t, tt.want, got, "normalizeIP(%v)", tt.input)
108+
})
109+
}
110+
}

website/docs/r/firewall.html.md

+8-5
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,11 @@ resource "hcloud_server" "node1" {
5555
- `direction` - (Required, string) Direction of the Firewall Rule. `in`
5656
- `protocol` - (Required, string) Protocol of the Firewall Rule. `tcp`, `icmp`, `udp`, `gre`, `esp`
5757
- `port` - (Required, string) Port of the Firewall Rule. Required when `protocol` is `tcp` or `udp`. You can use `any`
58-
to allow all ports for the specific protocol. Port ranges are also possible: `80-85` allows all ports between 80 and
59-
85.
60-
- `source_ips` - (Required, List) List of CIDRs that are allowed within this Firewall Rule
58+
to allow all ports for the specific protocol. Port ranges are also possible: `80-85` allows all ports between 80 and 85.
59+
- `source_ips` - (Required, List) List of IPs or CIDRs that are allowed within this Firewall Rule (when `direction`
60+
is `in`)
61+
- `destination_ips` - (Required, List) List of IPs or CIDRs that are allowed within this Firewall Rule (when `direction`
62+
is `out`)
6163
- `description` - (Optional, string) Description of the firewall rule
6264

6365
`apply_to` support the following fields:
@@ -79,8 +81,9 @@ resource "hcloud_server" "node1" {
7981
- `direction` - (Required, string) Direction of the Firewall Rule. `in`, `out`
8082
- `protocol` - (Required, string) Protocol of the Firewall Rule. `tcp`, `icmp`, `udp`, `gre`, `esp`
8183
- `port` - (Required, string) Port of the Firewall Rule. Required when `protocol` is `tcp` or `udp`
82-
- `source_ips` - (Required, List) List of CIDRs that are allowed within this Firewall Rule (when `direction` is `in`)
83-
- `destination_ips` - (Required, List) List of CIDRs that are allowed within this Firewall Rule (when `direction`
84+
- `source_ips` - (Required, List) List of IPs or CIDRs that are allowed within this Firewall Rule (when `direction`
85+
is `in`)
86+
- `destination_ips` - (Required, List) List of IPs or CIDRs that are allowed within this Firewall Rule (when `direction`
8487
is `out`)
8588
- `description` - (Optional, string) Description of the firewall rule
8689

0 commit comments

Comments
 (0)