Skip to content

Commit aa27683

Browse files
author
Jesus Carrillo
committed
firewall: reconcile NodeBalancer firewall rules on port changes
Previously, updateNodeBalancerFirewallWithACL only considered ACL IP annotation changes and never updated firewall rules when Service ports were added or removed—leading to stale or unintended open ports. This commit: - Rebuilds the complete desired rule set (Service.Spec.Ports + ACL) - Compares existing inbound rules vs. desired - Extends ruleChanged to detect both IP and port modifications - Adds unit tests
1 parent bc67c49 commit aa27683

2 files changed

Lines changed: 237 additions & 3 deletions

File tree

cloud/linode/firewall/firewalls.go

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const (
2424
maxRulesPerFirewall = 25
2525
accept = "ACCEPT"
2626
drop = "DROP"
27+
portRangeParts = 2
2728
)
2829

2930
var (
@@ -130,9 +131,88 @@ func ipsChanged(ips *linodego.NetworkAddresses, rules []linodego.FirewallRule) b
130131
return false
131132
}
132133

134+
func parsePorts(ports string) ([]int32, error) {
135+
var result []int32
136+
portParts := strings.Split(ports, ",")
137+
for _, part := range portParts {
138+
part = strings.TrimSpace(part)
139+
if strings.Contains(part, "-") {
140+
rangeParts := strings.Split(part, "-")
141+
if len(rangeParts) != portRangeParts {
142+
return nil, fmt.Errorf("invalid range format: %s", part)
143+
}
144+
145+
start, err1 := strconv.ParseInt(rangeParts[0], 10, 32)
146+
end, err2 := strconv.ParseInt(rangeParts[1], 10, 32)
147+
if err1 != nil || err2 != nil {
148+
return nil, fmt.Errorf("invalid number in range: %s", part)
149+
}
150+
if start > end {
151+
return nil, fmt.Errorf("start greater than end in range: %s", part)
152+
}
153+
154+
for i := start; i <= end; i++ {
155+
result = append(result, int32(i))
156+
}
157+
} else {
158+
port, err := strconv.ParseInt(part, 10, 32)
159+
if err != nil {
160+
return nil, fmt.Errorf("invalid port: %s", part)
161+
}
162+
result = append(result, int32(port))
163+
}
164+
}
165+
166+
return result, nil
167+
}
168+
169+
func isPortsChanged(rules []linodego.FirewallRule, service *v1.Service) bool {
170+
// Service has at least one port, so we can check if there are any rules in firewall
171+
// We only care about the first rule, as all rules should have same ports
172+
if len(rules) == 0 {
173+
return true
174+
}
175+
oldPorts := rules[0].Ports
176+
if oldPorts == "" {
177+
return true
178+
}
179+
// Split the old ports by comma and convert to a slice of strings
180+
oldPortsSlice, err := parsePorts(oldPorts)
181+
if err != nil {
182+
klog.Errorf("Error parsing old ports: %v", err)
183+
return true
184+
}
185+
// Create a map to store the old ports for easy lookup
186+
oldPortsMap := make(map[int32]struct{}, len(oldPortsSlice))
187+
for _, port := range oldPortsSlice {
188+
oldPortsMap[port] = struct{}{}
189+
}
190+
svcPortsMap := make(map[int32]struct{}, len(service.Spec.Ports))
191+
for _, port := range service.Spec.Ports {
192+
svcPortsMap[port.Port] = struct{}{}
193+
}
194+
195+
// Check if the ports in the service are different from the old ports
196+
for _, port := range service.Spec.Ports {
197+
if _, ok := oldPortsMap[port.Port]; !ok {
198+
return true
199+
}
200+
}
201+
202+
// Check if there are any old ports that are not in the service
203+
for _, port := range oldPortsSlice {
204+
if _, ok := svcPortsMap[port]; !ok {
205+
return true
206+
}
207+
}
208+
209+
// If all ports match, return false
210+
return false
211+
}
212+
133213
// ruleChanged takes an old FirewallRuleSet and new aclConfig and returns if
134214
// the IPs of the FirewallRuleSet would be changed with the new ACL Config
135-
func ruleChanged(old linodego.FirewallRuleSet, newACL aclConfig) bool {
215+
func ruleChanged(old linodego.FirewallRuleSet, newACL aclConfig, service *v1.Service) bool {
136216
var ips *linodego.NetworkAddresses
137217
if newACL.AllowList != nil {
138218
// this is a allowList, this means that the rules should have `DROP` as inboundpolicy
@@ -156,7 +236,7 @@ func ruleChanged(old linodego.FirewallRuleSet, newACL aclConfig) bool {
156236
ips = newACL.DenyList
157237
}
158238

159-
return ipsChanged(ips, old.Inbound)
239+
return ipsChanged(ips, old.Inbound) || isPortsChanged(old.Inbound, service)
160240
}
161241

162242
func chunkIPs(ips []string) [][]string {
@@ -463,7 +543,7 @@ func (l *LinodeClient) updateNodeBalancerFirewallWithACL(
463543
return err
464544
}
465545

466-
changed := ruleChanged(firewalls[0].Rules, acl)
546+
changed := ruleChanged(firewalls[0].Rules, acl, service)
467547
if !changed {
468548
return nil
469549
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package firewall
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
7+
"github.com/linode/linodego"
8+
v1 "k8s.io/api/core/v1"
9+
)
10+
11+
// makeOldRuleSet constructs a FirewallRuleSet with the given IPs, ports string, and policy.
12+
func makeOldRuleSet(ipList []string, ports string, policy string) linodego.FirewallRuleSet {
13+
// NetworkAddresses for the rule
14+
ips := linodego.NetworkAddresses{IPv4: &ipList}
15+
// Single inbound rule with these ports and addresses
16+
rule := linodego.FirewallRule{
17+
Protocol: "TCP",
18+
Ports: ports,
19+
Addresses: ips,
20+
}
21+
return linodego.FirewallRuleSet{
22+
InboundPolicy: policy,
23+
Inbound: []linodego.FirewallRule{rule},
24+
}
25+
}
26+
27+
func TestRuleChanged_NoChange(t *testing.T) {
28+
old := makeOldRuleSet(
29+
[]string{"1.2.3.4/32"},
30+
"80,8080",
31+
drop,
32+
)
33+
newACL := aclConfig{AllowList: &linodego.NetworkAddresses{IPv4: &[]string{"1.2.3.4/32"}}}
34+
svc := &v1.Service{Spec: v1.ServiceSpec{Ports: []v1.ServicePort{{Port: 80}, {Port: 8080}}}}
35+
36+
if changed := ruleChanged(old, newACL, svc); changed {
37+
t.Errorf("expected no change, but ruleChanged returned true")
38+
}
39+
}
40+
41+
func TestRuleChanged_IPChange(t *testing.T) {
42+
old := makeOldRuleSet(
43+
[]string{"1.2.3.4/32"},
44+
"80",
45+
drop,
46+
)
47+
// Change in IP
48+
newACL := aclConfig{AllowList: &linodego.NetworkAddresses{IPv4: &[]string{"5.6.7.8/32"}}}
49+
svc := &v1.Service{Spec: v1.ServiceSpec{Ports: []v1.ServicePort{{Port: 80}}}}
50+
51+
if !ruleChanged(old, newACL, svc) {
52+
t.Errorf("expected change due to IP modification, but ruleChanged returned false")
53+
}
54+
}
55+
56+
func TestRuleChanged_PortChange(t *testing.T) {
57+
old := makeOldRuleSet(
58+
[]string{"1.2.3.4/32"},
59+
"80",
60+
drop,
61+
)
62+
// Ports updated on the Service
63+
newACL := aclConfig{AllowList: &linodego.NetworkAddresses{IPv4: &[]string{"1.2.3.4/32"}}}
64+
svc := &v1.Service{Spec: v1.ServiceSpec{Ports: []v1.ServicePort{{Port: 80}, {Port: 8080}}}}
65+
66+
if !ruleChanged(old, newACL, svc) {
67+
t.Errorf("expected change due to port modification, but ruleChanged returned false")
68+
}
69+
}
70+
71+
func TestParsePorts_ValidSingle(t *testing.T) {
72+
input := "80"
73+
exp := []int32{80}
74+
out, err := parsePorts(input)
75+
if err != nil {
76+
t.Fatalf("unexpected error: %v", err)
77+
}
78+
if !reflect.DeepEqual(out, exp) {
79+
t.Errorf("got %v, want %v", out, exp)
80+
}
81+
}
82+
83+
func TestParsePorts_ValidMultiple(t *testing.T) {
84+
input := "80,443"
85+
exp := []int32{80, 443}
86+
out, err := parsePorts(input)
87+
if err != nil {
88+
t.Fatalf("unexpected error: %v", err)
89+
}
90+
if !reflect.DeepEqual(out, exp) {
91+
t.Errorf("got %v, want %v", out, exp)
92+
}
93+
}
94+
95+
func TestParsePorts_ValidRange(t *testing.T) {
96+
input := "100-102"
97+
exp := []int32{100, 101, 102}
98+
out, err := parsePorts(input)
99+
if err != nil {
100+
t.Fatalf("unexpected error: %v", err)
101+
}
102+
if !reflect.DeepEqual(out, exp) {
103+
t.Errorf("got %v, want %v", out, exp)
104+
}
105+
}
106+
107+
func TestParsePorts_Combined(t *testing.T) {
108+
input := "80,100-102,8080"
109+
exp := []int32{80, 100, 101, 102, 8080}
110+
out, err := parsePorts(input)
111+
if err != nil {
112+
t.Fatalf("unexpected error: %v", err)
113+
}
114+
if !reflect.DeepEqual(out, exp) {
115+
t.Errorf("got %v, want %v", out, exp)
116+
}
117+
}
118+
119+
func TestParsePorts_Spaces(t *testing.T) {
120+
input := " 80 , 443-445 "
121+
exp := []int32{80, 443, 444, 445}
122+
out, err := parsePorts(input)
123+
if err != nil {
124+
t.Fatalf("unexpected error: %v", err)
125+
}
126+
if !reflect.DeepEqual(out, exp) {
127+
t.Errorf("got %v, want %v", out, exp)
128+
}
129+
}
130+
131+
func TestParsePorts_InvalidRangeFormat(t *testing.T) {
132+
cases := []string{"1-2-3", "100-"}
133+
for _, input := range cases {
134+
if _, err := parsePorts(input); err == nil {
135+
t.Errorf("expected error for invalid range '%s', got nil", input)
136+
}
137+
}
138+
}
139+
140+
func TestParsePorts_NonNumeric(t *testing.T) {
141+
cases := []string{"abc", "80,a", "a-100"}
142+
for _, input := range cases {
143+
if _, err := parsePorts(input); err == nil {
144+
t.Errorf("expected error for non-numeric '%s', got nil", input)
145+
}
146+
}
147+
}
148+
149+
func TestParsePorts_RangeStartGreaterThanEnd(t *testing.T) {
150+
input := "200-100"
151+
if _, err := parsePorts(input); err == nil {
152+
t.Errorf("expected error for start greater than end, got nil")
153+
}
154+
}

0 commit comments

Comments
 (0)