Commit 8d627c8
Changed files (7)
internal/app/model.go
@@ -9,10 +9,12 @@ import (
)
type model struct {
- stats ufw.Stats
- policy ufw.Policy
- rules []ufw.Rule
- err error
+ stats ufw.Stats
+ policy ufw.Policy
+ rules []ufw.Rule // (kept for backward compat)
+ ipv4Rules []ufw.Rule
+ ipv6Rules []ufw.Rule
+ err error
activeSection int
@@ -29,9 +31,11 @@ func InitialModel() model {
data := ufw.GetUFWData()
styles := ui.NewStyles()
return model{
- stats: data.Stats,
- rules: data.Rules,
- policy: data.Policy,
+ stats: data.Stats,
+ rules: data.Rules,
+ ipv4Rules: data.IPv4Rules,
+ ipv6Rules: data.IPv6Rules,
+ policy: data.Policy,
styles: styles,
width: 87,
internal/app/update.go
@@ -3,6 +3,7 @@ package app
import (
"ufWall/internal/keys"
"ufWall/internal/sections"
+ "ufWall/internal/sections/rules"
"ufWall/internal/ufw"
"github.com/charmbracelet/bubbles/key"
@@ -58,7 +59,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, sectionCmd
case sections.RulesSection:
- newRules, sectionCmd := m.rulesSection.Update(msg, m.rules)
+ newRules, sectionCmd := m.rulesSection.Update(msg, rules.RulesData{IPv4: m.ipv4Rules, IPv6: m.ipv6Rules})
m.rulesSection = newRules
return m, sectionCmd
}
@@ -66,6 +67,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case keys.RefreshMsg:
data := ufw.GetUFWData()
m.rules = data.Rules
+ m.ipv4Rules = data.IPv4Rules
+ m.ipv6Rules = data.IPv6Rules
m.policy = data.Policy
m.stats = data.Stats
m.err = data.Error
internal/app/view.go
@@ -2,6 +2,7 @@ package app
import (
"fmt"
+ "ufWall/internal/sections/rules"
"ufWall/internal/ui"
"github.com/charmbracelet/lipgloss"
@@ -70,7 +71,7 @@ func (m model) View() string {
content := lipgloss.JoinVertical(
lipgloss.Left,
infoSection,
- m.rulesSection.View(m.rules),
+ m.rulesSection.View(rules.RulesData{IPv4: m.ipv4Rules, IPv6: m.ipv6Rules}),
)
layout := lipgloss.JoinVertical(
@@ -98,6 +99,10 @@ func (m model) View() string {
return m.renderCenteredOverlay(m.rulesSection.DetailView(), layout)
}
+ if m.rulesSection.ShowingAddWizard() {
+ return m.renderCenteredOverlay(m.rulesSection.AddWizardView(), layout)
+ }
+
if menu := m.statsSection.GetMenu(); menu != nil {
return m.renderCenteredOverlay(menu.View(m.styles), layout)
}
internal/keys/keys.go
@@ -16,6 +16,10 @@ type KeyMap struct {
Execute key.Binding
Info key.Binding
Delete key.Binding
+ SwitchTable key.Binding
+ AddRule key.Binding
+ CustomInput key.Binding
+ Back key.Binding
}
var Bindings = KeyMap{
@@ -54,6 +58,22 @@ var Bindings = KeyMap{
Delete: key.NewBinding(
key.WithKeys("d"),
),
+
+ SwitchTable: key.NewBinding(
+ key.WithKeys("s"),
+ ),
+
+ AddRule: key.NewBinding(
+ key.WithKeys("a"),
+ ),
+
+ CustomInput: key.NewBinding(
+ key.WithKeys("c"),
+ ),
+
+ Back: key.NewBinding(
+ key.WithKeys("b", "backspace"),
+ ),
}
type RefreshMsg struct{}
internal/sections/rules/model.go
@@ -1,44 +1,163 @@
package rules
import (
+ "ufWall/internal/ufw"
"ufWall/internal/ui"
)
+type Table int
+
+const (
+ IPv4Table Table = iota
+ IPv6Table
+)
+
+type WizardStep int
+
+const (
+ StepAction WizardStep = iota
+ StepDirection
+ StepProtocol
+ StepPort
+ StepSource
+ StepDestination
+ StepInterface
+ StepConfirm
+)
+
+type AddWizard struct {
+ Step WizardStep
+ Params ufw.AddRuleParams
+ Input string
+ InputMode bool
+ Options []string
+ Cursor int
+ Interfaces []string
+ Error string
+}
+
+type MenuContext struct {
+ Action string
+ SelectedRule *ufw.Rule
+ TotalRules int
+ PendingSubmenu bool
+}
+
type Model struct {
styles ui.Styles
- totalOpts int
- cursorLine int
- showMenu bool
+ cursorLine int
menu *ui.Menu
active bool
+
+ // Two-table navigation
+ activeTable Table // Which table (IPv4 or IPv6) is currently focused
+ ipv4CursorLine int
+ ipv6CursorLine int
+
+ // Detail view overlay
+ showDetails bool
+ detailRule *ufw.Rule
+
+ // Delete confirmation
+ showDeleteConfirm bool
+ deleteRule *ufw.Rule
+
+ // Multi-step menu operations
+ menuContext *MenuContext
+ addWizard *AddWizard
}
func New(styles ui.Styles) Model {
return Model{
- styles: styles,
- cursorLine: 0,
- showMenu: false,
- menu: nil,
- active: false,
- totalOpts: 2,
+ styles: styles,
+ cursorLine: 0,
+ menu: nil,
+ active: false,
+ activeTable: IPv4Table,
+ ipv4CursorLine: 0,
+ ipv6CursorLine: 0,
+ showDetails: false,
+ detailRule: nil,
+ showDeleteConfirm: false,
+ deleteRule: nil,
+ menuContext: nil,
+ addWizard: nil,
}
}
func (m *Model) Focus() {
- m.cursorLine = 0
m.active = true
}
func (m *Model) Blur() {
m.active = false
- m.showMenu = false
- m.cursorLine = 0
+ m.menu = nil
+ m.showDetails = false
+ m.showDeleteConfirm = false
+ m.menuContext = nil
+ m.addWizard = nil
}
func (m Model) HasOpenMenu() bool {
- return m.menu != nil
+ return m.menu != nil || m.showDetails || m.showDeleteConfirm || m.addWizard != nil
}
func (m Model) GetMenu() *ui.Menu {
return m.menu
}
+
+func (m Model) ShowingDetails() bool {
+ return m.showDetails
+}
+
+func (m Model) GetDetailRule() *ufw.Rule {
+ return m.detailRule
+}
+
+func (m Model) ShowingDeleteConfirm() bool {
+ return m.showDeleteConfirm
+}
+
+func (m Model) GetDeleteRule() *ufw.Rule {
+ return m.deleteRule
+}
+
+func (m Model) ActiveTable() Table {
+ return m.activeTable
+}
+
+func (m Model) IPv4CursorLine() int {
+ return m.ipv4CursorLine
+}
+
+func (m Model) IPv6CursorLine() int {
+ return m.ipv6CursorLine
+}
+
+func (m Model) CurrentCursorLine() int {
+ if m.activeTable == IPv6Table {
+ return m.ipv6CursorLine
+ }
+ return m.ipv4CursorLine
+}
+
+func (m Model) ShowingAddWizard() bool {
+ return m.addWizard != nil
+}
+
+func (m Model) GetAddWizard() *AddWizard {
+ return m.addWizard
+}
+
+func NewAddWizard() *AddWizard {
+ return &AddWizard{
+ Step: StepAction,
+ Params: ufw.AddRuleParams{},
+ Input: "",
+ InputMode: false,
+ Options: ufw.Actions,
+ Cursor: 0,
+ Interfaces: ufw.GetInterfaces(),
+ Error: "",
+ }
+}
internal/ufw/main.go
@@ -3,6 +3,8 @@ package ufw
import (
"bytes"
"fmt"
+ "log"
+ "net"
"os/exec"
"regexp"
"strings"
@@ -23,6 +25,7 @@ type Policy struct {
type Rule struct {
Num int
Action string
+ IPv6 bool
ToDest string
ToPort string
@@ -36,17 +39,28 @@ type Rule struct {
}
type ufwData struct {
- Stats Stats
- Policy Policy
- Rules []Rule
- Error error
+ Stats Stats
+ Policy Policy
+ Rules []Rule
+ IPv4Rules []Rule
+ IPv6Rules []Rule
+ Error error
}
+// UFW action constants
+const (
+ ActionAllow = "ALLOW"
+ ActionDeny = "DENY"
+ ActionReject = "REJECT"
+ ActionLimit = "LIMIT"
+)
+
+var Actions = []string{ActionAllow, ActionDeny, ActionReject, ActionLimit}
+
var (
- reStatus = regexp.MustCompile(`(?i)Status:\s*(\w+)`)
- reDefault = regexp.MustCompile(`(?im)^Default:\s*(.+)$`)
- reLogging = regexp.MustCompile(`(?im)^Logging:\s*(.+)$`)
- reRuleLine = regexp.MustCompile(`^\s*\[\s*(\d+)\]\s+(.+)$`)
+ reStatus = regexp.MustCompile(`(?i)Status:\s*(\w+)`)
+ reDefault = regexp.MustCompile(`(?im)^Default:\s*(.+)$`)
+ reLogging = regexp.MustCompile(`(?im)^Logging:\s*(.+)$`)
)
func RunCmd(name string, args ...string) (stdout, stderr string, err error) {
@@ -108,6 +122,14 @@ func GetUFWData() ufwData {
data.Rules = ParseRules(numOut)
data.Stats.TotalRules = len(data.Rules)
+ for _, rule := range data.Rules {
+ if rule.IPv6 {
+ data.IPv6Rules = append(data.IPv6Rules, rule)
+ } else {
+ data.IPv4Rules = append(data.IPv4Rules, rule)
+ }
+ }
+
return data
}
@@ -158,3 +180,310 @@ func AddRule(rule string) (stdout, stderr string, err error) {
}
return RunSudo(append([]string{}, parts...)...)
}
+
+func InsertRule(position int, args ...string) (stdout, stderr string, err error) {
+ cmdArgs := append([]string{"insert", fmt.Sprintf("%d", position)}, args...)
+ log.Printf("InsertRule cmdArgs: %v", cmdArgs)
+ return RunSudo(cmdArgs...)
+}
+
+// normalizeAddress cleans up UFW display addresses for use in commands
+// "Anywhere (v6)" -> "" (let UFW handle it)
+// "Anywhere" -> "" (let UFW handle it)
+// "192.168.1.0/24" -> "192.168.1.0/24"
+func normalizeAddress(addr string) string {
+ addr = strings.TrimSpace(addr)
+ addr = strings.TrimSuffix(addr, " (v6)")
+ if addr == "Anywhere" || addr == "" {
+ return ""
+ }
+ return addr
+}
+
+// normalizePort cleans up UFW display port values for use in commands
+// "80 (v6)" -> "80"
+// "22/tcp (v6)" -> "22/tcp"
+func normalizePort(port string) string {
+ port = strings.TrimSpace(port)
+ port = strings.TrimSuffix(port, " (v6)")
+ return port
+}
+
+func InsertRuleFromExisting(position int, action string, rule Rule) (stdout, stderr string, err error) {
+ args := buildRuleArgs(action, rule)
+
+ log.Printf("InsertRuleFromExisting: position=%d, action=%s, ipv6=%v, args=%v", position, action, rule.IPv6, args)
+
+ if position <= 0 {
+ return AppendRule(args...)
+ }
+ return InsertRule(position, args...)
+}
+
+func AppendRule(args ...string) (stdout, stderr string, err error) {
+ log.Printf("AppendRule args: %v", args)
+ return RunSudo(args...)
+}
+
+func buildRuleArgs(action string, rule Rule) []string {
+ var args []string
+ args = append(args, strings.ToLower(action))
+
+ toDest := normalizeAddress(rule.ToDest)
+ toPort := normalizePort(rule.ToPort)
+ toProto := rule.ToProtocol
+
+ fromSource := normalizeAddress(rule.FromSource)
+ fromPort := normalizePort(rule.FromPort)
+
+ // For IPv6 rules, we need to use specific addresses or "any" will default to IPv4
+ // UFW creates IPv6 rules when using IPv6 addresses or when the original rule was IPv6
+ // We use "::/0" for IPv6 "anywhere" equivalent
+ anyAddr := "any"
+ if rule.IPv6 {
+ anyAddr = "::/0" // IPv6 equivalent of "anywhere"
+ }
+
+ if fromSource != "" {
+ args = append(args, "from", fromSource)
+ } else {
+ args = append(args, "from", anyAddr)
+ }
+
+ if toDest != "" {
+ args = append(args, "to", toDest)
+ } else {
+ args = append(args, "to", anyAddr)
+ }
+
+ if toPort != "" && toPort != "any" {
+ args = append(args, "port", toPort)
+ }
+
+ if toProto != "" && toProto != "any" {
+ args = append(args, "proto", strings.ToLower(toProto))
+ }
+
+ if fromPort != "" && fromPort != "any" {
+ args = append(args, "sport", fromPort)
+ }
+
+ return args
+}
+
+func GetCurrentRules() []Rule {
+ numOut, _, err := RunSudo("status", "numbered")
+ if err != nil {
+ log.Printf("Error fetching rules: %v", err)
+ return nil
+ }
+ return ParseRules(numOut)
+}
+
+func FindInsertPosition(rules []Rule, prevNextNum int, isIPv6 bool) int {
+ if prevNextNum <= 0 {
+ return 0
+ }
+
+ for _, r := range rules {
+ if r.IPv6 == isIPv6 && r.Num >= 1 {
+ return r.Num
+ }
+ }
+ return 0
+}
+
+func MoveRule(rule Rule, direction int, newAction string) error {
+ originalNum := rule.Num
+ isIPv6 := rule.IPv6
+
+ log.Printf("MoveRule: rule #%d, direction=%d, newAction=%s, isIPv6=%v", originalNum, direction, newAction, isIPv6)
+
+ // Step 1: Get current rules to understand the landscape
+ currentRules := GetCurrentRules()
+ if currentRules == nil {
+ return fmt.Errorf("failed to fetch current rules")
+ }
+
+ // Find our rule's neighbors of the same IP version
+ var sameTypeRules []Rule
+ for _, r := range currentRules {
+ if r.IPv6 == isIPv6 {
+ sameTypeRules = append(sameTypeRules, r)
+ }
+ }
+
+ // Find index of our rule in the same-type list
+ ourIndex := -1
+ for i, r := range sameTypeRules {
+ if r.Num == originalNum {
+ ourIndex = i
+ break
+ }
+ }
+
+ if ourIndex == -1 {
+ return fmt.Errorf("rule #%d not found in current rules", originalNum)
+ }
+
+ // Calculate target index in same-type list
+ targetIndex := max(ourIndex + direction, 0)
+ if targetIndex >= len(sameTypeRules) {
+ targetIndex = len(sameTypeRules) - 1
+ }
+
+ // Step 2: Delete the rule
+ _, stderr, err := DeleteRule(originalNum)
+ if err != nil {
+ return fmt.Errorf("failed to delete rule: %s", stderr)
+ }
+
+ // Step 3: Re-fetch rules to get accurate positions
+ newRules := GetCurrentRules()
+ if newRules == nil {
+ return fmt.Errorf("failed to fetch rules after delete")
+ }
+
+ // Find same-type rules again after deletion
+ var newSameTypeRules []Rule
+ for _, r := range newRules {
+ if r.IPv6 == isIPv6 {
+ newSameTypeRules = append(newSameTypeRules, r)
+ }
+ }
+
+ // Step 4: Calculate actual insert position
+ var insertPos int
+ if len(newSameTypeRules) == 0 {
+ insertPos = 0
+ } else if targetIndex >= len(newSameTypeRules) {
+ insertPos = 0
+ } else {
+ insertPos = newSameTypeRules[targetIndex].Num
+ }
+
+ log.Printf("MoveRule: targetIndex=%d, insertPos=%d, newSameTypeRules=%d", targetIndex, insertPos, len(newSameTypeRules))
+
+ // Step 5: Validate insert position
+ // UFW insert position must be between 1 and (total_rules + 1)
+ totalRulesAfterDelete := len(newRules)
+ if insertPos > totalRulesAfterDelete+1 {
+ log.Printf("MoveRule: insertPos %d exceeds valid range (1-%d), appending instead", insertPos, totalRulesAfterDelete+1)
+ insertPos = 0 // Signal to append
+ }
+
+ // Step 6: Insert the rule at the calculated position
+ _, stderr, err = InsertRuleFromExisting(insertPos, newAction, rule)
+ if err != nil {
+ return fmt.Errorf("failed to insert rule (pos=%d, total=%d): %s", insertPos, totalRulesAfterDelete, stderr)
+ }
+
+ return nil
+}
+
+func GetInterfaces() []string {
+ interfaces, err := net.Interfaces()
+ if err != nil {
+ log.Printf("Error getting interfaces: %v", err)
+ return []string{}
+ }
+
+ var names []string
+ for _, iface := range interfaces {
+ if iface.Flags&net.FlagLoopback != 0 {
+ continue
+ }
+ names = append(names, iface.Name)
+ }
+ return names
+}
+
+const (
+ DirectionIn = "in"
+ DirectionOut = "out"
+)
+
+const (
+ ProtoTCP = "tcp"
+ ProtoUDP = "udp"
+ ProtoAny = "any"
+)
+
+var Directions = []string{DirectionIn, DirectionOut}
+var Protocols = []string{ProtoAny, ProtoTCP, ProtoUDP}
+var CommonPorts = []struct {
+ Port string
+ Name string
+}{
+ {"22", "SSH"},
+ {"80", "HTTP"},
+ {"443", "HTTPS"},
+ {"53", "DNS"},
+ {"5432", "PostgreSQL"},
+ {"6379", "Redis"},
+ {"3000", "Dev Server"},
+}
+
+type AddRuleParams struct {
+ Action string // allow, deny, reject, limit
+ Direction string // in, out, or empty for both
+ Protocol string // tcp, udp, any
+ Port string // port number or range
+ FromAddr string // source address or empty for any
+ ToAddr string // destination address or empty for any
+ Interface string // network interface or empty for all
+ Comment string // optional comment
+}
+
+func BuildAddRuleCommand(p AddRuleParams) []string {
+ var args []string
+
+ args = append(args, strings.ToLower(p.Action))
+
+ if p.Direction != "" {
+ args = append(args, p.Direction)
+ }
+
+ if p.Interface != "" {
+ args = append(args, "on", p.Interface)
+ }
+
+ if p.Port != "" {
+ if p.FromAddr != "" && p.FromAddr != "any" {
+ args = append(args, "from", p.FromAddr)
+ } else {
+ args = append(args, "from", "any")
+ }
+
+ if p.ToAddr != "" && p.ToAddr != "any" {
+ args = append(args, "to", p.ToAddr)
+ } else {
+ args = append(args, "to", "any")
+ }
+
+ args = append(args, "port", p.Port)
+
+ if p.Protocol != "" && p.Protocol != ProtoAny {
+ args = append(args, "proto", p.Protocol)
+ }
+ } else {
+ if p.FromAddr != "" && p.FromAddr != "any" {
+ args = append(args, "from", p.FromAddr)
+ }
+ if p.ToAddr != "" && p.ToAddr != "any" {
+ args = append(args, "to", p.ToAddr)
+ }
+ }
+
+ if p.Comment != "" {
+ args = append(args, "comment", p.Comment)
+ }
+
+ return args
+}
+
+func AddNewRule(p AddRuleParams) (stdout, stderr string, err error) {
+ args := BuildAddRuleCommand(p)
+ log.Printf("AddNewRule: %v", args)
+ return RunSudo(args...)
+}