Commit 8d627c8

Ansari <ping@ansari.wtf>
2026-03-02 22:14:45
ipv4 and ipv6 seprate
1 parent af6e956
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...)
+}
internal/ui/footer.go
@@ -21,6 +21,6 @@ func Footer(styles Styles, activeSection int, width int) string {
 	}
 
 	keys = append(keys, "q: quit")
-	footerText := strings.Join(keys, "  •  ")
+	footerText := strings.Join(keys, "  |  ")
 	return styles.Footer.Width(width).Render(footerText)
 }