Commit 7057e85
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"
+}