Commit 7057e85

Ansari <ping@ansari.wtf>
2026-02-25 10:18:22
Basic layout formed
1 parent 90ad4ec
internal/app/model.go
@@ -4,16 +4,23 @@ import "ufWall/internal/ufw"
 
 type model struct {
 	status ufw.UFWStatus
+	rules  []ufw.Rule
+
+	activeSection int
+	selectedRule int
+
 	styles Styles
 	width  int
 	height int
 }
 
 func InitialModel() model {
+	s,r := ufw.GetStatus()
 	return model{
-		status: ufw.GetStatus(),
+		status: s,
+		rules: r,
 		styles: NewStyles(),
-		width:  80,
-		height: 24,
+		width:   87,
+		height: 30,	
 	}
 }
internal/app/sections.go
@@ -2,6 +2,7 @@ package app
 
 import (
 	"fmt"
+	"strconv"
 	"strings"
 
 	"github.com/charmbracelet/lipgloss"
@@ -31,20 +32,20 @@ func (m model) renderStatusSection() string {
 		m.styles.Value.Render(strings.ToUpper(m.status.Logging)),
 	)
 
-	uptimeLine := lipgloss.JoinHorizontal(
+	totalRulesLine := lipgloss.JoinHorizontal(
 		lipgloss.Left,
-		m.styles.Label.Render("UpTime:"),
-		m.styles.Value.Render(m.status.UpTime),
+		m.styles.Label.Render("Total Rules:"),
+		m.styles.Value.Render(strconv.Itoa(len(m.rules))),
 	)
 
 	content := lipgloss.JoinVertical(
 		lipgloss.Left,
 		statusLine,
 		loggingLine,
-		uptimeLine,
+		totalRulesLine,
 	)
 
-	return RenderBoxWithTitle("Firewall Status", content, m.styles, -1)
+	return RenderBoxWithTitle("Firewall Stats", content, m.styles, -1)
 }
 
 func (m model) renderPoliciesSection() string {
@@ -80,46 +81,36 @@ func (m model) renderPoliciesSection() string {
 	return RenderBoxWithTitle("Default Policies", content, m.styles, -1)
 }
 
-func (m model) renderActiveRulesCountSection() string {
-
-	ruleCountText := fmt.Sprintf("%d rule(s) configured", len(m.status.Rules))
-	ruleCountLine := m.styles.Value.Render(ruleCountText)
-
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		ruleCountLine,
-	)
-
-	return RenderBoxWithTitle("Active Rules", content, m.styles,-1)
-}
-
-func (m model) renderAllRulesSection(width int) string {
-	title := m.styles.SectionTitle.Render("All Rules")
+func (m model) renderRulesSection() string {
 	var rows []string
 
 	header := fmt.Sprintf(
-		"%-1s %-3s %-5s %-7s",
-		"NUM", "TO", "ACTION", "FROM",
-	)
-	rows = append(rows, m.styles.Label.Render(header))
-	// Rule rows
-	for i, r := range m.status.Rules {
-		if i > 1 {
-			continue
-		}
+	"%-3s │ %-6s │ %-5s │ %-16s │ %-5s │ %-16s │ %-5s",
+		"#", "Action", "Proto", "Source", "sPort","Destination","dPort")
+
+	headerContent := m.styles.Label.UnsetWidth().Render(header)
+	line := strings.Repeat("─", lipgloss.Width(headerContent))
+	rows = append(rows, headerContent, m.styles.Label.UnsetWidth().Render(line))
+
+	for _, r := range m.rules {
 		row := fmt.Sprintf(
-			"%-5d %-15s %-10s %-20s",
-			r.Num, r.To, r.Action, r.From,
+			"%-3d │ %-6s │ %-5s │ %-16s │ %-5s │ %-16s │ %-5s",
+			r.Num,
+			r.Action,
+			r.ToProtocol,
+			r.FromSource,
+			r.FromPort,
+			r.ToDest,
+			r.ToPort,
 		)
 		rows = append(rows, m.styles.Value.Render(row))
 	}
 	table := strings.Join(rows, "\n")
 	content := lipgloss.JoinVertical(
 		lipgloss.Left,
-		title,
 		table,
 	)
-	return m.styles.SectionBorder.Width(width).Render(content)
+	return RenderBoxWithTitle("Active Rules", content, m.styles, -1)
 }
 
 func (m model) getPolicyStyle(policy string) lipgloss.Style {
internal/app/update.go
@@ -18,7 +18,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case key.Matches(msg, Keys.Quit):
 			return m, tea.Quit
 		case key.Matches(msg, Keys.Refresh):
-			m.status = ufw.GetStatus()
+			m.status,m.rules = ufw.GetStatus()
 			return m, nil
 		}
 
internal/app/view.go
@@ -8,7 +8,7 @@ import (
 
 func (m model) View() string {
 	const minWidth, minHeight = 87, 30
-	const borderWidth, borderHeight = minWidth - 5, minHeight - 3
+	const containerWidth, containerHeight = minWidth - 5, minHeight - 3
 	if m.width < minWidth || m.height < minHeight {
 		return lipgloss.Place(
 			m.width,
@@ -20,7 +20,7 @@ func (m model) View() string {
 	}
 
 	if m.status.Error != nil {
-		footer := m.renderFooter(borderWidth)
+		footer := m.renderFooter(containerWidth)
 		return lipgloss.JoinVertical(
 			lipgloss.Center,
 			"",
@@ -30,27 +30,20 @@ func (m model) View() string {
 		)
 	}
 
-	borderStyle := lipgloss.NewStyle().
-		Border(lipgloss.RoundedBorder()).
-		Height(borderHeight).
-		Width(borderWidth).
-		BorderForeground(lipgloss.Color("240"))
-
 	title := m.styles.Title.
-		Width(borderWidth).
+		Width(containerWidth).
 		Render("Firewall Manager")
 
 	infoSection := lipgloss.JoinHorizontal(
 		lipgloss.Top,
 		m.renderStatusSection(),
-		m.renderPoliciesSection(),	
-		)
+		m.renderPoliciesSection(),
+	)
 
 	content := lipgloss.JoinVertical(
 		lipgloss.Left,
 		infoSection,
-		"",
-		m.renderActiveRulesCountSection(),
+		m.renderRulesSection(),
 	)
 
 	layout := lipgloss.JoinVertical(
@@ -59,18 +52,16 @@ func (m model) View() string {
 		"",
 		content,
 		"",
-		m.renderFooter(borderWidth),
+		m.renderFooter(containerWidth),
 	)
 
-	box := borderStyle.Render(layout)
-
-	box = lipgloss.Place(
+	layout = lipgloss.Place(
 		m.width,
 		m.height,
 		lipgloss.Center,
 		lipgloss.Center,
-		box,
+		layout,
 	)
 
-	return box
+	return layout
 }
internal/ufw/main.go
@@ -1,13 +1,11 @@
 package ufw
 
 import (
-	"bufio"
 	"bytes"
 	"fmt"
 	"os/exec"
 	"regexp"
 	"strings"
-	"time"
 )
 
 type UFWStatus struct {
@@ -16,21 +14,12 @@ type UFWStatus struct {
 	DefaultOut    string
 	DefaultRouted string
 	Logging       string
-	Rules         []Rule
 	RawVerbose    string
 	RawNumbered   string
 	Error         error
 	UpTime        string
 }
 
-type Rule struct {
-	Num    int
-	To     string
-	Action string
-	From   string
-	Raw    string
-}
-
 var (
 	reStatus   = regexp.MustCompile(`(?i)Status:\s*(\w+)`)
 	reDefault  = regexp.MustCompile(`(?im)^Default:\s*(.+)$`)
@@ -51,16 +40,24 @@ func RunSudo(args ...string) (stdout, stderr string, err error) {
 	return RunCmd("sudo", append([]string{"ufw"}, args...)...)
 }
 
-func GetStatus() UFWStatus {
+func extractPolicy(s string) string {
+	parts := strings.Fields(s)
+	if len(parts) > 0 {
+		return parts[0]
+	}
+	return "unknown"
+}
+
+func GetStatus() (UFWStatus, []Rule) {
 	var s UFWStatus
-	// Prefer numbered for rule list; use verbose for defaults/logging.
+
 	numOut, _, errNum := RunSudo("status", "numbered")
 	verbOut, _, errVerb := RunSudo("status", "verbose")
 	s.RawNumbered = numOut
 	s.RawVerbose = verbOut
 	if errNum != nil && errVerb != nil {
 		s.Error = fmt.Errorf("ufw status: %w", errNum)
-		return s
+		return s, nil
 	}
 
 	if m := reStatus.FindStringSubmatch(numOut); len(m) > 1 {
@@ -76,67 +73,21 @@ func GetStatus() UFWStatus {
 			p = strings.TrimSpace(p)
 			lower := strings.ToLower(p)
 			if strings.Contains(lower, "incoming") {
-				s.DefaultIn = strings.TrimSuffix(p, "(incoming)")
+				s.DefaultIn = strings.ToUpper(extractPolicy(p))
 			}
 			if strings.Contains(lower, "outgoing") {
-				s.DefaultOut = strings.TrimSuffix(p, "(outgoing)")
+				s.DefaultOut = strings.ToUpper(extractPolicy(p))
 			}
 			if strings.Contains(lower, "routed") {
-				s.DefaultRouted = strings.TrimSuffix(p, "(routed)")
+				s.DefaultRouted = strings.ToUpper(extractPolicy(p))
 			}
 		}
 	}
 	if m := reLogging.FindStringSubmatch(verbOut); len(m) > 1 {
 		s.Logging = m[1]
 	}
-
-	sc := bufio.NewScanner(strings.NewReader(numOut))
-	for sc.Scan() {
-		line := sc.Text()
-		if subm := reRuleLine.FindStringSubmatch(line); len(subm) >= 3 {
-			var num int
-			fmt.Sscanf(subm[1], "%d", &num)
-			rest := strings.TrimSpace(subm[2])
-			fields := splitRuleFields(rest)
-			r := Rule{Num: num, Raw: rest}
-			if len(fields) >= 3 {
-				r.To = fields[0]
-				r.Action = fields[1]
-				r.From = strings.Join(fields[2:], " ")
-			} else {
-				r.To = rest
-			}
-			s.Rules = append(s.Rules, r)
-		}
-	}
-	out, _, _ := RunCmd("systemctl", "show", "ufw", "--property=ActiveEnterTimestamp")
-	line := strings.TrimSpace(out)
-	parts := strings.SplitN(line, "=", 2)
-	if len(parts) != 2 || parts[1] == "" {
-		s.Error = fmt.Errorf("could not parse timestamp")
-		return s
-	}
-	timestamp := parts[1]
-	startTime, err := time.Parse("Mon 2006-01-02 15:04:05 MST", timestamp)
-	if err != nil {
-		s.Error = err
-		return s
-	}
-
-	uptime := time.Since(startTime)
-	hours := int(uptime.Hours())
-	minutes := int(uptime.Minutes()) % 60
-	
-	s.UpTime = fmt.Sprintf("%dh %dm", hours, minutes)	
-	return s
-}
-
-func splitRuleFields(s string) []string {
-	var out []string
-	for f := range strings.FieldsSeq(s) {
-		out = append(out, f)
-	}
-	return out
+	rules := ParseRules(numOut)
+	return s, rules
 }
 
 func Enable() (stdout, stderr string, err error) {
@@ -167,7 +118,6 @@ func DefaultOutgoing(allow bool) (stdout, stderr string, err error) {
 	return RunSudo("default", pol, "outgoing")
 }
 
-// AddRule runs e.g. "sudo ufw allow 22/tcp".
 func AddRule(rule string) (stdout, stderr string, err error) {
 	parts := strings.Fields(rule)
 	if len(parts) == 0 {
internal/ufw/rules.go
@@ -0,0 +1,218 @@
+package ufw
+
+import (
+	"bufio"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+type Rule struct {
+	Num        int
+	Action     string
+	Direction  string
+	ToDest     string
+	ToPort     string
+	ToProtocol string
+	FromSource string
+	FromPort   string
+	Comment    string
+	Raw        string
+}
+
+func ParseRules(output string) []Rule {
+	var rules []Rule
+	reNumbered := regexp.MustCompile(`^\[\s*(\d+)\]\s+(.+)$`)
+	scanner := bufio.NewScanner(strings.NewReader(output))
+
+	for scanner.Scan() {
+		line := scanner.Text()
+
+		if shouldSkipLine(line) {
+			continue
+		}
+
+		matches := reNumbered.FindStringSubmatch(line)
+		if len(matches) < 3 {
+			continue
+		}
+
+		num, _ := strconv.Atoi(matches[1])
+		ruleLine := strings.TrimSpace(matches[2])
+
+		rule := parseRuleLine(num, ruleLine)
+		rules = append(rules, rule)
+	}
+
+	return rules
+}
+
+func shouldSkipLine(line string) bool {
+	trimmed := strings.TrimSpace(line)
+
+	if trimmed == "" {
+		return true
+	}
+
+	if strings.Contains(line, "Status:") {
+		return true
+	}
+
+	if strings.Contains(line, "To") && strings.Contains(line, "Action") && strings.Contains(line, "From") {
+		return true
+	}
+
+	if strings.HasPrefix(trimmed, "--") || strings.HasPrefix(trimmed, "==") {
+		return true
+	}
+
+	return false
+}
+
+func parseRuleLine(num int, line string) Rule {
+	rule := Rule{
+		Num: num,
+		Raw: line,
+	}
+
+	commentIdx := strings.Index(line, "#")
+	if commentIdx >= 0 {
+		rule.Comment = strings.TrimSpace(line[commentIdx+1:])
+		line = strings.TrimSpace(line[:commentIdx])
+	}
+
+	parts := splitByMultipleSpaces(line)
+
+	if len(parts) < 3 {
+		return rule
+	}
+
+	actionIndex := findActionIndex(parts)
+	if actionIndex == -1 {
+		return rule
+	}
+
+	actionParts := strings.Fields(parts[actionIndex])
+	if len(actionParts) > 0 {
+		rule.Action = actionParts[0]
+	}
+	if len(actionParts) > 1 {
+		rule.Direction = actionParts[1]
+	}
+
+	if actionIndex > 0 {
+		toField := strings.Join(parts[:actionIndex], " ")
+		rule.ToDest, rule.ToPort, rule.ToProtocol = parseDestination(toField)
+	}
+
+	if actionIndex+1 < len(parts) {
+		fromField := strings.Join(parts[actionIndex+1:], " ")
+		rule.FromSource, rule.FromPort = parseSource(fromField)
+	}
+
+	return rule
+}
+
+func findActionIndex(parts []string) int {
+	for i, part := range parts {
+		upper := strings.ToUpper(strings.Fields(part)[0])
+		if upper == "ALLOW" || upper == "DENY" || upper == "REJECT" || upper == "LIMIT" {
+			return i
+		}
+	}
+	return -1
+}
+
+func splitByMultipleSpaces(s string) []string {
+	re := regexp.MustCompile(`\s{2,}`)
+	parts := re.Split(s, -1)
+
+	cleaned := make([]string, 0, len(parts))
+	for _, part := range parts {
+		trimmed := strings.TrimSpace(part)
+		if trimmed != "" {
+			cleaned = append(cleaned, trimmed)
+		}
+	}
+
+	return cleaned
+}
+
+func parseDestination(s string) (dest, port, protocol string) {
+	s = strings.TrimSpace(s)
+
+	if strings.HasPrefix(s, "Anywhere") {
+		return s, "any", "any"
+	}
+
+	spaceParts := strings.Fields(s)
+	if len(spaceParts) >= 2 && isIPOrCIDR(spaceParts[0]) {
+		dest = spaceParts[0]
+		port, protocol = parsePortSpec(strings.Join(spaceParts[1:], " "))
+		return dest, port, protocol
+	}
+
+	if isIPOrCIDR(s) {
+		return s, "any", "any"
+	}
+
+	port, protocol = parsePortSpec(s)
+	return "Anywhere", port, protocol
+}
+
+func parseSource(s string) (source, port string) {
+	s = strings.TrimSpace(s)
+
+	if strings.HasPrefix(s, "Anywhere") {
+		return s, "any"
+	}
+
+	if strings.Contains(s, " ") {
+		parts := strings.Fields(s)
+		if len(parts) >= 2 {
+			return parts[0], parts[1]
+		}
+	}
+
+	return s, "any"
+}
+
+func isIPOrCIDR(s string) bool {
+	if strings.Contains(s, ".") {
+		parts := strings.Split(s, "/")
+		if strings.Contains(parts[0], ".") {
+			return true
+		}
+	}
+
+	colonCount := strings.Count(s, ":")
+	if colonCount > 1 {
+		return true
+	}
+
+	return false
+}
+
+func parsePortSpec(s string) (port, protocol string) {
+	s = strings.TrimSpace(s)
+
+	if strings.Contains(s, "/") {
+		parts := strings.SplitN(s, "/", 2)
+		if len(parts) == 2 {
+			portNum := parts[0]
+			protoWithExtra := parts[1]
+
+			protoWords := strings.Fields(protoWithExtra)
+			if len(protoWords) > 0 {
+				protocol = strings.ToLower(protoWords[0])
+				return portNum, protocol
+			}
+		}
+	}
+
+	if strings.Contains(s, ":") && !strings.Contains(s, "::") {
+		return s, "any"
+	}
+
+	return s, "any"
+}