Skip to content

Commit 022453c

Browse files
committed
feat(server): add flexible filtering to firewall rule enable/disable commands
Extends firewall rule enable/disable commands with multiple filter options beyond just position-based selection, addressing feedback on issue #244. The same filter options used when creating firewall rules (--direction, --protocol, --dest-port, --src-address, --comment) can now be used to select which existing rules to enable or disable. Filter options (can be combined): - --comment: Match rules by comment text (partial, case-insensitive) - --direction: Filter by direction (in/out) - --protocol: Filter by protocol (tcp/udp/icmp) - --dest-port: Match destination port - --src-address: Match source IP address (partial) - --position: Match exact position (mutually exclusive with others) Multiple filters use AND logic to narrow results. For example: upctl server firewall rule enable <uuid> --comment "Dev" --direction in --protocol tcp Safety features: - --skip-confirmation N: Set max rules to modify without confirmation - Default: confirms if modifying >1 rule - Lists all matching rules before requiring confirmation Examples prioritize comment-based selection over position, as position-based selection is fragile when rules are reordered. Comments provide stable, human-readable rule identification. Resolves: #244
1 parent 08f34be commit 022453c

4 files changed

Lines changed: 258 additions & 30 deletions

File tree

internal/commands/server/firewall/rule_disable.go

Lines changed: 127 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package serverfirewall
22

33
import (
44
"fmt"
5+
"strings"
56

67
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
78
"github.com/UpCloudLtd/upcloud-cli/v3/internal/completion"
@@ -15,7 +16,13 @@ import (
1516

1617
type ruleDisableCommand struct {
1718
*commands.BaseCommand
18-
rulePosition int
19+
rulePosition int
20+
ruleComment string
21+
ruleDirection string
22+
ruleProtocol string
23+
ruleDestPort string
24+
ruleSrcAddress string
25+
skipConfirmation int
1926
completion.Server
2027
resolver.CachingServer
2128
}
@@ -25,29 +32,58 @@ func RuleDisableCommand() commands.Command {
2532
return &ruleDisableCommand{
2633
BaseCommand: commands.New(
2734
"disable",
28-
"Disable a specific firewall rule by changing its action to drop",
35+
"Disable firewall rules by changing their action to drop",
36+
"upctl server firewall rule disable 00038afc-d526-4148-af0e-d2f1eeaded9b --comment \"Dev ports\"",
37+
"upctl server firewall rule disable 00038afc-d526-4148-af0e-d2f1eeaded9b --direction in --protocol tcp --dest-port 8080",
38+
"upctl server firewall rule disable 00038afc-d526-4148-af0e-d2f1eeaded9b --comment \"Test\" --direction in --skip-confirmation 10",
2939
"upctl server firewall rule disable 00038afc-d526-4148-af0e-d2f1eeaded9b --position 5",
3040
),
41+
skipConfirmation: 1,
3142
}
3243
}
3344

3445
// InitCommand implements Command.InitCommand
3546
func (s *ruleDisableCommand) InitCommand() {
47+
directions := []string{upcloud.FirewallRuleDirectionIn, upcloud.FirewallRuleDirectionOut}
48+
protocols := []string{upcloud.FirewallRuleProtocolTCP, upcloud.FirewallRuleProtocolUDP, upcloud.FirewallRuleProtocolICMP}
49+
3650
flagSet := &pflag.FlagSet{}
3751
flagSet.IntVar(&s.rulePosition, "position", 0, "Rule position. Available: 1-1000")
52+
flagSet.StringVar(&s.ruleComment, "comment", "", "Filter by comment (partial match, case-insensitive)")
53+
flagSet.StringVar(&s.ruleDirection, "direction", "", "Filter by direction. Available: "+strings.Join(directions, ", "))
54+
flagSet.StringVar(&s.ruleProtocol, "protocol", "", "Filter by protocol. Available: "+strings.Join(protocols, ", "))
55+
flagSet.StringVar(&s.ruleDestPort, "dest-port", "", "Filter by destination port (matches both start and end)")
56+
flagSet.StringVar(&s.ruleSrcAddress, "src-address", "", "Filter by source address (partial match)")
57+
flagSet.IntVar(&s.skipConfirmation, "skip-confirmation", 1, "Maximum rules to modify without confirmation (0 = always confirm)")
3858
s.AddFlags(flagSet)
3959

40-
commands.Must(s.Cobra().MarkFlagRequired("position"))
60+
s.Cobra().MarkFlagsMutuallyExclusive("position", "comment")
61+
s.Cobra().MarkFlagsMutuallyExclusive("position", "direction")
62+
s.Cobra().MarkFlagsMutuallyExclusive("position", "protocol")
63+
s.Cobra().MarkFlagsMutuallyExclusive("position", "dest-port")
64+
s.Cobra().MarkFlagsMutuallyExclusive("position", "src-address")
4165
commands.Must(s.Cobra().RegisterFlagCompletionFunc("position", cobra.NoFileCompletions))
66+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("comment", cobra.NoFileCompletions))
67+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("direction", cobra.FixedCompletions(directions, cobra.ShellCompDirectiveNoFileComp)))
68+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("protocol", cobra.FixedCompletions(protocols, cobra.ShellCompDirectiveNoFileComp)))
69+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("dest-port", cobra.NoFileCompletions))
70+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("src-address", cobra.NoFileCompletions))
71+
commands.Must(s.Cobra().RegisterFlagCompletionFunc("skip-confirmation", cobra.NoFileCompletions))
4272
}
4373

4474
// Execute implements commands.MultipleArgumentCommand
4575
func (s *ruleDisableCommand) Execute(exec commands.Executor, arg string) (output.Output, error) {
46-
if s.rulePosition < 1 || s.rulePosition > 1000 {
76+
// Validation
77+
hasFilters := s.rulePosition != 0 || s.ruleComment != "" || s.ruleDirection != "" ||
78+
s.ruleProtocol != "" || s.ruleDestPort != "" || s.ruleSrcAddress != ""
79+
if !hasFilters {
80+
return nil, fmt.Errorf("at least one filter must be specified (--comment, --direction, --protocol, --dest-port, --src-address, or --position)")
81+
}
82+
if s.rulePosition != 0 && (s.rulePosition < 1 || s.rulePosition > 1000) {
4783
return nil, fmt.Errorf("invalid position (1-1000 allowed)")
4884
}
4985

50-
msg := fmt.Sprintf("Disabling firewall rule %d on server %v", s.rulePosition, arg)
86+
msg := fmt.Sprintf("Disabling firewall rules on server %v", arg)
5187
exec.PushProgressStarted(msg)
5288

5389
// Fetch current firewall rules
@@ -58,18 +94,95 @@ func (s *ruleDisableCommand) Execute(exec commands.Executor, arg string) (output
5894
return commands.HandleError(exec, msg, err)
5995
}
6096

61-
// Find and modify the target rule
62-
ruleFound := false
97+
// Find matching rules
98+
var matchedIndices []int
6399
for i := range currentRules.FirewallRules {
64-
if currentRules.FirewallRules[i].Position == s.rulePosition {
65-
currentRules.FirewallRules[i].Action = upcloud.FirewallRuleActionDrop
66-
ruleFound = true
67-
break
100+
rule := &currentRules.FirewallRules[i]
101+
102+
// Position-based filter (exact match, exclusive)
103+
if s.rulePosition != 0 {
104+
if rule.Position == s.rulePosition {
105+
matchedIndices = append(matchedIndices, i)
106+
}
107+
continue
108+
}
109+
110+
// Apply all specified filters (AND logic)
111+
match := true
112+
113+
if s.ruleComment != "" {
114+
if !strings.Contains(strings.ToLower(rule.Comment), strings.ToLower(s.ruleComment)) {
115+
match = false
116+
}
117+
}
118+
119+
if s.ruleDirection != "" {
120+
if !strings.EqualFold(rule.Direction, s.ruleDirection) {
121+
match = false
122+
}
123+
}
124+
125+
if s.ruleProtocol != "" {
126+
if !strings.EqualFold(rule.Protocol, s.ruleProtocol) {
127+
match = false
128+
}
129+
}
130+
131+
if s.ruleDestPort != "" {
132+
// Match if either start or end matches the specified port
133+
if rule.DestinationPortStart != s.ruleDestPort && rule.DestinationPortEnd != s.ruleDestPort {
134+
match = false
135+
}
136+
}
137+
138+
if s.ruleSrcAddress != "" {
139+
// Partial match on either start or end address
140+
addrLower := strings.ToLower(s.ruleSrcAddress)
141+
if !strings.Contains(strings.ToLower(rule.SourceAddressStart), addrLower) &&
142+
!strings.Contains(strings.ToLower(rule.SourceAddressEnd), addrLower) {
143+
match = false
144+
}
145+
}
146+
147+
if match {
148+
matchedIndices = append(matchedIndices, i)
149+
}
150+
}
151+
152+
if len(matchedIndices) == 0 {
153+
if s.rulePosition != 0 {
154+
return nil, fmt.Errorf("firewall rule at position %d not found on server %s", s.rulePosition, arg)
155+
}
156+
return nil, fmt.Errorf("no firewall rules matching the specified filters found on server %s", arg)
157+
}
158+
159+
// Confirmation check
160+
if len(matchedIndices) > s.skipConfirmation {
161+
var ruleDescriptions []string
162+
for _, idx := range matchedIndices {
163+
rule := currentRules.FirewallRules[idx]
164+
desc := fmt.Sprintf(" - Position %d: %s %s", rule.Position, rule.Direction, rule.Protocol)
165+
if rule.Comment != "" {
166+
desc += fmt.Sprintf(" (comment: %q)", rule.Comment)
167+
}
168+
ruleDescriptions = append(ruleDescriptions, desc)
169+
}
170+
171+
return nil, fmt.Errorf("would disable %d rules (exceeds skip-confirmation=%d). Matching rules:\n%s\n\nIncrease --skip-confirmation to proceed",
172+
len(matchedIndices), s.skipConfirmation, strings.Join(ruleDescriptions, "\n"))
173+
}
174+
175+
// Modify matched rules
176+
modifiedCount := 0
177+
for _, idx := range matchedIndices {
178+
if currentRules.FirewallRules[idx].Action != upcloud.FirewallRuleActionDrop {
179+
currentRules.FirewallRules[idx].Action = upcloud.FirewallRuleActionDrop
180+
modifiedCount++
68181
}
69182
}
70183

71-
if !ruleFound {
72-
return nil, fmt.Errorf("firewall rule at position %d not found on server %s", s.rulePosition, arg)
184+
if modifiedCount == 0 {
185+
return nil, fmt.Errorf("all %d matching rules already disabled", len(matchedIndices))
73186
}
74187

75188
// Replace entire ruleset atomically
@@ -81,6 +194,7 @@ func (s *ruleDisableCommand) Execute(exec commands.Executor, arg string) (output
81194
return commands.HandleError(exec, msg, err)
82195
}
83196

197+
msg = fmt.Sprintf("Disabled %d firewall rule(s) on server %v", modifiedCount, arg)
84198
exec.PushProgressSuccess(msg)
85199

86200
return output.None{}, nil

internal/commands/server/firewall/rule_disable_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,13 @@ func TestRuleDisableCommand(t *testing.T) {
5151
name: "Missing position flag",
5252
flags: []string{},
5353
arg: serverUUID,
54-
expectedErr: `required flag(s) "position" not set`,
54+
expectedErr: "at least one filter must be specified",
5555
},
5656
{
5757
name: "Invalid position - too low",
5858
flags: []string{"--position", "0"},
5959
arg: serverUUID,
60-
expectedErr: "invalid position (1-1000 allowed)",
60+
expectedErr: "at least one filter must be specified",
6161
},
6262
{
6363
name: "Invalid position - too high",

0 commit comments

Comments
 (0)