Prechádzať zdrojové kódy

feat: implement MikroTik RouterOS-grade Firewall and Routing Tables suite

Proxmox Router 1 týždeň pred
rodič
commit
1f70fce21c

+ 5 - 1
cmd/server/main.go

@@ -12,7 +12,9 @@ import (
 	"time"
 
 	"proxmox-router/internal/api"
+	"proxmox-router/internal/firewall"
 	"proxmox-router/internal/network"
+	"proxmox-router/internal/routing"
 	"proxmox-router/internal/vpn"
 	"proxmox-router/internal/web"
 )
@@ -27,9 +29,11 @@ func main() {
 	netMgr := network.NewManager(metaMgr)
 	persistMgr := network.NewPersistenceManager(netMgr)
 	vpnMgr := vpn.NewManager()
+	fwMgr := firewall.NewFirewallManager()
+	routingMgr := routing.NewRoutingManager()
 	webHandler := web.Handler()
 
-	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, webHandler)
+	srv := api.NewServer(netMgr, persistMgr, metaMgr, vpnMgr, fwMgr, routingMgr, webHandler)
 
 	httpServer := &http.Server{
 		Addr:         *addr,

+ 54 - 2
internal/api/api.go

@@ -5,7 +5,9 @@ import (
 	"net/http"
 	"strings"
 
+	"proxmox-router/internal/firewall"
 	"proxmox-router/internal/network"
+	"proxmox-router/internal/routing"
 	"proxmox-router/internal/system"
 	"proxmox-router/internal/vpn"
 )
@@ -15,16 +17,20 @@ type Server struct {
 	persistMgr *network.PersistenceManager
 	metaMgr    *network.MetadataManager
 	vpnMgr     *vpn.Manager
+	fwMgr      *firewall.FirewallManager
+	routingMgr *routing.RoutingManager
 	mux        *http.ServeMux
 	fsHandler  http.Handler
 }
 
-func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fsHandler http.Handler) *Server {
+func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, fsHandler http.Handler) *Server {
 	s := &Server{
 		netMgr:     netMgr,
 		persistMgr: persistMgr,
 		metaMgr:    metaMgr,
 		vpnMgr:     vpnMgr,
+		fwMgr:      fwMgr,
+		routingMgr: routingMgr,
 		mux:        http.NewServeMux(),
 		fsHandler:  fsHandler,
 	}
@@ -35,7 +41,53 @@ func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager,
 func (s *Server) registerRoutes() {
 	// API Endpoints
 	s.mux.HandleFunc("GET /api/system/status", s.handleSystemStatus)
-	s.mux.HandleFunc("GET /api/routes", s.handleRoutes)
+
+	// Routing & Policy Rules (/ip route & /ip route rule)
+	s.mux.HandleFunc("GET /api/routes", s.handleListRoutes)
+	s.mux.HandleFunc("POST /api/routes", s.handleSaveRoute)
+	s.mux.HandleFunc("DELETE /api/routes/{id}", s.handleDeleteRoute)
+	s.mux.HandleFunc("POST /api/routes/{id}/state", s.handleSetRouteState)
+
+	s.mux.HandleFunc("GET /api/routing/rules", s.handleListPolicyRules)
+	s.mux.HandleFunc("POST /api/routing/rules", s.handleSavePolicyRule)
+	s.mux.HandleFunc("DELETE /api/routing/rules/{id}", s.handleDeletePolicyRule)
+
+	s.mux.HandleFunc("GET /api/routing/tables", s.handleListRoutingTables)
+	s.mux.HandleFunc("POST /api/routing/tables", s.handleSaveRoutingTable)
+
+	// Firewall Endpoints (/ip firewall)
+	s.mux.HandleFunc("GET /api/firewall/summary", s.handleFirewallSummary)
+	s.mux.HandleFunc("POST /api/firewall/reset-counters", s.handleResetFirewallCounters)
+
+	// Filter Rules
+	s.mux.HandleFunc("GET /api/firewall/filter", s.handleListFilterRules)
+	s.mux.HandleFunc("POST /api/firewall/filter", s.handleSaveFilterRule)
+	s.mux.HandleFunc("DELETE /api/firewall/filter/{id}", s.handleDeleteFilterRule)
+	s.mux.HandleFunc("POST /api/firewall/filter/{id}/move", s.handleMoveFilterRule)
+	s.mux.HandleFunc("POST /api/firewall/filter/{id}/state", s.handleSetFilterRuleState)
+
+	// NAT Rules
+	s.mux.HandleFunc("GET /api/firewall/nat", s.handleListNATRules)
+	s.mux.HandleFunc("POST /api/firewall/nat", s.handleSaveNATRule)
+	s.mux.HandleFunc("DELETE /api/firewall/nat/{id}", s.handleDeleteNATRule)
+	s.mux.HandleFunc("POST /api/firewall/nat/{id}/move", s.handleMoveNATRule)
+	s.mux.HandleFunc("POST /api/firewall/nat/{id}/state", s.handleSetNATRuleState)
+
+	// Mangle Rules
+	s.mux.HandleFunc("GET /api/firewall/mangle", s.handleListMangleRules)
+	s.mux.HandleFunc("POST /api/firewall/mangle", s.handleSaveMangleRule)
+	s.mux.HandleFunc("DELETE /api/firewall/mangle/{id}", s.handleDeleteMangleRule)
+	s.mux.HandleFunc("POST /api/firewall/mangle/{id}/move", s.handleMoveMangleRule)
+	s.mux.HandleFunc("POST /api/firewall/mangle/{id}/state", s.handleSetMangleRuleState)
+
+	// Address Lists
+	s.mux.HandleFunc("GET /api/firewall/address-lists", s.handleListAddressLists)
+	s.mux.HandleFunc("POST /api/firewall/address-lists", s.handleSaveAddressListEntry)
+	s.mux.HandleFunc("DELETE /api/firewall/address-lists/{id}", s.handleDeleteAddressListEntry)
+
+	// Active Connections
+	s.mux.HandleFunc("GET /api/firewall/connections", s.handleListConnections)
+	s.mux.HandleFunc("POST /api/firewall/connections/kill", s.handleKillConnection)
 
 	s.mux.HandleFunc("GET /api/interfaces", s.handleListInterfaces)
 	s.mux.HandleFunc("GET /api/interfaces/{name}", s.handleGetInterface)

+ 327 - 0
internal/api/firewall_handlers.go

@@ -0,0 +1,327 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"proxmox-router/internal/firewall"
+	"proxmox-router/internal/routing"
+)
+
+// ======================== Firewall Summary ========================
+func (s *Server) handleFirewallSummary(w http.ResponseWriter, r *http.Request) {
+	writeJSON(w, http.StatusOK, s.fwMgr.GetSummary())
+}
+
+func (s *Server) handleResetFirewallCounters(w http.ResponseWriter, r *http.Request) {
+	if err := s.fwMgr.ResetCounters(); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true})
+}
+
+// ======================== Filter Rules ========================
+func (s *Server) handleListFilterRules(w http.ResponseWriter, r *http.Request) {
+	rules := s.fwMgr.ListFilterRules()
+	writeJSON(w, http.StatusOK, rules)
+}
+
+func (s *Server) handleSaveFilterRule(w http.ResponseWriter, r *http.Request) {
+	var rule firewall.FilterRule
+	if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+	if err := s.fwMgr.SaveFilterRule(rule); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": rule.ID})
+}
+
+func (s *Server) handleDeleteFilterRule(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := s.fwMgr.DeleteFilterRule(id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+func (s *Server) handleMoveFilterRule(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	var req struct {
+		Direction string `json:"direction"` // "up" or "down"
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if err := s.fwMgr.MoveFilterRule(id, req.Direction); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+func (s *Server) handleSetFilterRuleState(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	var req struct {
+		Disabled bool `json:"disabled"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if err := s.fwMgr.SetFilterRuleState(id, req.Disabled); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id, "disabled": req.Disabled})
+}
+
+// ======================== NAT Rules ========================
+func (s *Server) handleListNATRules(w http.ResponseWriter, r *http.Request) {
+	rules := s.fwMgr.ListNATRules()
+	writeJSON(w, http.StatusOK, rules)
+}
+
+func (s *Server) handleSaveNATRule(w http.ResponseWriter, r *http.Request) {
+	var rule firewall.NATRule
+	if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+	if err := s.fwMgr.SaveNATRule(rule); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": rule.ID})
+}
+
+func (s *Server) handleDeleteNATRule(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := s.fwMgr.DeleteNATRule(id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+func (s *Server) handleMoveNATRule(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	var req struct {
+		Direction string `json:"direction"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if err := s.fwMgr.MoveNATRule(id, req.Direction); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+func (s *Server) handleSetNATRuleState(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	var req struct {
+		Disabled bool `json:"disabled"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if err := s.fwMgr.SetNATRuleState(id, req.Disabled); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id, "disabled": req.Disabled})
+}
+
+// ======================== Mangle Rules ========================
+func (s *Server) handleListMangleRules(w http.ResponseWriter, r *http.Request) {
+	rules := s.fwMgr.ListMangleRules()
+	writeJSON(w, http.StatusOK, rules)
+}
+
+func (s *Server) handleSaveMangleRule(w http.ResponseWriter, r *http.Request) {
+	var rule firewall.MangleRule
+	if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+	if err := s.fwMgr.SaveMangleRule(rule); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": rule.ID})
+}
+
+func (s *Server) handleDeleteMangleRule(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := s.fwMgr.DeleteMangleRule(id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+func (s *Server) handleMoveMangleRule(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	var req struct {
+		Direction string `json:"direction"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if err := s.fwMgr.MoveMangleRule(id, req.Direction); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+func (s *Server) handleSetMangleRuleState(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	var req struct {
+		Disabled bool `json:"disabled"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if err := s.fwMgr.SetMangleRuleState(id, req.Disabled); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id, "disabled": req.Disabled})
+}
+
+// ======================== Address Lists ========================
+func (s *Server) handleListAddressLists(w http.ResponseWriter, r *http.Request) {
+	lists := s.fwMgr.ListAddressLists()
+	writeJSON(w, http.StatusOK, lists)
+}
+
+func (s *Server) handleSaveAddressListEntry(w http.ResponseWriter, r *http.Request) {
+	var entry firewall.AddressListEntry
+	if err := json.NewDecoder(r.Body).Decode(&entry); err != nil || entry.Name == "" || entry.Address == "" {
+		writeError(w, http.StatusBadRequest, "name and address are required")
+		return
+	}
+	if err := s.fwMgr.SaveAddressListEntry(entry); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": entry.ID})
+}
+
+func (s *Server) handleDeleteAddressListEntry(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := s.fwMgr.DeleteAddressListEntry(id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+// ======================== Active Connections ========================
+func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request) {
+	conns, err := firewall.ListActiveConnections()
+	if err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, conns)
+}
+
+func (s *Server) handleKillConnection(w http.ResponseWriter, r *http.Request) {
+	var req struct {
+		Protocol string `json:"protocol"`
+		Src      string `json:"src"`
+		Sport    int    `json:"sport"`
+		Dst      string `json:"dst"`
+		Dport    int    `json:"dport"`
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+	if err := firewall.KillConnection(req.Protocol, req.Src, req.Sport, req.Dst, req.Dport); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true})
+}
+
+// ======================== Routes & Policy Routing ========================
+func (s *Server) handleListRoutes(w http.ResponseWriter, r *http.Request) {
+	routes := s.routingMgr.ListRoutes()
+	writeJSON(w, http.StatusOK, routes)
+}
+
+func (s *Server) handleSaveRoute(w http.ResponseWriter, r *http.Request) {
+	var route routing.RouteItem
+	if err := json.NewDecoder(r.Body).Decode(&route); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+	if err := s.routingMgr.SaveRoute(route); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": route.ID})
+}
+
+func (s *Server) handleDeleteRoute(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := s.routingMgr.DeleteRoute(id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+func (s *Server) handleSetRouteState(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	var req struct {
+		Disabled bool `json:"disabled"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if err := s.routingMgr.SetRouteState(id, req.Disabled); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id, "disabled": req.Disabled})
+}
+
+func (s *Server) handleListPolicyRules(w http.ResponseWriter, r *http.Request) {
+	rules := s.routingMgr.ListPolicyRules()
+	writeJSON(w, http.StatusOK, rules)
+}
+
+func (s *Server) handleSavePolicyRule(w http.ResponseWriter, r *http.Request) {
+	var rule routing.PolicyRuleItem
+	if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+	if err := s.routingMgr.SavePolicyRule(rule); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": rule.ID})
+}
+
+func (s *Server) handleDeletePolicyRule(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := s.routingMgr.DeletePolicyRule(id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+func (s *Server) handleListRoutingTables(w http.ResponseWriter, r *http.Request) {
+	tables := s.routingMgr.ListTables()
+	writeJSON(w, http.StatusOK, tables)
+}
+
+func (s *Server) handleSaveRoutingTable(w http.ResponseWriter, r *http.Request) {
+	var table routing.RoutingTableItem
+	if err := json.NewDecoder(r.Body).Decode(&table); err != nil || table.Name == "" {
+		writeError(w, http.StatusBadRequest, "table name is required")
+		return
+	}
+	if err := s.routingMgr.SaveTable(table); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "name": table.Name})
+}

+ 149 - 0
internal/firewall/conntrack.go

@@ -0,0 +1,149 @@
+package firewall
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"os/exec"
+	"strconv"
+	"strings"
+)
+
+// ListActiveConnections executes `conntrack -L -o extended` and parses the output
+func ListActiveConnections() ([]ActiveConnection, error) {
+	cmd := exec.Command("conntrack", "-L", "-o", "extended")
+	out, err := cmd.Output()
+	if err != nil {
+		// If conntrack is empty or fails, return empty slice
+		return make([]ActiveConnection, 0), nil
+	}
+
+	var conns []ActiveConnection
+	scanner := bufio.NewScanner(bytes.NewReader(out))
+
+	for scanner.Scan() {
+		line := strings.TrimSpace(scanner.Text())
+		if line == "" || strings.HasPrefix(line, "conntrack") {
+			continue
+		}
+
+		conn := parseConntrackLine(line)
+		if conn.Protocol != "" && conn.SrcAddress != "" {
+			conns = append(conns, conn)
+		}
+	}
+
+	if conns == nil {
+		conns = make([]ActiveConnection, 0)
+	}
+	return conns, nil
+}
+
+func parseConntrackLine(line string) ActiveConnection {
+	var conn ActiveConnection
+	fields := strings.Fields(line)
+	if len(fields) < 4 {
+		return conn
+	}
+
+	// Detect protocol
+	protocol := ""
+	timeout := 0
+	state := ""
+
+	var srcList []string
+	var dstList []string
+	var sportList []int
+	var dportList []int
+
+	for i, f := range fields {
+		if f == "tcp" || f == "udp" || f == "icmp" || f == "gre" || f == "esp" {
+			protocol = strings.ToUpper(f)
+		} else if strings.HasPrefix(f, "src=") {
+			srcList = append(srcList, strings.TrimPrefix(f, "src="))
+		} else if strings.HasPrefix(f, "dst=") {
+			dstList = append(dstList, strings.TrimPrefix(f, "dst="))
+		} else if strings.HasPrefix(f, "sport=") {
+			p, _ := strconv.Atoi(strings.TrimPrefix(f, "sport="))
+			sportList = append(sportList, p)
+		} else if strings.HasPrefix(f, "dport=") {
+			p, _ := strconv.Atoi(strings.TrimPrefix(f, "dport="))
+			dportList = append(dportList, p)
+		} else if strings.HasPrefix(f, "mark=") {
+			conn.ConnectionMark = strings.TrimPrefix(f, "mark=")
+		} else if f == "[ASSURED]" {
+			conn.Assured = true
+		} else if i > 1 && timeout == 0 {
+			if t, err := strconv.Atoi(f); err == nil && t > 0 {
+				timeout = t
+			}
+		}
+		if f == "ESTABLISHED" || f == "TIME_WAIT" || f == "CLOSE_WAIT" || f == "FIN_WAIT" || f == "SYN_SENT" || f == "SYN_RECV" {
+			state = f
+		}
+	}
+
+	conn.Protocol = protocol
+	conn.Timeout = timeout
+	conn.TCPState = state
+	if conn.TCPState == "" {
+		conn.TCPState = protocol
+	}
+
+	if len(srcList) > 0 {
+		conn.SrcAddress = srcList[0]
+	}
+	if len(dstList) > 0 {
+		conn.DstAddress = dstList[0]
+	}
+	if len(sportList) > 0 {
+		conn.SrcPort = sportList[0]
+	}
+	if len(dportList) > 0 {
+		conn.DstPort = dportList[0]
+	}
+
+	// Reply pair
+	if len(srcList) > 1 {
+		conn.ReplySrcAddress = srcList[1]
+	}
+	if len(dstList) > 1 {
+		conn.ReplyDstAddress = dstList[1]
+	}
+	if len(sportList) > 1 {
+		conn.ReplySrcPort = sportList[1]
+	}
+	if len(dportList) > 1 {
+		conn.ReplyDstPort = dportList[1]
+	}
+
+	conn.ID = fmt.Sprintf("%s-%s:%d-%s:%d", conn.Protocol, conn.SrcAddress, conn.SrcPort, conn.DstAddress, conn.DstPort)
+	return conn
+}
+
+// KillConnection deletes a specific conntrack flow
+func KillConnection(protocol, src string, sport int, dst string, dport int) error {
+	args := []string{"-D"}
+	if protocol != "" {
+		args = append(args, "-p", strings.ToLower(protocol))
+	}
+	if src != "" {
+		args = append(args, "-s", src)
+	}
+	if dst != "" {
+		args = append(args, "-d", dst)
+	}
+	if sport > 0 {
+		args = append(args, "--sport", strconv.Itoa(sport))
+	}
+	if dport > 0 {
+		args = append(args, "--dport", strconv.Itoa(dport))
+	}
+
+	cmd := exec.Command("conntrack", args...)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("failed to terminate connection: %s: %w", string(out), err)
+	}
+	return nil
+}

+ 54 - 0
internal/firewall/ipset.go

@@ -0,0 +1,54 @@
+package firewall
+
+import (
+	"os/exec"
+	"strings"
+)
+
+// SyncAddressLists updates Linux ipsets to match the configured AddressList entries
+func SyncAddressLists(entries []AddressListEntry) error {
+	// Group addresses by list name
+	listMap := make(map[string][]string)
+	for _, e := range entries {
+		if e.Disabled || e.Name == "" || e.Address == "" {
+			continue
+		}
+		listMap[e.Name] = append(listMap[e.Name], e.Address)
+	}
+
+	// Clean up any mk_ sets that no longer exist or are now empty
+	if out, err := exec.Command("ipset", "list", "-name").Output(); err == nil {
+		lines := strings.Split(string(out), "\n")
+		for _, line := range lines {
+			line = strings.TrimSpace(line)
+			if strings.HasPrefix(line, "mk_") {
+				name := strings.TrimPrefix(line, "mk_")
+				if _, ok := listMap[name]; !ok {
+					_ = exec.Command("ipset", "flush", line).Run()
+					_ = exec.Command("ipset", "destroy", line).Run()
+				}
+			}
+		}
+	}
+
+	for name, addrs := range listMap {
+		setName := "mk_" + strings.ReplaceAll(name, " ", "_")
+		// Create set if not exists
+		_ = exec.Command("ipset", "create", setName, "hash:net", "-exist").Run()
+		_ = exec.Command("ipset", "flush", setName).Run()
+
+		for _, a := range addrs {
+			_ = exec.Command("ipset", "add", setName, a, "-exist").Run()
+		}
+	}
+
+	return nil
+}
+
+// GetIPSetName returns the internal ipset name for an address list
+func GetIPSetName(listName string) string {
+	if listName == "" {
+		return ""
+	}
+	return "mk_" + strings.ReplaceAll(listName, " ", "_")
+}

+ 416 - 0
internal/firewall/iptables.go

@@ -0,0 +1,416 @@
+package firewall
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"hash/fnv"
+	"os/exec"
+	"strconv"
+	"strings"
+)
+
+// MarkNameToID converts a string mark name into a deterministic 32-bit fwmark integer
+func MarkNameToID(name string) int {
+	if name == "" {
+		return 0
+	}
+	if id, err := strconv.Atoi(name); err == nil && id > 0 {
+		return id
+	}
+	h := fnv.New32a()
+	h.Write([]byte(name))
+	val := int(h.Sum32() % 65000)
+	if val < 100 {
+		val += 100
+	}
+	return val
+}
+
+// ReadRuleCounters reads `iptables-save -c` and populates the Packets and Bytes fields for all rules
+func ReadRuleCounters(filterRules []FilterRule, natRules []NATRule, mangleRules []MangleRule) (map[string][2]uint64, error) {
+	out, err := exec.Command("iptables-save", "-c").Output()
+	if err != nil {
+		return nil, err
+	}
+
+	counters := make(map[string][2]uint64)
+	scanner := bufio.NewScanner(bytes.NewReader(out))
+
+	for scanner.Scan() {
+		line := strings.TrimSpace(scanner.Text())
+		if !strings.HasPrefix(line, "[") {
+			continue
+		}
+
+		closeBracket := strings.Index(line, "]")
+		if closeBracket == -1 {
+			continue
+		}
+
+		countsStr := line[1:closeBracket]
+		parts := strings.Split(countsStr, ":")
+		if len(parts) != 2 {
+			continue
+		}
+
+		pkts, _ := strconv.ParseUint(parts[0], 10, 64)
+		bts, _ := strconv.ParseUint(parts[1], 10, 64)
+
+		commentIdx := strings.Index(line, "--comment \"id:")
+		if commentIdx != -1 {
+			sub := line[commentIdx+len("--comment \"id:"):]
+			endQuote := strings.Index(sub, "\"")
+			if endQuote != -1 {
+				ruleID := sub[:endQuote]
+				counters[ruleID] = [2]uint64{pkts, bts}
+			}
+		}
+	}
+
+	return counters, nil
+}
+
+// BuildIPTablesRestore generates the atomic configuration for `iptables-restore`
+func BuildIPTablesRestore(filterRules []FilterRule, natRules []NATRule, mangleRules []MangleRule) string {
+	var sb strings.Builder
+
+	// ================= MANGLE TABLE =================
+	sb.WriteString("*mangle\n")
+	sb.WriteString(":PREROUTING ACCEPT [0:0]\n")
+	sb.WriteString(":INPUT ACCEPT [0:0]\n")
+	sb.WriteString(":FORWARD ACCEPT [0:0]\n")
+	sb.WriteString(":OUTPUT ACCEPT [0:0]\n")
+	sb.WriteString(":POSTROUTING ACCEPT [0:0]\n")
+
+	// Custom user chains in mangle
+	userMangleChains := make(map[string]bool)
+	for _, r := range mangleRules {
+		ch := strings.ToUpper(r.Chain)
+		if ch != "PREROUTING" && ch != "POSTROUTING" && ch != "INPUT" && ch != "OUTPUT" && ch != "FORWARD" && ch != "" {
+			if !userMangleChains[ch] {
+				sb.WriteString(fmt.Sprintf(":%s - [0:0]\n", ch))
+				userMangleChains[ch] = true
+			}
+		}
+	}
+
+	for _, r := range mangleRules {
+		if r.Disabled {
+			continue
+		}
+		ruleStr := buildMangleRuleLine(r)
+		if ruleStr != "" {
+			sb.WriteString(ruleStr + "\n")
+		}
+	}
+	sb.WriteString("COMMIT\n")
+
+	// ================= NAT TABLE =================
+	sb.WriteString("*nat\n")
+	sb.WriteString(":PREROUTING ACCEPT [0:0]\n")
+	sb.WriteString(":INPUT ACCEPT [0:0]\n")
+	sb.WriteString(":OUTPUT ACCEPT [0:0]\n")
+	sb.WriteString(":POSTROUTING ACCEPT [0:0]\n")
+
+	userNATChains := make(map[string]bool)
+	for _, r := range natRules {
+		ch := strings.ToUpper(r.Chain)
+		if ch != "PREROUTING" && ch != "POSTROUTING" && ch != "OUTPUT" && ch != "INPUT" && ch != "SRCNAT" && ch != "DSTNAT" && ch != "" {
+			if !userNATChains[ch] {
+				sb.WriteString(fmt.Sprintf(":%s - [0:0]\n", ch))
+				userNATChains[ch] = true
+			}
+		}
+	}
+
+	for _, r := range natRules {
+		if r.Disabled {
+			continue
+		}
+		ruleStr := buildNATRuleLine(r)
+		if ruleStr != "" {
+			sb.WriteString(ruleStr + "\n")
+		}
+	}
+	sb.WriteString("COMMIT\n")
+
+	// ================= FILTER TABLE =================
+	sb.WriteString("*filter\n")
+	sb.WriteString(":INPUT ACCEPT [0:0]\n")
+	sb.WriteString(":FORWARD ACCEPT [0:0]\n")
+	sb.WriteString(":OUTPUT ACCEPT [0:0]\n")
+
+	userFilterChains := make(map[string]bool)
+	for _, r := range filterRules {
+		ch := strings.ToUpper(r.Chain)
+		if ch != "INPUT" && ch != "FORWARD" && ch != "OUTPUT" && ch != "" {
+			if !userFilterChains[ch] {
+				sb.WriteString(fmt.Sprintf(":%s - [0:0]\n", ch))
+				userFilterChains[ch] = true
+			}
+		}
+	}
+
+	for _, r := range filterRules {
+		if r.Disabled {
+			continue
+		}
+		ruleStr := buildFilterRuleLine(r)
+		if ruleStr != "" {
+			sb.WriteString(ruleStr + "\n")
+		}
+	}
+	sb.WriteString("COMMIT\n")
+
+	return sb.String()
+}
+
+func buildFilterRuleLine(r FilterRule) string {
+	chain := strings.ToUpper(r.Chain)
+	if chain == "" {
+		chain = "FORWARD"
+	}
+
+	var args []string
+	args = append(args, fmt.Sprintf("-A %s", chain))
+
+	// Match criteria
+	args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, r.InInterface, r.OutInterface, r.ConnectionState, r.ConnectionMark, r.RoutingMark, r.SrcAddressList, r.DstAddressList)
+
+	// Comment ID for counters mapping & user comment
+	args = append(args, fmt.Sprintf("-m comment --comment \"id:%s\"", r.ID))
+
+	// Action
+	act := strings.ToLower(r.Action)
+	switch act {
+	case "accept":
+		args = append(args, "-j ACCEPT")
+	case "drop":
+		args = append(args, "-j DROP")
+	case "reject":
+		args = append(args, "-j REJECT")
+		if r.RejectWith != "" {
+			args = append(args, fmt.Sprintf("--reject-with %s", r.RejectWith))
+		}
+	case "return":
+		args = append(args, "-j RETURN")
+	case "jump":
+		if r.JumpTarget != "" {
+			args = append(args, fmt.Sprintf("-j %s", strings.ToUpper(r.JumpTarget)))
+		}
+	case "log":
+		args = append(args, "-j LOG")
+		if r.LogPrefix != "" {
+			args = append(args, fmt.Sprintf("--log-prefix \"%s: \"", r.LogPrefix))
+		}
+	case "passthrough":
+		// in iptables, passthrough continues evaluation
+		return ""
+	default:
+		args = append(args, "-j ACCEPT")
+	}
+
+	return strings.Join(args, " ")
+}
+
+func buildNATRuleLine(r NATRule) string {
+	chain := strings.ToUpper(r.Chain)
+	if chain == "SRCNAT" {
+		chain = "POSTROUTING"
+	} else if chain == "DSTNAT" {
+		chain = "PREROUTING"
+	}
+
+	var args []string
+	args = append(args, fmt.Sprintf("-A %s", chain))
+
+	args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, r.InInterface, r.OutInterface, nil, "", "", r.SrcAddressList, r.DstAddressList)
+	args = append(args, fmt.Sprintf("-m comment --comment \"id:%s\"", r.ID))
+
+	act := strings.ToLower(r.Action)
+	switch act {
+	case "masquerade":
+		args = append(args, "-j MASQUERADE")
+	case "src-nat":
+		if r.ToAddresses != "" {
+			target := r.ToAddresses
+			if r.ToPorts != "" {
+				target = fmt.Sprintf("%s:%s", r.ToAddresses, r.ToPorts)
+			}
+			args = append(args, fmt.Sprintf("-j SNAT --to-source %s", target))
+		} else {
+			args = append(args, "-j MASQUERADE")
+		}
+	case "dst-nat":
+		if r.ToAddresses != "" {
+			target := r.ToAddresses
+			if r.ToPorts != "" {
+				target = fmt.Sprintf("%s:%s", r.ToAddresses, r.ToPorts)
+			}
+			args = append(args, fmt.Sprintf("-j DNAT --to-destination %s", target))
+		}
+	case "redirect":
+		if r.ToPorts != "" {
+			args = append(args, fmt.Sprintf("-j REDIRECT --to-ports %s", r.ToPorts))
+		} else {
+			args = append(args, "-j REDIRECT")
+		}
+	case "accept":
+		args = append(args, "-j ACCEPT")
+	case "return":
+		args = append(args, "-j RETURN")
+	default:
+		args = append(args, "-j MASQUERADE")
+	}
+
+	return strings.Join(args, " ")
+}
+
+func buildMangleRuleLine(r MangleRule) string {
+	chain := strings.ToUpper(r.Chain)
+	if chain == "" {
+		chain = "PREROUTING"
+	}
+
+	var args []string
+	args = append(args, fmt.Sprintf("-A %s", chain))
+
+	args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, r.InInterface, r.OutInterface, r.ConnectionState, r.ConnectionMark, r.RoutingMark, r.SrcAddressList, r.DstAddressList)
+	args = append(args, fmt.Sprintf("-m comment --comment \"id:%s\"", r.ID))
+
+	act := strings.ToLower(r.Action)
+	switch act {
+	case "mark-connection":
+		if r.NewConnectionMark != "" {
+			markID := MarkNameToID(r.NewConnectionMark)
+			args = append(args, fmt.Sprintf("-j CONNMARK --set-mark %d", markID))
+		}
+	case "mark-routing":
+		if r.NewRoutingMark != "" {
+			markID := MarkNameToID(r.NewRoutingMark)
+			args = append(args, fmt.Sprintf("-j MARK --set-mark %d", markID))
+		}
+	case "mark-packet":
+		if r.NewPacketMark != "" {
+			markID := MarkNameToID(r.NewPacketMark)
+			args = append(args, fmt.Sprintf("-j MARK --set-mark %d", markID))
+		}
+	case "change-mss":
+		args = append(args, "-p tcp --tcp-flags SYN,RST SYN -j TCPMSS")
+		if r.NewMSS == "clamp-to-pmtu" || r.NewMSS == "" {
+			args = append(args, "--clamp-mss-to-pmtu")
+		} else {
+			args = append(args, fmt.Sprintf("--set-mss %s", r.NewMSS))
+		}
+	case "set-dscp":
+		if r.NewDSCP > 0 {
+			args = append(args, fmt.Sprintf("-j DSCP --set-dscp %d", r.NewDSCP))
+		}
+	case "accept":
+		args = append(args, "-j ACCEPT")
+	case "return":
+		args = append(args, "-j RETURN")
+	default:
+		return ""
+	}
+
+	return strings.Join(args, " ")
+}
+
+func appendMatchCriteria(args []string, srcAddr, dstAddr, proto, srcPort, dstPort, inIface, outIface string, ctStates []string, connMark, routingMark, srcList, dstList string) []string {
+	if srcAddr != "" {
+		if strings.HasPrefix(srcAddr, "!") {
+			args = append(args, fmt.Sprintf("! -s %s", strings.TrimPrefix(srcAddr, "!")))
+		} else {
+			args = append(args, fmt.Sprintf("-s %s", srcAddr))
+		}
+	}
+
+	if dstAddr != "" {
+		if strings.HasPrefix(dstAddr, "!") {
+			args = append(args, fmt.Sprintf("! -d %s", strings.TrimPrefix(dstAddr, "!")))
+		} else {
+			args = append(args, fmt.Sprintf("-d %s", dstAddr))
+		}
+	}
+
+	effectiveProto := strings.ToLower(proto)
+	if effectiveProto != "" && effectiveProto != "all" {
+		args = append(args, fmt.Sprintf("-p %s", effectiveProto))
+	} else if srcPort != "" || dstPort != "" {
+		effectiveProto = "tcp"
+		args = append(args, "-p tcp")
+	}
+
+	if srcPort != "" && (effectiveProto == "tcp" || effectiveProto == "udp") {
+		if strings.Contains(srcPort, ",") {
+			args = append(args, fmt.Sprintf("-m multiport --sports %s", srcPort))
+		} else {
+			args = append(args, fmt.Sprintf("--sport %s", srcPort))
+		}
+	}
+
+	if dstPort != "" && (effectiveProto == "tcp" || effectiveProto == "udp") {
+		if strings.Contains(dstPort, ",") {
+			args = append(args, fmt.Sprintf("-m multiport --dports %s", dstPort))
+		} else {
+			args = append(args, fmt.Sprintf("--dport %s", dstPort))
+		}
+	}
+
+	if inIface != "" {
+		if strings.HasPrefix(inIface, "!") {
+			args = append(args, fmt.Sprintf("! -i %s", strings.TrimPrefix(inIface, "!")))
+		} else {
+			args = append(args, fmt.Sprintf("-i %s", inIface))
+		}
+	}
+
+	if outIface != "" {
+		if strings.HasPrefix(outIface, "!") {
+			args = append(args, fmt.Sprintf("! -o %s", strings.TrimPrefix(outIface, "!")))
+		} else {
+			args = append(args, fmt.Sprintf("-o %s", outIface))
+		}
+	}
+
+	if len(ctStates) > 0 {
+		var upperStates []string
+		for _, s := range ctStates {
+			upperStates = append(upperStates, strings.ToUpper(s))
+		}
+		args = append(args, fmt.Sprintf("-m conntrack --ctstate %s", strings.Join(upperStates, ",")))
+	}
+
+	if connMark != "" {
+		markID := MarkNameToID(connMark)
+		args = append(args, fmt.Sprintf("-m connmark --mark %d", markID))
+	}
+
+	if routingMark != "" {
+		markID := MarkNameToID(routingMark)
+		args = append(args, fmt.Sprintf("-m mark --mark %d", markID))
+	}
+
+	if srcList != "" {
+		args = append(args, fmt.Sprintf("-m set --match-set %s src", GetIPSetName(srcList)))
+	}
+
+	if dstList != "" {
+		args = append(args, fmt.Sprintf("-m set --match-set %s dst", GetIPSetName(dstList)))
+	}
+
+	return args
+}
+
+// ApplyIPTables executes `iptables-restore` with the given rule set payload
+func ApplyIPTables(payload string) error {
+	cmd := exec.Command("iptables-restore", "-c")
+	cmd.Stdin = strings.NewReader(payload)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("failed to apply iptables: %s: %w", string(out), err)
+	}
+	return nil
+}

+ 512 - 0
internal/firewall/manager.go

@@ -0,0 +1,512 @@
+package firewall
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"os/exec"
+	"sync"
+	"time"
+)
+
+const (
+	FilterStorePath       = "/etc/proxmox-router/firewall/filter.json"
+	NATStorePath          = "/etc/proxmox-router/firewall/nat.json"
+	MangleStorePath       = "/etc/proxmox-router/firewall/mangle.json"
+	AddressListsStorePath = "/etc/proxmox-router/firewall/address_lists.json"
+)
+
+type FirewallManager struct {
+	mu           sync.Mutex
+	filterRules  []FilterRule
+	natRules     []NATRule
+	mangleRules  []MangleRule
+	addressLists []AddressListEntry
+}
+
+func NewFirewallManager() *FirewallManager {
+	m := &FirewallManager{
+		filterRules:  make([]FilterRule, 0),
+		natRules:     make([]NATRule, 0),
+		mangleRules:  make([]MangleRule, 0),
+		addressLists: make([]AddressListEntry, 0),
+	}
+	m.load()
+
+	// If clean installation and no rules exist, initialize default sensible router rules (like RouterOS default firewall)
+	if len(m.filterRules) == 0 && len(m.natRules) == 0 {
+		m.initDefaultRules()
+	}
+
+	_ = m.Apply()
+	return m
+}
+
+func (m *FirewallManager) initDefaultRules() {
+	// Default Masquerade NAT
+	m.natRules = append(m.natRules, NATRule{
+		ID:       fmt.Sprintf("nat-%d", time.Now().UnixNano()),
+		Order:    1,
+		Chain:    "srcnat",
+		Action:   "masquerade",
+		Comment:  "defconf: masquerade outgoing traffic",
+		Disabled: false,
+	})
+
+	// Default Fasttrack / Established,Related Filter
+	m.filterRules = append(m.filterRules, FilterRule{
+		ID:              fmt.Sprintf("filter-%d-1", time.Now().UnixNano()),
+		Order:           1,
+		Chain:           "input",
+		Action:          "accept",
+		ConnectionState: []string{"established", "related"},
+		Comment:         "defconf: accept established,related",
+		Disabled:        false,
+	})
+
+	m.filterRules = append(m.filterRules, FilterRule{
+		ID:              fmt.Sprintf("filter-%d-2", time.Now().UnixNano()),
+		Order:           2,
+		Chain:           "forward",
+		Action:          "accept",
+		ConnectionState: []string{"established", "related"},
+		Comment:         "defconf: accept established,related forward",
+		Disabled:        false,
+	})
+
+	_ = m.save()
+}
+
+func (m *FirewallManager) load() {
+	if data, err := os.ReadFile(FilterStorePath); err == nil {
+		_ = json.Unmarshal(data, &m.filterRules)
+	}
+	if data, err := os.ReadFile(NATStorePath); err == nil {
+		_ = json.Unmarshal(data, &m.natRules)
+	}
+	if data, err := os.ReadFile(MangleStorePath); err == nil {
+		_ = json.Unmarshal(data, &m.mangleRules)
+	}
+	if data, err := os.ReadFile(AddressListsStorePath); err == nil {
+		_ = json.Unmarshal(data, &m.addressLists)
+	}
+}
+
+func (m *FirewallManager) save() error {
+	_ = os.MkdirAll("/etc/proxmox-router/firewall", 0755)
+
+	if d, err := json.MarshalIndent(m.filterRules, "", "  "); err == nil {
+		_ = os.WriteFile(FilterStorePath, d, 0600)
+	}
+	if d, err := json.MarshalIndent(m.natRules, "", "  "); err == nil {
+		_ = os.WriteFile(NATStorePath, d, 0600)
+	}
+	if d, err := json.MarshalIndent(m.mangleRules, "", "  "); err == nil {
+		_ = os.WriteFile(MangleStorePath, d, 0600)
+	}
+	if d, err := json.MarshalIndent(m.addressLists, "", "  "); err == nil {
+		_ = os.WriteFile(AddressListsStorePath, d, 0600)
+	}
+	return nil
+}
+
+// Apply re-synchronizes AddressLists with ipset and applies iptables
+func (m *FirewallManager) Apply() error {
+	// Sync ipset address lists
+	_ = SyncAddressLists(m.addressLists)
+
+	payload := BuildIPTablesRestore(m.filterRules, m.natRules, m.mangleRules)
+	return ApplyIPTables(payload)
+}
+
+// ==================== Filter Rules ====================
+
+func (m *FirewallManager) ListFilterRules() []FilterRule {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	counters, _ := ReadRuleCounters(m.filterRules, m.natRules, m.mangleRules)
+
+	res := make([]FilterRule, len(m.filterRules))
+	for i, r := range m.filterRules {
+		res[i] = r
+		if c, ok := counters[r.ID]; ok {
+			res[i].Packets = c[0]
+			res[i].Bytes = c[1]
+		}
+	}
+	return res
+}
+
+func (m *FirewallManager) SaveFilterRule(r FilterRule) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if r.ID == "" {
+		r.ID = fmt.Sprintf("filter-%d", time.Now().UnixNano())
+	}
+
+	found := false
+	for i, existing := range m.filterRules {
+		if existing.ID == r.ID {
+			r.Order = existing.Order
+			m.filterRules[i] = r
+			found = true
+			break
+		}
+	}
+	if !found {
+		r.Order = len(m.filterRules) + 1
+		m.filterRules = append(m.filterRules, r)
+	}
+
+	_ = m.save()
+	return m.Apply()
+}
+
+func (m *FirewallManager) DeleteFilterRule(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	var updated []FilterRule
+	for _, r := range m.filterRules {
+		if r.ID != id {
+			updated = append(updated, r)
+		}
+	}
+	for i := range updated {
+		updated[i].Order = i + 1
+	}
+	m.filterRules = updated
+
+	_ = m.save()
+	return m.Apply()
+}
+
+func (m *FirewallManager) MoveFilterRule(id string, dir string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	idx := -1
+	for i, r := range m.filterRules {
+		if r.ID == id {
+			idx = i
+			break
+		}
+	}
+	if idx == -1 {
+		return fmt.Errorf("rule not found")
+	}
+
+	if dir == "up" && idx > 0 {
+		m.filterRules[idx], m.filterRules[idx-1] = m.filterRules[idx-1], m.filterRules[idx]
+	} else if dir == "down" && idx < len(m.filterRules)-1 {
+		m.filterRules[idx], m.filterRules[idx+1] = m.filterRules[idx+1], m.filterRules[idx]
+	}
+
+	for i := range m.filterRules {
+		m.filterRules[i].Order = i + 1
+	}
+
+	_ = m.save()
+	return m.Apply()
+}
+
+func (m *FirewallManager) SetFilterRuleState(id string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	for i := range m.filterRules {
+		if m.filterRules[i].ID == id {
+			m.filterRules[i].Disabled = disabled
+			_ = m.save()
+			return m.Apply()
+		}
+	}
+	return fmt.Errorf("rule not found")
+}
+
+// ==================== NAT Rules ====================
+
+func (m *FirewallManager) ListNATRules() []NATRule {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	counters, _ := ReadRuleCounters(m.filterRules, m.natRules, m.mangleRules)
+
+	res := make([]NATRule, len(m.natRules))
+	for i, r := range m.natRules {
+		res[i] = r
+		if c, ok := counters[r.ID]; ok {
+			res[i].Packets = c[0]
+			res[i].Bytes = c[1]
+		}
+	}
+	return res
+}
+
+func (m *FirewallManager) SaveNATRule(r NATRule) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if r.ID == "" {
+		r.ID = fmt.Sprintf("nat-%d", time.Now().UnixNano())
+	}
+
+	found := false
+	for i, existing := range m.natRules {
+		if existing.ID == r.ID {
+			r.Order = existing.Order
+			m.natRules[i] = r
+			found = true
+			break
+		}
+	}
+	if !found {
+		r.Order = len(m.natRules) + 1
+		m.natRules = append(m.natRules, r)
+	}
+
+	_ = m.save()
+	return m.Apply()
+}
+
+func (m *FirewallManager) DeleteNATRule(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	var updated []NATRule
+	for _, r := range m.natRules {
+		if r.ID != id {
+			updated = append(updated, r)
+		}
+	}
+	for i := range updated {
+		updated[i].Order = i + 1
+	}
+	m.natRules = updated
+
+	_ = m.save()
+	return m.Apply()
+}
+
+func (m *FirewallManager) MoveNATRule(id string, dir string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	idx := -1
+	for i, r := range m.natRules {
+		if r.ID == id {
+			idx = i
+			break
+		}
+	}
+	if idx == -1 {
+		return fmt.Errorf("rule not found")
+	}
+
+	if dir == "up" && idx > 0 {
+		m.natRules[idx], m.natRules[idx-1] = m.natRules[idx-1], m.natRules[idx]
+	} else if dir == "down" && idx < len(m.natRules)-1 {
+		m.natRules[idx], m.natRules[idx+1] = m.natRules[idx+1], m.natRules[idx]
+	}
+
+	for i := range m.natRules {
+		m.natRules[i].Order = i + 1
+	}
+
+	_ = m.save()
+	return m.Apply()
+}
+
+func (m *FirewallManager) SetNATRuleState(id string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	for i := range m.natRules {
+		if m.natRules[i].ID == id {
+			m.natRules[i].Disabled = disabled
+			_ = m.save()
+			return m.Apply()
+		}
+	}
+	return fmt.Errorf("rule not found")
+}
+
+// ==================== Mangle Rules ====================
+
+func (m *FirewallManager) ListMangleRules() []MangleRule {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	counters, _ := ReadRuleCounters(m.filterRules, m.natRules, m.mangleRules)
+
+	res := make([]MangleRule, len(m.mangleRules))
+	for i, r := range m.mangleRules {
+		res[i] = r
+		if c, ok := counters[r.ID]; ok {
+			res[i].Packets = c[0]
+			res[i].Bytes = c[1]
+		}
+	}
+	return res
+}
+
+func (m *FirewallManager) SaveMangleRule(r MangleRule) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if r.ID == "" {
+		r.ID = fmt.Sprintf("mangle-%d", time.Now().UnixNano())
+	}
+
+	found := false
+	for i, existing := range m.mangleRules {
+		if existing.ID == r.ID {
+			r.Order = existing.Order
+			m.mangleRules[i] = r
+			found = true
+			break
+		}
+	}
+	if !found {
+		r.Order = len(m.mangleRules) + 1
+		m.mangleRules = append(m.mangleRules, r)
+	}
+
+	_ = m.save()
+	return m.Apply()
+}
+
+func (m *FirewallManager) DeleteMangleRule(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	var updated []MangleRule
+	for _, r := range m.mangleRules {
+		if r.ID != id {
+			updated = append(updated, r)
+		}
+	}
+	for i := range updated {
+		updated[i].Order = i + 1
+	}
+	m.mangleRules = updated
+
+	_ = m.save()
+	return m.Apply()
+}
+
+func (m *FirewallManager) MoveMangleRule(id string, dir string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	idx := -1
+	for i, r := range m.mangleRules {
+		if r.ID == id {
+			idx = i
+			break
+		}
+	}
+	if idx == -1 {
+		return fmt.Errorf("rule not found")
+	}
+
+	if dir == "up" && idx > 0 {
+		m.mangleRules[idx], m.mangleRules[idx-1] = m.mangleRules[idx-1], m.mangleRules[idx]
+	} else if dir == "down" && idx < len(m.mangleRules)-1 {
+		m.mangleRules[idx], m.mangleRules[idx+1] = m.mangleRules[idx+1], m.mangleRules[idx]
+	}
+
+	for i := range m.mangleRules {
+		m.mangleRules[i].Order = i + 1
+	}
+
+	_ = m.save()
+	return m.Apply()
+}
+
+func (m *FirewallManager) SetMangleRuleState(id string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	for i := range m.mangleRules {
+		if m.mangleRules[i].ID == id {
+			m.mangleRules[i].Disabled = disabled
+			_ = m.save()
+			return m.Apply()
+		}
+	}
+	return fmt.Errorf("rule not found")
+}
+
+// ==================== Address Lists ====================
+
+func (m *FirewallManager) ListAddressLists() []AddressListEntry {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	res := make([]AddressListEntry, len(m.addressLists))
+	copy(res, m.addressLists)
+	return res
+}
+
+func (m *FirewallManager) SaveAddressListEntry(e AddressListEntry) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if e.ID == "" {
+		e.ID = fmt.Sprintf("addrlist-%d", time.Now().UnixNano())
+	}
+
+	found := false
+	for i, existing := range m.addressLists {
+		if existing.ID == e.ID {
+			m.addressLists[i] = e
+			found = true
+			break
+		}
+	}
+	if !found {
+		m.addressLists = append(m.addressLists, e)
+	}
+
+	_ = m.save()
+	return m.Apply()
+}
+
+func (m *FirewallManager) DeleteAddressListEntry(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	var updated []AddressListEntry
+	for _, e := range m.addressLists {
+		if e.ID != id {
+			updated = append(updated, e)
+		}
+	}
+	m.addressLists = updated
+
+	_ = m.save()
+	return m.Apply()
+}
+
+func (m *FirewallManager) ResetCounters() error {
+	_ = exec.Command("iptables", "-Z").Run()
+	return nil
+}
+
+func (m *FirewallManager) GetSummary() FirewallSummary {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	conns, _ := ListActiveConnections()
+
+	return FirewallSummary{
+		FilterRulesCount:  len(m.filterRules),
+		NATRulesCount:     len(m.natRules),
+		MangleRulesCount:  len(m.mangleRules),
+		AddressListsCount: len(m.addressLists),
+		ActiveConnections: len(conns),
+	}
+}

+ 120 - 0
internal/firewall/types.go

@@ -0,0 +1,120 @@
+package firewall
+
+// FilterRule represents a MikroTik-style /ip firewall filter rule
+type FilterRule struct {
+	ID              string   `json:"id"`
+	Order           int      `json:"order"`
+	Chain           string   `json:"chain"` // input, forward, output, or custom
+	Action          string   `json:"action"` // accept, drop, reject, return, jump, log, passthrough
+	RejectWith      string   `json:"reject_with,omitempty"` // icmp-network-unreachable, icmp-host-unreachable, tcp-reset, etc.
+	JumpTarget      string   `json:"jump_target,omitempty"`
+	SrcAddress      string   `json:"src_address,omitempty"` // e.g. 192.168.1.0/24, !10.0.0.1
+	DstAddress      string   `json:"dst_address,omitempty"`
+	Protocol        string   `json:"protocol,omitempty"` // all, tcp, udp, icmp, etc.
+	SrcPort         string   `json:"src_port,omitempty"` // e.g. 80, 80-443, 80,443
+	DstPort         string   `json:"dst_port,omitempty"`
+	InInterface     string   `json:"in_interface,omitempty"` // e.g. eth0, !eth0, wg0
+	OutInterface    string   `json:"out_interface,omitempty"`
+	ConnectionState []string `json:"connection_state,omitempty"` // new, established, related, invalid, untracked
+	ConnectionMark  string   `json:"connection_mark,omitempty"`
+	RoutingMark     string   `json:"routing_mark,omitempty"`
+	SrcAddressList  string   `json:"src_address_list,omitempty"`
+	DstAddressList  string   `json:"dst_address_list,omitempty"`
+	Log             bool     `json:"log"`
+	LogPrefix       string   `json:"log_prefix,omitempty"`
+	Comment         string   `json:"comment,omitempty"`
+	Disabled        bool     `json:"disabled"`
+	Packets         uint64   `json:"packets"`
+	Bytes           uint64   `json:"bytes"`
+}
+
+// NATRule represents a MikroTik-style /ip firewall nat rule
+type NATRule struct {
+	ID             string `json:"id"`
+	Order          int    `json:"order"`
+	Chain          string `json:"chain"` // srcnat, dstnat
+	Action         string `json:"action"` // masquerade, src-nat, dst-nat, redirect, accept, return
+	ToAddresses    string `json:"to_addresses,omitempty"` // for src-nat or dst-nat
+	ToPorts        string `json:"to_ports,omitempty"` // for dst-nat, redirect, src-nat
+	SrcAddress     string `json:"src_address,omitempty"`
+	DstAddress     string `json:"dst_address,omitempty"`
+	Protocol       string `json:"protocol,omitempty"`
+	SrcPort        string `json:"src_port,omitempty"`
+	DstPort        string `json:"dst_port,omitempty"`
+	InInterface    string `json:"in_interface,omitempty"`
+	OutInterface   string `json:"out_interface,omitempty"`
+	SrcAddressList string `json:"src_address_list,omitempty"`
+	DstAddressList string `json:"dst_address_list,omitempty"`
+	Comment        string `json:"comment,omitempty"`
+	Disabled       bool   `json:"disabled"`
+	Packets        uint64 `json:"packets"`
+	Bytes          uint64 `json:"bytes"`
+}
+
+// MangleRule represents a MikroTik-style /ip firewall mangle rule
+type MangleRule struct {
+	ID                 string   `json:"id"`
+	Order              int      `json:"order"`
+	Chain              string   `json:"chain"` // prerouting, postrouting, input, output, forward
+	Action             string   `json:"action"` // mark-connection, mark-routing, mark-packet, change-mss, set-dscp, accept, return
+	NewConnectionMark  string   `json:"new_connection_mark,omitempty"`
+	NewRoutingMark     string   `json:"new_routing_mark,omitempty"`
+	NewPacketMark      string   `json:"new_packet_mark,omitempty"`
+	NewMSS             string   `json:"new_mss,omitempty"` // e.g. "1440" or "clamp-to-pmtu"
+	NewDSCP            int      `json:"new_dscp,omitempty"`
+	Passthrough        bool     `json:"passthrough"` // default true in MikroTik
+	SrcAddress         string   `json:"src_address,omitempty"`
+	DstAddress         string   `json:"dst_address,omitempty"`
+	Protocol           string   `json:"protocol,omitempty"`
+	SrcPort            string   `json:"src_port,omitempty"`
+	DstPort            string   `json:"dst_port,omitempty"`
+	InInterface        string   `json:"in_interface,omitempty"`
+	OutInterface       string   `json:"out_interface,omitempty"`
+	ConnectionState    []string `json:"connection_state,omitempty"`
+	ConnectionMark     string   `json:"connection_mark,omitempty"`
+	RoutingMark        string   `json:"routing_mark,omitempty"`
+	SrcAddressList     string   `json:"src_address_list,omitempty"`
+	DstAddressList     string   `json:"dst_address_list,omitempty"`
+	Comment            string   `json:"comment,omitempty"`
+	Disabled           bool     `json:"disabled"`
+	Packets            uint64   `json:"packets"`
+	Bytes              uint64   `json:"bytes"`
+}
+
+// AddressListEntry represents a MikroTik /ip firewall address-list entry
+type AddressListEntry struct {
+	ID       string `json:"id"`
+	Name     string `json:"name"`    // list name, e.g. "LAN_Subnets", "Blocked"
+	Address  string `json:"address"` // IP or CIDR, e.g. "192.168.1.0/24"
+	Comment  string `json:"comment,omitempty"`
+	Disabled bool   `json:"disabled"`
+}
+
+// ActiveConnection represents an active flow from /proc/net/nf_conntrack / conntrack table
+type ActiveConnection struct {
+	ID               string `json:"id"`
+	Protocol         string `json:"protocol"`
+	SrcAddress       string `json:"src_address"`
+	SrcPort          int    `json:"src_port"`
+	DstAddress       string `json:"dst_address"`
+	DstPort          int    `json:"dst_port"`
+	ReplySrcAddress  string `json:"reply_src_address"`
+	ReplySrcPort     int    `json:"reply_src_port"`
+	ReplyDstAddress  string `json:"reply_dst_address"`
+	ReplyDstPort     int    `json:"reply_dst_port"`
+	TCPState         string `json:"tcp_state"`
+	Timeout          int    `json:"timeout"`
+	ConnectionMark   string `json:"connection_mark,omitempty"`
+	Assured          bool   `json:"assured"`
+	Bytes            uint64 `json:"bytes,omitempty"`
+	Packets          uint64 `json:"packets,omitempty"`
+}
+
+// FirewallSummary contains total rule and connection counts
+type FirewallSummary struct {
+	FilterRulesCount   int `json:"filter_rules_count"`
+	NATRulesCount      int `json:"nat_rules_count"`
+	MangleRulesCount   int `json:"mangle_rules_count"`
+	AddressListsCount  int `json:"address_lists_count"`
+	ActiveConnections  int `json:"active_connections"`
+}

+ 548 - 0
internal/routing/manager.go

@@ -0,0 +1,548 @@
+package routing
+
+import (
+	"bufio"
+	"encoding/json"
+	"fmt"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/vishvananda/netlink"
+)
+
+const (
+	StaticRoutesStorePath = "/etc/proxmox-router/routing/static_routes.json"
+	PolicyRulesStorePath  = "/etc/proxmox-router/routing/policy_rules.json"
+	CustomTablesStorePath = "/etc/proxmox-router/routing/custom_tables.json"
+	RTTableFilePath       = "/etc/iproute2/rt_tables"
+)
+
+type RoutingManager struct {
+	mu           sync.Mutex
+	staticRoutes []RouteItem
+	policyRules  []PolicyRuleItem
+	customTables []RoutingTableItem
+}
+
+func NewRoutingManager() *RoutingManager {
+	m := &RoutingManager{
+		staticRoutes: make([]RouteItem, 0),
+		policyRules:  make([]PolicyRuleItem, 0),
+		customTables: make([]RoutingTableItem, 0),
+	}
+	m.initRTTableFile()
+	m.load()
+	m.syncCustomTablesToSystem()
+	m.applyAll()
+	return m
+}
+
+func (m *RoutingManager) initRTTableFile() {
+	_ = os.MkdirAll(filepath.Dir(RTTableFilePath), 0755)
+	if _, err := os.Stat(RTTableFilePath); os.IsNotExist(err) {
+		// Copy from /usr/share/iproute2/rt_tables or seed default
+		content, err := os.ReadFile("/usr/share/iproute2/rt_tables")
+		if err == nil {
+			_ = os.WriteFile(RTTableFilePath, content, 0644)
+		} else {
+			defaultContent := "# reserved values\n255\tlocal\n254\tmain\n253\tdefault\n0\tunspec\n"
+			_ = os.WriteFile(RTTableFilePath, []byte(defaultContent), 0644)
+		}
+	}
+}
+
+func (m *RoutingManager) load() {
+	if data, err := os.ReadFile(StaticRoutesStorePath); err == nil {
+		_ = json.Unmarshal(data, &m.staticRoutes)
+	}
+	if data, err := os.ReadFile(PolicyRulesStorePath); err == nil {
+		_ = json.Unmarshal(data, &m.policyRules)
+	}
+	if data, err := os.ReadFile(CustomTablesStorePath); err == nil {
+		_ = json.Unmarshal(data, &m.customTables)
+	}
+}
+
+func (m *RoutingManager) save() error {
+	_ = os.MkdirAll("/etc/proxmox-router/routing", 0755)
+
+	if d, err := json.MarshalIndent(m.staticRoutes, "", "  "); err == nil {
+		_ = os.WriteFile(StaticRoutesStorePath, d, 0600)
+	}
+	if d, err := json.MarshalIndent(m.policyRules, "", "  "); err == nil {
+		_ = os.WriteFile(PolicyRulesStorePath, d, 0600)
+	}
+	if d, err := json.MarshalIndent(m.customTables, "", "  "); err == nil {
+		_ = os.WriteFile(CustomTablesStorePath, d, 0600)
+	}
+	return nil
+}
+
+func (m *RoutingManager) syncCustomTablesToSystem() {
+	content, _ := os.ReadFile(RTTableFilePath)
+	str := string(content)
+
+	var sb strings.Builder
+	sb.WriteString(str)
+
+	for _, t := range m.customTables {
+		check := fmt.Sprintf("\t%s", t.Name)
+		if !strings.Contains(str, check) {
+			sb.WriteString(fmt.Sprintf("%d\t%s\n", t.TableID, t.Name))
+		}
+	}
+
+	_ = os.WriteFile(RTTableFilePath, []byte(sb.String()), 0644)
+}
+
+func (m *RoutingManager) applyAll() {
+	// Apply all static routes
+	for _, r := range m.staticRoutes {
+		if !r.Disabled {
+			_ = m.applyRouteToKernel(r)
+		}
+	}
+
+	// Apply policy rules
+	for _, pr := range m.policyRules {
+		_ = m.applyRuleToKernel(pr)
+	}
+}
+
+// ==================== Routes (/ip route) ====================
+
+func (m *RoutingManager) ListRoutes() []RouteItem {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	// 1. Get live routes from netlink across all tables
+	tableNames := m.getTableMap()
+	links, _ := netlink.LinkList()
+	linkMap := make(map[int]string)
+	for _, l := range links {
+		linkMap[l.Attrs().Index] = l.Attrs().Name
+	}
+
+	var liveRoutes []RouteItem
+	routes, err := netlink.RouteList(nil, netlink.FAMILY_V4)
+	if err == nil {
+		for _, r := range routes {
+			dst := "0.0.0.0/0"
+			if r.Dst != nil {
+				dst = r.Dst.String()
+			}
+			gw := ""
+			if r.Gw != nil {
+				gw = r.Gw.String()
+			}
+
+			tableName := "main"
+			if name, ok := tableNames[r.Table]; ok {
+				tableName = name
+			} else if r.Table != 254 && r.Table != 0 {
+				tableName = strconv.Itoa(r.Table)
+			}
+
+			ifName := linkMap[r.LinkIndex]
+			isDirect := r.Scope == netlink.SCOPE_LINK
+
+			liveRoutes = append(liveRoutes, RouteItem{
+				ID:           fmt.Sprintf("kr-%d-%s-%s", r.Table, dst, gw),
+				DstAddress:   dst,
+				Gateway:      gw,
+				Interface:    ifName,
+				Distance:     r.Priority,
+				RoutingTable: tableName,
+				TableID:      r.Table,
+				Active:       true,
+				Connect:      isDirect,
+				Dynamic:      !isDirect && (r.Protocol == netlink.RouteProtocol(2) || r.Protocol == netlink.RouteProtocol(3)),
+				Static:       !isDirect && r.Protocol != netlink.RouteProtocol(2) && r.Protocol != netlink.RouteProtocol(3),
+				Disabled:     false,
+			})
+		}
+	}
+
+	// Merge static routes that might be disabled or pending
+	for _, sr := range m.staticRoutes {
+		found := false
+		for i, lr := range liveRoutes {
+			if lr.DstAddress == sr.DstAddress && lr.RoutingTable == sr.RoutingTable && (lr.Gateway == sr.Gateway || sr.Gateway == "") {
+				liveRoutes[i].Comment = sr.Comment
+				liveRoutes[i].Static = true
+				liveRoutes[i].ID = sr.ID
+				found = true
+				break
+			}
+		}
+		if !found {
+			// Inactive or disabled static route
+			srCopy := sr
+			srCopy.Active = false
+			srCopy.Static = true
+			liveRoutes = append(liveRoutes, srCopy)
+		}
+	}
+
+	return liveRoutes
+}
+
+func (m *RoutingManager) SaveRoute(r RouteItem) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if r.DstAddress == "" {
+		return fmt.Errorf("destination address is required")
+	}
+	if r.RoutingTable == "" {
+		r.RoutingTable = "main"
+	}
+	if r.ID == "" {
+		r.ID = fmt.Sprintf("route-%d", time.Now().UnixNano())
+	}
+	r.Static = true
+
+	found := false
+	for i, existing := range m.staticRoutes {
+		if existing.ID == r.ID {
+			m.staticRoutes[i] = r
+			found = true
+			break
+		}
+	}
+	if !found {
+		m.staticRoutes = append(m.staticRoutes, r)
+	}
+
+	_ = m.save()
+
+	if r.Disabled {
+		_ = m.removeRouteFromKernel(r)
+	} else {
+		if err := m.applyRouteToKernel(r); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (m *RoutingManager) DeleteRoute(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	var updated []RouteItem
+	var target *RouteItem
+	for _, r := range m.staticRoutes {
+		if r.ID == id {
+			target = &r
+		} else {
+			updated = append(updated, r)
+		}
+	}
+
+	if target != nil {
+		_ = m.removeRouteFromKernel(*target)
+	}
+	m.staticRoutes = updated
+	return m.save()
+}
+
+func (m *RoutingManager) SetRouteState(id string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	for i := range m.staticRoutes {
+		if m.staticRoutes[i].ID == id {
+			m.staticRoutes[i].Disabled = disabled
+			_ = m.save()
+			if disabled {
+				_ = m.removeRouteFromKernel(m.staticRoutes[i])
+			} else {
+				_ = m.applyRouteToKernel(m.staticRoutes[i])
+			}
+			return nil
+		}
+	}
+	return fmt.Errorf("route not found")
+}
+
+func (m *RoutingManager) applyRouteToKernel(r RouteItem) error {
+	args := []string{"route", "replace"}
+
+	if r.DstAddress == "default" || r.DstAddress == "0.0.0.0/0" {
+		args = append(args, "default")
+	} else {
+		args = append(args, r.DstAddress)
+	}
+
+	if r.Gateway != "" {
+		// check if gateway is IP or device
+		if net.ParseIP(r.Gateway) != nil {
+			args = append(args, "via", r.Gateway)
+		} else {
+			args = append(args, "dev", r.Gateway)
+		}
+	}
+
+	if r.Interface != "" && r.Gateway != r.Interface {
+		args = append(args, "dev", r.Interface)
+	}
+
+	if r.Distance > 0 {
+		args = append(args, "metric", strconv.Itoa(r.Distance))
+	}
+
+	if r.RoutingTable != "" && r.RoutingTable != "main" {
+		args = append(args, "table", r.RoutingTable)
+	}
+
+	cmd := exec.Command("ip", args...)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("failed to apply route (%s): %s: %w", strings.Join(args, " "), string(out), err)
+	}
+	return nil
+}
+
+func (m *RoutingManager) removeRouteFromKernel(r RouteItem) error {
+	args := []string{"route", "del"}
+	if r.DstAddress == "default" || r.DstAddress == "0.0.0.0/0" {
+		args = append(args, "default")
+	} else {
+		args = append(args, r.DstAddress)
+	}
+
+	if r.RoutingTable != "" && r.RoutingTable != "main" {
+		args = append(args, "table", r.RoutingTable)
+	}
+
+	_ = exec.Command("ip", args...).Run()
+	return nil
+}
+
+// ==================== Policy Rules (/ip route rule) ====================
+
+func (m *RoutingManager) ListPolicyRules() []PolicyRuleItem {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	// Query live rules from kernel
+	rules, err := netlink.RuleList(netlink.FAMILY_V4)
+	if err != nil {
+		return m.policyRules
+	}
+
+	tableNames := m.getTableMap()
+	var res []PolicyRuleItem
+
+	for _, r := range rules {
+		tableName := "main"
+		if name, ok := tableNames[r.Table]; ok {
+			tableName = name
+		} else if r.Table != 254 && r.Table != 0 {
+			tableName = strconv.Itoa(r.Table)
+		}
+
+		src := ""
+		if r.Src != nil {
+			src = r.Src.String()
+		}
+		dst := ""
+		if r.Dst != nil {
+			dst = r.Dst.String()
+		}
+
+		markStr := ""
+		if r.Mark > 0 {
+			markStr = fmt.Sprintf("0x%x (%d)", r.Mark, r.Mark)
+		}
+
+		id := fmt.Sprintf("rule-%d-%d", r.Priority, r.Table)
+
+		res = append(res, PolicyRuleItem{
+			ID:          id,
+			Priority:    r.Priority,
+			SrcAddress:  src,
+			DstAddress:  dst,
+			RoutingMark: markStr,
+			Table:       tableName,
+			TableID:     r.Table,
+			Action:      "lookup",
+		})
+	}
+
+	return res
+}
+
+func (m *RoutingManager) SavePolicyRule(pr PolicyRuleItem) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if pr.Table == "" {
+		pr.Table = "main"
+	}
+	if pr.ID == "" {
+		pr.ID = fmt.Sprintf("prule-%d", time.Now().UnixNano())
+	}
+	if pr.Priority == 0 {
+		pr.Priority = 1000 + len(m.policyRules)*10
+	}
+
+	if err := m.applyRuleToKernel(pr); err != nil {
+		return err
+	}
+
+	m.policyRules = append(m.policyRules, pr)
+	return m.save()
+}
+
+func (m *RoutingManager) DeletePolicyRule(id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	var updated []PolicyRuleItem
+	var target *PolicyRuleItem
+	for _, pr := range m.policyRules {
+		if pr.ID == id {
+			target = &pr
+		} else {
+			updated = append(updated, pr)
+		}
+	}
+
+	if target != nil {
+		_ = m.removeRuleFromKernel(*target)
+	}
+	m.policyRules = updated
+	return m.save()
+}
+
+func (m *RoutingManager) applyRuleToKernel(pr PolicyRuleItem) error {
+	args := []string{"rule", "add"}
+	if pr.Priority > 0 {
+		args = append(args, "pref", strconv.Itoa(pr.Priority))
+	}
+	if pr.SrcAddress != "" {
+		args = append(args, "from", pr.SrcAddress)
+	}
+	if pr.DstAddress != "" {
+		args = append(args, "to", pr.DstAddress)
+	}
+	if pr.RoutingMark != "" {
+		// Clean mark string if it has (hex)
+		markClean := pr.RoutingMark
+		if strings.Contains(markClean, "(") {
+			markClean = strings.TrimSpace(markClean[:strings.Index(markClean, "(")])
+		}
+		args = append(args, "fwmark", markClean)
+	}
+	if pr.Table != "" {
+		args = append(args, "lookup", pr.Table)
+	}
+
+	cmd := exec.Command("ip", args...)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("failed to add rule (%s): %s: %w", strings.Join(args, " "), string(out), err)
+	}
+	return nil
+}
+
+func (m *RoutingManager) removeRuleFromKernel(pr PolicyRuleItem) error {
+	args := []string{"rule", "del"}
+	if pr.Priority > 0 {
+		args = append(args, "pref", strconv.Itoa(pr.Priority))
+	}
+	if pr.RoutingMark != "" {
+		markClean := pr.RoutingMark
+		if strings.Contains(markClean, "(") {
+			markClean = strings.TrimSpace(markClean[:strings.Index(markClean, "(")])
+		}
+		args = append(args, "fwmark", markClean)
+	}
+	if pr.Table != "" {
+		args = append(args, "lookup", pr.Table)
+	}
+
+	_ = exec.Command("ip", args...).Run()
+	return nil
+}
+
+// ==================== Routing Tables ====================
+
+func (m *RoutingManager) ListTables() []RoutingTableItem {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	tables := []RoutingTableItem{
+		{Name: "main", TableID: 254, Comment: "Default system routing table"},
+		{Name: "default", TableID: 253, Comment: "Default fallback table"},
+		{Name: "local", TableID: 255, Comment: "Local kernel device table"},
+	}
+
+	tables = append(tables, m.customTables...)
+	return tables
+}
+
+func (m *RoutingManager) SaveTable(t RoutingTableItem) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if t.Name == "" {
+		return fmt.Errorf("table name is required")
+	}
+	if t.TableID == 0 {
+		// Auto assign FIB ID starting at 100
+		t.TableID = 100 + len(m.customTables)
+	}
+
+	for _, existing := range m.customTables {
+		if existing.Name == t.Name || existing.TableID == t.TableID {
+			return fmt.Errorf("table with name %q or ID %d already exists", t.Name, t.TableID)
+		}
+	}
+
+	m.customTables = append(m.customTables, t)
+	_ = m.save()
+	m.syncCustomTablesToSystem()
+	return nil
+}
+
+func (m *RoutingManager) getTableMap() map[int]string {
+	tableMap := map[int]string{
+		254: "main",
+		253: "default",
+		255: "local",
+	}
+
+	// Read /etc/iproute2/rt_tables
+	if f, err := os.Open(RTTableFilePath); err == nil {
+		defer f.Close()
+		scanner := bufio.NewScanner(f)
+		for scanner.Scan() {
+			line := strings.TrimSpace(scanner.Text())
+			if line == "" || strings.HasPrefix(line, "#") {
+				continue
+			}
+			parts := strings.Fields(line)
+			if len(parts) >= 2 {
+				if id, err := strconv.Atoi(parts[0]); err == nil {
+					tableMap[id] = parts[1]
+				}
+			}
+		}
+	}
+
+	for _, t := range m.customTables {
+		tableMap[t.TableID] = t.Name
+	}
+
+	return tableMap
+}

+ 38 - 0
internal/routing/types.go

@@ -0,0 +1,38 @@
+package routing
+
+// RouteItem represents a MikroTik-style /ip route entry
+type RouteItem struct {
+	ID           string `json:"id"`
+	DstAddress   string `json:"dst_address"`   // CIDR e.g. "0.0.0.0/0", "192.168.10.0/24"
+	Gateway      string `json:"gateway"`       // Gateway IP e.g. "192.168.1.1" or interface "wg0"
+	Interface    string `json:"interface"`     // Output device
+	Distance     int    `json:"distance"`      // Metric / Administrative Distance
+	RoutingTable string `json:"routing_table"` // Table name e.g. "main", "wan2", "vpn_out"
+	TableID      int    `json:"table_id"`
+	Active       bool   `json:"active"`        // MikroTik 'A' flag
+	Dynamic      bool   `json:"dynamic"`       // MikroTik 'D' flag (kernel learned)
+	Connect      bool   `json:"connect"`       // MikroTik 'C' flag (directly connected interface subnet)
+	Static       bool   `json:"static"`        // MikroTik 'S' flag (user defined)
+	Disabled     bool   `json:"disabled"`      // MikroTik 'X' flag
+	Comment      string `json:"comment,omitempty"`
+}
+
+// PolicyRuleItem represents a MikroTik /ip route rule (Linux `ip rule`)
+type PolicyRuleItem struct {
+	ID          string `json:"id"`
+	Priority    int    `json:"priority"`     // Preference number (e.g. 1000, 32766)
+	SrcAddress  string `json:"src_address,omitempty"`
+	DstAddress  string `json:"dst_address,omitempty"`
+	RoutingMark string `json:"routing_mark,omitempty"` // Matches fwmark set in Mangle
+	Table       string `json:"table"`        // Table to lookup (e.g. "wan2", "main")
+	TableID     int    `json:"table_id"`
+	Action      string `json:"action"`       // lookup, unreachable, blackhole, prohibit
+	Comment     string `json:"comment,omitempty"`
+}
+
+// RoutingTableItem represents a named routing table in Linux (/etc/iproute2/rt_tables)
+type RoutingTableItem struct {
+	Name    string `json:"name"`
+	TableID int    `json:"table_id"`
+	Comment string `json:"comment,omitempty"`
+}

+ 1307 - 23
internal/web/static/app.js

@@ -78,6 +78,7 @@ function navigate(tabName) {
     }
   });
 
+  if (tabName === 'firewall') loadFirewall();
   if (tabName === 'routes') loadRoutes();
   if (tabName === 'persistence') loadDebianConfig();
   if (tabName === 'pppoe') loadPPPoE();
@@ -93,11 +94,12 @@ async function fetchData(isManual = false) {
   if (isManual && spinner) spinner.classList.add('animate-spin');
 
   try {
-    const [statusRes, ifacesRes, vlansRes, vpnRes] = await Promise.all([
+    const [statusRes, ifacesRes, vlansRes, vpnRes, fwRes] = await Promise.all([
       fetch(`${API_BASE}/system/status`),
       fetch(`${API_BASE}/interfaces`),
       fetch(`${API_BASE}/vlans`),
-      fetch(`${API_BASE}/vpn/status`)
+      fetch(`${API_BASE}/vpn/status`),
+      fetch(`${API_BASE}/firewall/summary`)
     ]);
 
     if (statusRes.ok) state.systemStatus = await statusRes.json();
@@ -110,6 +112,7 @@ async function fetchData(isManual = false) {
       state.vlans = Array.isArray(vlansData) ? vlansData : [];
     }
     if (vpnRes.ok) state.vpnStatus = await vpnRes.json();
+    if (fwRes && fwRes.ok) state.fwSummary = await fwRes.json();
 
     updateSystemMetrics();
     updateBadges();
@@ -117,6 +120,8 @@ async function fetchData(isManual = false) {
     if (state.activeTab === 'dashboard') renderDashboard();
     if (state.activeTab === 'interfaces') renderInterfacesList();
     if (state.activeTab === 'vlans') renderVlansList();
+    if (state.activeTab === 'firewall') loadFirewall(false);
+    if (state.activeTab === 'routes') loadRoutes(false);
     if (state.activeTab === 'pppoe') loadPPPoE(false);
     if (state.activeTab === 'wireguard') loadWireGuard(false);
     if (state.activeTab === 'ipsec') loadIPSec(false);
@@ -210,6 +215,11 @@ function setDotBadge(id, active) {
 function updateBadges() {
   document.getElementById('badge-ifaces-count').innerText = (state.interfaces || []).length;
   document.getElementById('badge-vlans-count').innerText = (state.vlans || []).length;
+  if (state.fwSummary) {
+    const totalFwRules = (state.fwSummary.filter_rules_count || 0) + (state.fwSummary.nat_rules_count || 0) + (state.fwSummary.mangle_rules_count || 0);
+    const badgeFw = document.getElementById('badge-firewall-count');
+    if (badgeFw) badgeFw.innerText = totalFwRules;
+  }
 }
 
 // Render Dashboard View
@@ -2022,33 +2032,1307 @@ async function saveInterfaceMetadata() {
   }
 }
 
-// ---------------- Routes & Debian Config ----------------
+// ======================== MIKROTIK FIREWALL (/ip firewall) ========================
+state.fwSubTab = 'filter';
+state.routesSubTab = 'routes';
+
+function renderRuleFlag(disabled, hasComment) {
+  let flags = '';
+  if (disabled) flags += '<span class="text-rose-400 font-bold" title="Disabled">X</span>';
+  if (hasComment) flags += '<span class="text-amber-400 font-bold ml-1" title="Has Comment">C</span>';
+  return flags || '<span class="text-slate-600">-</span>';
+}
+
+function renderActionBadge(action) {
+  const act = (action || '').toLowerCase();
+  let color = 'bg-slate-800 text-slate-300';
+  if (act === 'accept') color = 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
+  else if (act === 'drop' || act === 'reject') color = 'bg-rose-500/20 text-rose-300 border border-rose-500/30';
+  else if (act === 'masquerade' || act === 'src-nat' || act === 'dst-nat') color = 'bg-amber-500/20 text-amber-300 border border-amber-500/30';
+  else if (act.startsWith('mark-')) color = 'bg-purple-500/20 text-purple-300 border border-purple-500/30';
+  else if (act === 'log') color = 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30';
+
+  return `<span class="px-2 py-0.5 rounded text-[10px] font-mono font-semibold ${color}">${action}</span>`;
+}
+
+function switchFWSubTab(tab) {
+  state.fwSubTab = tab;
+  const tabs = ['filter', 'nat', 'mangle', 'addrlist', 'connections'];
+  tabs.forEach(t => {
+    const panel = document.getElementById(`fw-subtab-${t}`);
+    const btn = document.getElementById(`fw-subtab-btn-${t}`);
+    if (panel) {
+      if (t === tab) panel.classList.remove('hidden');
+      else panel.classList.add('hidden');
+    }
+    if (btn) {
+      if (t === tab) {
+        btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-emerald-500 text-white transition flex items-center gap-2 whitespace-nowrap';
+      } else {
+        btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap';
+      }
+    }
+  });
+
+  const addBtn = document.getElementById('fw-primary-add-btn');
+  if (addBtn) {
+    if (tab === 'filter') addBtn.innerHTML = '<span>+ Add Filter Rule</span>';
+    else if (tab === 'nat') addBtn.innerHTML = '<span>+ Add NAT Rule</span>';
+    else if (tab === 'mangle') addBtn.innerHTML = '<span>+ Add Mangle Rule</span>';
+    else if (tab === 'addrlist') addBtn.innerHTML = '<span>+ Add Address List</span>';
+    else if (tab === 'connections') addBtn.innerHTML = '<span>Refresh Flows</span>';
+  }
+
+  if (tab === 'connections') loadConnections();
+}
+
+function openCurrentFWModal() {
+  if (state.fwSubTab === 'filter') openAddFilterModal();
+  else if (state.fwSubTab === 'nat') openAddNATModal();
+  else if (state.fwSubTab === 'mangle') openAddMangleModal();
+  else if (state.fwSubTab === 'addrlist') openAddAddrListModal();
+  else if (state.fwSubTab === 'connections') loadConnections();
+}
+
+async function loadFirewall(populateDropdowns = true) {
+  try {
+    const [summaryRes, filterRes, natRes, mangleRes, addrlistRes] = await Promise.all([
+      fetch(`${API_BASE}/firewall/summary`),
+      fetch(`${API_BASE}/firewall/filter`),
+      fetch(`${API_BASE}/firewall/nat`),
+      fetch(`${API_BASE}/firewall/mangle`),
+      fetch(`${API_BASE}/firewall/address-lists`)
+    ]);
+
+    if (summaryRes.ok) state.fwSummary = await summaryRes.json();
+    if (filterRes.ok) state.filterRules = await filterRes.json();
+    if (natRes.ok) state.natRules = await natRes.json();
+    if (mangleRes.ok) state.mangleRules = await mangleRes.json();
+    if (addrlistRes.ok) state.addressLists = await addrlistRes.json();
+
+    // Badges
+    document.getElementById('fw-badge-filter').innerText = (state.filterRules || []).length;
+    document.getElementById('fw-badge-nat').innerText = (state.natRules || []).length;
+    document.getElementById('fw-badge-mangle').innerText = (state.mangleRules || []).length;
+    document.getElementById('fw-badge-addrlist').innerText = (state.addressLists || []).length;
+
+    renderFilterRules();
+    renderNATRules();
+    renderMangleRules();
+    renderAddressLists();
+
+    if (state.fwSubTab === 'connections') {
+      loadConnections();
+    }
+  } catch (err) {
+    console.error('Failed to load firewall:', err);
+  }
+}
+
+// ----------------- Filter Rules -----------------
+function renderFilterRules() {
+  const chainFilter = document.getElementById('filter-filter-chain')?.value || '';
+  const tbody = document.getElementById('filter-table-body');
+  const empty = document.getElementById('filter-empty');
+  if (!tbody) return;
+  tbody.innerHTML = '';
+
+  const rules = (state.filterRules || []).filter(r => !chainFilter || r.chain === chainFilter);
+  document.getElementById('filter-count-badge').innerText = `${rules.length} Rules`;
+
+  if (rules.length === 0) {
+    empty?.classList.remove('hidden');
+    return;
+  }
+  empty?.classList.add('hidden');
+
+  rules.forEach((r, idx) => {
+    const tr = document.createElement('tr');
+    tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`;
+    const ports = [r.src_port ? 's:' + r.src_port : '', r.dst_port ? 'd:' + r.dst_port : ''].filter(Boolean).join(' ');
+    const ifaces = [r.in_interface ? 'in:' + r.in_interface : '', r.out_interface ? 'out:' + r.out_interface : ''].filter(Boolean).join(' ');
+
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${r.order || idx + 1}</td>
+      <td class="py-2.5 px-3 text-center">${renderRuleFlag(r.disabled, !!r.comment)}</td>
+      <td class="py-2.5 px-3 font-bold text-white">${r.chain}</td>
+      <td class="py-2.5 px-3 text-slate-300">${r.src_address || r.src_address_list ? (r.src_address || `list:${r.src_address_list}`) : '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
+      <td class="py-2.5 px-3 text-slate-300">${r.dst_address || r.dst_address_list ? (r.dst_address || `list:${r.dst_address_list}`) : '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
+      <td class="py-2.5 px-3 text-cyan-400 uppercase">${r.protocol || 'all'}</td>
+      <td class="py-2.5 px-3 text-amber-400">${ports || '-'}</td>
+      <td class="py-2.5 px-3 text-emerald-400">${ifaces || '-'}</td>
+      <td class="py-2.5 px-3">${renderActionBadge(r.action)}</td>
+      <td class="py-2.5 px-3 text-[11px] text-slate-400">
+        <span class="text-slate-300">${(r.packets || 0).toLocaleString()}p</span> / 
+        <span class="text-slate-500">${formatTraffic(r.bytes || 0).replace('/s', '')}</span>
+      </td>
+      <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${r.comment || ''}">${r.comment || '-'}</td>
+      <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
+        <button onclick="moveFilterRule('${r.id}', 'up')" class="text-xs text-slate-400 hover:text-white px-1">▲</button>
+        <button onclick="moveFilterRule('${r.id}', 'down')" class="text-xs text-slate-400 hover:text-white px-1">▼</button>
+        <button onclick="toggleFilterRuleState('${r.id}', ${!r.disabled})" class="text-xs ${r.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline">${r.disabled ? 'Enable' : 'Disable'}</button>
+        <button onclick="openEditFilterModal('${r.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
+        <button onclick="deleteFilterRule('${r.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function populateInterfaceSelect(selectId, currentVal = '') {
+  const sel = document.getElementById(selectId);
+  if (!sel) return;
+  sel.innerHTML = '<option value="">(Any Interface)</option>';
+  (state.interfaces || []).forEach(i => {
+    const opt = document.createElement('option');
+    opt.value = i.name;
+    opt.innerText = `${i.name} (${i.role || i.type})`;
+    if (i.name === currentVal) opt.selected = true;
+    sel.appendChild(opt);
+  });
+}
+
+function populateAddressListSelect(selectId, currentVal = '') {
+  const sel = document.getElementById(selectId);
+  if (!sel) return;
+  sel.innerHTML = '<option value="">(None)</option>';
+  const set = new Set((state.addressLists || []).map(a => a.name));
+  set.forEach(name => {
+    const opt = document.createElement('option');
+    opt.value = name;
+    opt.innerText = name;
+    if (name === currentVal) opt.selected = true;
+    sel.appendChild(opt);
+  });
+}
+
+function openAddFilterModal() {
+  document.getElementById('filter-modal-title').innerText = 'Add Firewall Filter Rule';
+  document.getElementById('filter-modal-id').value = '';
+  document.getElementById('filter-modal-chain').value = 'forward';
+  document.getElementById('filter-modal-action').value = 'accept';
+  document.getElementById('filter-modal-proto').value = 'all';
+  document.getElementById('filter-modal-src').value = '';
+  document.getElementById('filter-modal-dst').value = '';
+  document.getElementById('filter-modal-sport').value = '';
+  document.getElementById('filter-modal-dport').value = '';
+  document.getElementById('filter-modal-connmark').value = '';
+  document.getElementById('filter-modal-comment').value = '';
+  document.getElementById('filter-modal-disabled').checked = false;
+
+  document.getElementById('filter-modal-cs-established').checked = false;
+  document.getElementById('filter-modal-cs-related').checked = false;
+  document.getElementById('filter-modal-cs-new').checked = false;
+  document.getElementById('filter-modal-cs-invalid').checked = false;
+
+  populateInterfaceSelect('filter-modal-iniface');
+  populateInterfaceSelect('filter-modal-outiface');
+  populateAddressListSelect('filter-modal-srclist');
+  populateAddressListSelect('filter-modal-dstlist');
+  toggleFilterActionFields();
+
+  document.getElementById('filter-modal').classList.remove('hidden');
+}
+
+function openEditFilterModal(id) {
+  const r = (state.filterRules || []).find(rule => rule.id === id);
+  if (!r) return;
+
+  document.getElementById('filter-modal-title').innerText = `Edit Filter Rule (#${r.order})`;
+  document.getElementById('filter-modal-id').value = r.id;
+  document.getElementById('filter-modal-chain').value = r.chain || 'forward';
+  document.getElementById('filter-modal-action').value = r.action || 'accept';
+  document.getElementById('filter-modal-proto').value = r.protocol || 'all';
+  document.getElementById('filter-modal-src').value = r.src_address || '';
+  document.getElementById('filter-modal-dst').value = r.dst_address || '';
+  document.getElementById('filter-modal-sport').value = r.src_port || '';
+  document.getElementById('filter-modal-dport').value = r.dst_port || '';
+  document.getElementById('filter-modal-connmark').value = r.connection_mark || '';
+  document.getElementById('filter-modal-comment').value = r.comment || '';
+  document.getElementById('filter-modal-disabled').checked = !!r.disabled;
+
+  const states = r.connection_state || [];
+  document.getElementById('filter-modal-cs-established').checked = states.includes('established');
+  document.getElementById('filter-modal-cs-related').checked = states.includes('related');
+  document.getElementById('filter-modal-cs-new').checked = states.includes('new');
+  document.getElementById('filter-modal-cs-invalid').checked = states.includes('invalid');
+
+  populateInterfaceSelect('filter-modal-iniface', r.in_interface);
+  populateInterfaceSelect('filter-modal-outiface', r.out_interface);
+  populateAddressListSelect('filter-modal-srclist', r.src_address_list);
+  populateAddressListSelect('filter-modal-dstlist', r.dst_address_list);
+  toggleFilterActionFields();
+
+  document.getElementById('filter-modal').classList.remove('hidden');
+}
+
+function closeFilterModal() {
+  document.getElementById('filter-modal').classList.add('hidden');
+}
+
+function toggleFilterActionFields() {
+  const act = document.getElementById('filter-modal-action').value;
+  const rejectBox = document.getElementById('filter-action-reject-box');
+  if (rejectBox) {
+    if (act === 'reject') rejectBox.classList.remove('hidden');
+    else rejectBox.classList.add('hidden');
+  }
+}
+
+async function submitSaveFilterRule(e) {
+  e.preventDefault();
+  const id = document.getElementById('filter-modal-id').value;
+  const chain = document.getElementById('filter-modal-chain').value;
+  const action = document.getElementById('filter-modal-action').value;
+  const proto = document.getElementById('filter-modal-proto').value;
+  const src = document.getElementById('filter-modal-src').value.trim();
+  const dst = document.getElementById('filter-modal-dst').value.trim();
+  const sport = document.getElementById('filter-modal-sport').value.trim();
+  const dport = document.getElementById('filter-modal-dport').value.trim();
+  const inIface = document.getElementById('filter-modal-iniface').value;
+  const outIface = document.getElementById('filter-modal-outiface').value;
+  const connmark = document.getElementById('filter-modal-connmark').value.trim();
+  const srclist = document.getElementById('filter-modal-srclist').value;
+  const dstlist = document.getElementById('filter-modal-dstlist').value;
+  const comment = document.getElementById('filter-modal-comment').value.trim();
+  const disabled = document.getElementById('filter-modal-disabled').checked;
+  const rejectWith = document.getElementById('filter-modal-rejectwith')?.value || '';
+
+  const states = [];
+  if (document.getElementById('filter-modal-cs-established').checked) states.push('established');
+  if (document.getElementById('filter-modal-cs-related').checked) states.push('related');
+  if (document.getElementById('filter-modal-cs-new').checked) states.push('new');
+  if (document.getElementById('filter-modal-cs-invalid').checked) states.push('invalid');
+
+  try {
+    const res = await fetch(`${API_BASE}/firewall/filter`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        id,
+        chain,
+        action,
+        reject_with: rejectWith,
+        protocol: proto,
+        src_address: src,
+        dst_address: dst,
+        src_port: sport,
+        dst_port: dport,
+        in_interface: inIface,
+        out_interface: outIface,
+        connection_state: states,
+        connection_mark: connmark,
+        src_address_list: srclist,
+        dst_address_list: dstlist,
+        comment,
+        disabled
+      })
+    });
+    if (res.ok) {
+      showToast('Filter Rule Saved', 'Rule applied successfully to firewall', 'success');
+      closeFilterModal();
+      loadFirewall(false);
+    } else {
+      const err = await res.json();
+      showToast('Error', err.message, 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function moveFilterRule(id, dir) {
+  try {
+    const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}/move`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ direction: dir })
+    });
+    if (res.ok) loadFirewall(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleFilterRuleState(id, disabled) {
+  try {
+    const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled })
+    });
+    if (res.ok) loadFirewall(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteFilterRule(id) {
+  if (!confirm('Are you sure you want to delete this filter rule?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', 'Filter rule removed', 'success');
+      loadFirewall(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// ----------------- NAT Rules -----------------
+function renderNATRules() {
+  const chainFilter = document.getElementById('nat-filter-chain')?.value || '';
+  const tbody = document.getElementById('nat-table-body');
+  const empty = document.getElementById('nat-empty');
+  if (!tbody) return;
+  tbody.innerHTML = '';
+
+  const rules = (state.natRules || []).filter(r => !chainFilter || r.chain === chainFilter);
+  document.getElementById('nat-count-badge').innerText = `${rules.length} Rules`;
+
+  if (rules.length === 0) {
+    empty?.classList.remove('hidden');
+    return;
+  }
+  empty?.classList.add('hidden');
+
+  rules.forEach((r, idx) => {
+    const tr = document.createElement('tr');
+    tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`;
+    const toTarget = [r.to_addresses, r.to_ports].filter(Boolean).join(':');
+    const ifaces = [r.out_interface ? 'out:' + r.out_interface : '', r.in_interface ? 'in:' + r.in_interface : ''].filter(Boolean).join(' ');
+
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${r.order || idx + 1}</td>
+      <td class="py-2.5 px-3 text-center">${renderRuleFlag(r.disabled, !!r.comment)}</td>
+      <td class="py-2.5 px-3 font-bold text-amber-400">${r.chain}</td>
+      <td class="py-2.5 px-3 text-slate-300">${r.src_address || '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
+      <td class="py-2.5 px-3 text-slate-300">${r.dst_address || '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
+      <td class="py-2.5 px-3 text-cyan-400 uppercase">${r.protocol || 'all'}${r.dst_port ? ':' + r.dst_port : ''}</td>
+      <td class="py-2.5 px-3 text-emerald-400">${ifaces || '-'}</td>
+      <td class="py-2.5 px-3">${renderActionBadge(r.action)}</td>
+      <td class="py-2.5 px-3 font-mono font-bold text-amber-300">${toTarget || '-'}</td>
+      <td class="py-2.5 px-3 text-[11px] text-slate-400">
+        <span class="text-slate-300">${(r.packets || 0).toLocaleString()}p</span> / 
+        <span class="text-slate-500">${formatTraffic(r.bytes || 0).replace('/s', '')}</span>
+      </td>
+      <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${r.comment || ''}">${r.comment || '-'}</td>
+      <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
+        <button onclick="moveNATRule('${r.id}', 'up')" class="text-xs text-slate-400 hover:text-white px-1">▲</button>
+        <button onclick="moveNATRule('${r.id}', 'down')" class="text-xs text-slate-400 hover:text-white px-1">▼</button>
+        <button onclick="toggleNATRuleState('${r.id}', ${!r.disabled})" class="text-xs ${r.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline">${r.disabled ? 'Enable' : 'Disable'}</button>
+        <button onclick="openEditNATModal('${r.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
+        <button onclick="deleteNATRule('${r.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function openAddNATModal() {
+  document.getElementById('nat-modal-title').innerText = 'Add NAT Rule';
+  document.getElementById('nat-modal-id').value = '';
+  document.getElementById('nat-modal-chain').value = 'srcnat';
+  document.getElementById('nat-modal-action').value = 'masquerade';
+  document.getElementById('nat-modal-toaddr').value = '';
+  document.getElementById('nat-modal-toport').value = '';
+  document.getElementById('nat-modal-proto').value = 'all';
+  document.getElementById('nat-modal-src').value = '';
+  document.getElementById('nat-modal-dst').value = '';
+  document.getElementById('nat-modal-dport').value = '';
+  document.getElementById('nat-modal-comment').value = '';
+  document.getElementById('nat-modal-disabled').checked = false;
+
+  populateInterfaceSelect('nat-modal-iniface');
+  populateInterfaceSelect('nat-modal-outiface');
+  toggleNATActionFields();
+
+  document.getElementById('nat-modal').classList.remove('hidden');
+}
+
+function openEditNATModal(id) {
+  const r = (state.natRules || []).find(rule => rule.id === id);
+  if (!r) return;
+
+  document.getElementById('nat-modal-title').innerText = `Edit NAT Rule (#${r.order})`;
+  document.getElementById('nat-modal-id').value = r.id;
+  document.getElementById('nat-modal-chain').value = r.chain || 'srcnat';
+  document.getElementById('nat-modal-action').value = r.action || 'masquerade';
+  document.getElementById('nat-modal-toaddr').value = r.to_addresses || '';
+  document.getElementById('nat-modal-toport').value = r.to_ports || '';
+  document.getElementById('nat-modal-proto').value = r.protocol || 'all';
+  document.getElementById('nat-modal-src').value = r.src_address || '';
+  document.getElementById('nat-modal-dst').value = r.dst_address || '';
+  document.getElementById('nat-modal-dport').value = r.dst_port || '';
+  document.getElementById('nat-modal-comment').value = r.comment || '';
+  document.getElementById('nat-modal-disabled').checked = !!r.disabled;
+
+  populateInterfaceSelect('nat-modal-iniface', r.in_interface);
+  populateInterfaceSelect('nat-modal-outiface', r.out_interface);
+  toggleNATActionFields();
+
+  document.getElementById('nat-modal').classList.remove('hidden');
+}
+
+function closeNATModal() {
+  document.getElementById('nat-modal').classList.add('hidden');
+}
+
+function toggleNATChainFields() {
+  const ch = document.getElementById('nat-modal-chain').value;
+  const actSel = document.getElementById('nat-modal-action');
+  if (ch === 'srcnat' && actSel.value === 'dst-nat') {
+    actSel.value = 'masquerade';
+  } else if (ch === 'dstnat' && actSel.value === 'masquerade') {
+    actSel.value = 'dst-nat';
+  }
+  toggleNATActionFields();
+}
+
+function toggleNATActionFields() {
+  const act = document.getElementById('nat-modal-action').value;
+  const toBox = document.getElementById('nat-action-to-box');
+  if (toBox) {
+    if (act === 'masquerade' || act === 'accept') toBox.classList.add('hidden');
+    else toBox.classList.remove('hidden');
+  }
+}
+
+async function submitSaveNATRule(e) {
+  e.preventDefault();
+  const id = document.getElementById('nat-modal-id').value;
+  const chain = document.getElementById('nat-modal-chain').value;
+  const action = document.getElementById('nat-modal-action').value;
+  const toaddr = document.getElementById('nat-modal-toaddr').value.trim();
+  const toport = document.getElementById('nat-modal-toport').value.trim();
+  const proto = document.getElementById('nat-modal-proto').value;
+  const src = document.getElementById('nat-modal-src').value.trim();
+  const dst = document.getElementById('nat-modal-dst').value.trim();
+  const dport = document.getElementById('nat-modal-dport').value.trim();
+  const inIface = document.getElementById('nat-modal-iniface').value;
+  const outIface = document.getElementById('nat-modal-outiface').value;
+  const comment = document.getElementById('nat-modal-comment').value.trim();
+  const disabled = document.getElementById('nat-modal-disabled').checked;
+
+  try {
+    const res = await fetch(`${API_BASE}/firewall/nat`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        id,
+        chain,
+        action,
+        to_addresses: toaddr,
+        to_ports: toport,
+        protocol: proto,
+        src_address: src,
+        dst_address: dst,
+        dst_port: dport,
+        in_interface: inIface,
+        out_interface: outIface,
+        comment,
+        disabled
+      })
+    });
+    if (res.ok) {
+      showToast('NAT Rule Saved', 'NAT mapping updated', 'success');
+      closeNATModal();
+      loadFirewall(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function moveNATRule(id, dir) {
+  try {
+    const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}/move`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ direction: dir })
+    });
+    if (res.ok) loadFirewall(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleNATRuleState(id, disabled) {
+  try {
+    const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled })
+    });
+    if (res.ok) loadFirewall(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteNATRule(id) {
+  if (!confirm('Are you sure you want to delete this NAT rule?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', 'NAT rule removed', 'success');
+      loadFirewall(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// ----------------- Mangle Rules -----------------
+function renderMangleRules() {
+  const chainFilter = document.getElementById('mangle-filter-chain')?.value || '';
+  const tbody = document.getElementById('mangle-table-body');
+  const empty = document.getElementById('mangle-empty');
+  if (!tbody) return;
+  tbody.innerHTML = '';
+
+  const rules = (state.mangleRules || []).filter(r => !chainFilter || r.chain === chainFilter);
+  document.getElementById('mangle-count-badge').innerText = `${rules.length} Rules`;
+
+  if (rules.length === 0) {
+    empty?.classList.remove('hidden');
+    return;
+  }
+  empty?.classList.add('hidden');
+
+  rules.forEach((r, idx) => {
+    const tr = document.createElement('tr');
+    tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`;
+    const markVal = r.new_connection_mark || r.new_routing_mark || r.new_packet_mark || r.new_mss || (r.new_dscp ? 'DSCP:' + r.new_dscp : '');
+
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${r.order || idx + 1}</td>
+      <td class="py-2.5 px-3 text-center">${renderRuleFlag(r.disabled, !!r.comment)}</td>
+      <td class="py-2.5 px-3 font-bold text-purple-400">${r.chain}</td>
+      <td class="py-2.5 px-3 text-slate-300">${r.src_address || '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
+      <td class="py-2.5 px-3 text-slate-300">${r.dst_address || '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
+      <td class="py-2.5 px-3">${renderActionBadge(r.action)}</td>
+      <td class="py-2.5 px-3 font-mono font-bold text-cyan-300">${markVal || '-'}</td>
+      <td class="py-2.5 px-3 text-center">${r.passthrough ? '<span class="text-emerald-400">yes</span>' : '<span class="text-slate-500">no</span>'}</td>
+      <td class="py-2.5 px-3 text-[11px] text-slate-400">
+        <span class="text-slate-300">${(r.packets || 0).toLocaleString()}p</span> / 
+        <span class="text-slate-500">${formatTraffic(r.bytes || 0).replace('/s', '')}</span>
+      </td>
+      <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[120px]" title="${r.comment || ''}">${r.comment || '-'}</td>
+      <td class="py-2.5 px-3 text-right space-x-1.5 font-sans whitespace-nowrap">
+        <button onclick="moveMangleRule('${r.id}', 'up')" class="text-xs text-slate-400 hover:text-white px-1">▲</button>
+        <button onclick="moveMangleRule('${r.id}', 'down')" class="text-xs text-slate-400 hover:text-white px-1">▼</button>
+        <button onclick="toggleMangleRuleState('${r.id}', ${!r.disabled})" class="text-xs ${r.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline">${r.disabled ? 'Enable' : 'Disable'}</button>
+        <button onclick="openEditMangleModal('${r.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
+        <button onclick="deleteMangleRule('${r.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function openAddMangleModal() {
+  document.getElementById('mangle-modal-title').innerText = 'Add Mangle Rule';
+  document.getElementById('mangle-modal-id').value = '';
+  document.getElementById('mangle-modal-chain').value = 'prerouting';
+  document.getElementById('mangle-modal-action').value = 'mark-connection';
+  document.getElementById('mangle-modal-newmark').value = '';
+  document.getElementById('mangle-modal-passthrough').checked = true;
+  document.getElementById('mangle-modal-src').value = '';
+  document.getElementById('mangle-modal-dst').value = '';
+  document.getElementById('mangle-modal-connmark').value = '';
+  document.getElementById('mangle-modal-comment').value = '';
+  document.getElementById('mangle-modal-disabled').checked = false;
+
+  populateInterfaceSelect('mangle-modal-iniface');
+  document.getElementById('mangle-modal').classList.remove('hidden');
+}
+
+function openEditMangleModal(id) {
+  const r = (state.mangleRules || []).find(rule => rule.id === id);
+  if (!r) return;
+
+  document.getElementById('mangle-modal-title').innerText = `Edit Mangle Rule (#${r.order})`;
+  document.getElementById('mangle-modal-id').value = r.id;
+  document.getElementById('mangle-modal-chain').value = r.chain || 'prerouting';
+  document.getElementById('mangle-modal-action').value = r.action || 'mark-connection';
+  document.getElementById('mangle-modal-newmark').value = r.new_connection_mark || r.new_routing_mark || r.new_packet_mark || r.new_mss || '';
+  document.getElementById('mangle-modal-passthrough').checked = r.passthrough ?? true;
+  document.getElementById('mangle-modal-src').value = r.src_address || '';
+  document.getElementById('mangle-modal-dst').value = r.dst_address || '';
+  document.getElementById('mangle-modal-connmark').value = r.connection_mark || '';
+  document.getElementById('mangle-modal-comment').value = r.comment || '';
+  document.getElementById('mangle-modal-disabled').checked = !!r.disabled;
+
+  populateInterfaceSelect('mangle-modal-iniface', r.in_interface);
+  document.getElementById('mangle-modal').classList.remove('hidden');
+}
+
+function closeMangleModal() {
+  document.getElementById('mangle-modal').classList.add('hidden');
+}
+
+function toggleMangleActionFields() {
+  // Handled smoothly by single dynamic input
+}
+
+async function submitSaveMangleRule(e) {
+  e.preventDefault();
+  const id = document.getElementById('mangle-modal-id').value;
+  const chain = document.getElementById('mangle-modal-chain').value;
+  const action = document.getElementById('mangle-modal-action').value;
+  const newmark = document.getElementById('mangle-modal-newmark').value.trim();
+  const passthrough = document.getElementById('mangle-modal-passthrough').checked;
+  const src = document.getElementById('mangle-modal-src').value.trim();
+  const dst = document.getElementById('mangle-modal-dst').value.trim();
+  const inIface = document.getElementById('mangle-modal-iniface').value;
+  const connmark = document.getElementById('mangle-modal-connmark').value.trim();
+  const comment = document.getElementById('mangle-modal-comment').value.trim();
+  const disabled = document.getElementById('mangle-modal-disabled').checked;
+
+  const payload = {
+    id,
+    chain,
+    action,
+    passthrough,
+    src_address: src,
+    dst_address: dst,
+    in_interface: inIface,
+    connection_mark: connmark,
+    comment,
+    disabled
+  };
+
+  if (action === 'mark-connection') payload.new_connection_mark = newmark;
+  else if (action === 'mark-routing') payload.new_routing_mark = newmark;
+  else if (action === 'mark-packet') payload.new_packet_mark = newmark;
+  else if (action === 'change-mss') payload.new_mss = newmark || 'clamp-to-pmtu';
+
+  try {
+    const res = await fetch(`${API_BASE}/firewall/mangle`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+    if (res.ok) {
+      showToast('Mangle Rule Saved', 'Marking rule applied', 'success');
+      closeMangleModal();
+      loadFirewall(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function moveMangleRule(id, dir) {
+  try {
+    const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}/move`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ direction: dir })
+    });
+    if (res.ok) loadFirewall(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleMangleRuleState(id, disabled) {
+  try {
+    const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled })
+    });
+    if (res.ok) loadFirewall(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteMangleRule(id) {
+  if (!confirm('Are you sure you want to delete this mangle rule?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', 'Mangle rule removed', 'success');
+      loadFirewall(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// ----------------- Address Lists -----------------
+function renderAddressLists() {
+  const filterList = document.getElementById('addrlist-filter-name')?.value || '';
+  const tbody = document.getElementById('addrlist-table-body');
+  const empty = document.getElementById('addrlist-empty');
+  if (!tbody) return;
+  tbody.innerHTML = '';
+
+  const entries = (state.addressLists || []).filter(e => !filterList || e.name === filterList);
+  document.getElementById('addrlist-count-badge').innerText = `${entries.length} Entries`;
+
+  // Update filter select
+  const sel = document.getElementById('addrlist-filter-name');
+  if (sel) {
+    const cur = sel.value;
+    sel.innerHTML = '<option value="">All Lists</option>';
+    const set = new Set((state.addressLists || []).map(a => a.name));
+    set.forEach(n => {
+      const opt = document.createElement('option');
+      opt.value = n;
+      opt.innerText = n;
+      if (n === cur) opt.selected = true;
+      sel.appendChild(opt);
+    });
+  }
+
+  if (entries.length === 0) {
+    empty?.classList.remove('hidden');
+    return;
+  }
+  empty?.classList.add('hidden');
+
+  entries.forEach((e, idx) => {
+    const tr = document.createElement('tr');
+    tr.className = `hover:bg-slate-800/40 transition text-xs ${e.disabled ? 'opacity-50' : ''}`;
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${idx + 1}</td>
+      <td class="py-2.5 px-3 text-center">${e.disabled ? '<span class="text-rose-400 font-bold">X</span>' : '<span class="text-slate-600">-</span>'}</td>
+      <td class="py-2.5 px-3 font-bold text-white font-mono">${e.name}</td>
+      <td class="py-2.5 px-3 font-mono font-bold text-emerald-400">${e.address}</td>
+      <td class="py-2.5 px-3 text-slate-400 italic">${e.comment || '-'}</td>
+      <td class="py-2.5 px-3 text-right font-sans">
+        <button onclick="deleteAddrList('${e.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function openAddAddrListModal() {
+  document.getElementById('addrlist-modal-name').value = '';
+  document.getElementById('addrlist-modal-addr').value = '';
+  document.getElementById('addrlist-modal-comment').value = '';
+  document.getElementById('addrlist-modal').classList.remove('hidden');
+}
+
+function closeAddrListModal() {
+  document.getElementById('addrlist-modal').classList.add('hidden');
+}
+
+async function submitSaveAddrList(e) {
+  e.preventDefault();
+  const name = document.getElementById('addrlist-modal-name').value.trim();
+  const addr = document.getElementById('addrlist-modal-addr').value.trim();
+  const comment = document.getElementById('addrlist-modal-comment').value.trim();
+
+  try {
+    const res = await fetch(`${API_BASE}/firewall/address-lists`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ name, address: addr, comment, disabled: false })
+    });
+    if (res.ok) {
+      showToast('Address List Item Added', `Added ${addr} to ${name}`, 'success');
+      closeAddrListModal();
+      loadFirewall(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteAddrList(id) {
+  try {
+    const res = await fetch(`${API_BASE}/firewall/address-lists/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', 'Address list item removed', 'info');
+      loadFirewall(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// ----------------- Active Connections Tracking -----------------
+async function loadConnections() {
+  try {
+    const res = await fetch(`${API_BASE}/firewall/connections`);
+    const data = await res.json();
+    state.connections = Array.isArray(data) ? data : [];
+    document.getElementById('fw-badge-connections').innerText = state.connections.length;
+    document.getElementById('conns-count-badge').innerText = `${state.connections.length} Flows`;
+    renderConnections();
+  } catch (err) {
+    console.error('Failed to load connections:', err);
+  }
+}
+
+function renderConnections() {
+  const search = (document.getElementById('conns-search-input')?.value || '').toLowerCase();
+  const protoFilter = document.getElementById('conns-filter-proto')?.value || '';
+  const tbody = document.getElementById('conns-table-body');
+  const empty = document.getElementById('conns-empty');
+  if (!tbody) return;
+  tbody.innerHTML = '';
+
+  const conns = (state.connections || []).filter(c => {
+    if (protoFilter && c.protocol !== protoFilter) return false;
+    if (search) {
+      const line = `${c.protocol} ${c.src_address} ${c.dst_address} ${c.src_port} ${c.dst_port} ${c.tcp_state} ${c.connection_mark}`.toLowerCase();
+      if (!line.includes(search)) return false;
+    }
+    return true;
+  });
+
+  if (conns.length === 0) {
+    empty?.classList.remove('hidden');
+    return;
+  }
+  empty?.classList.add('hidden');
+
+  conns.slice(0, 150).forEach(c => {
+    const tr = document.createElement('tr');
+    tr.className = 'hover:bg-slate-800/40 transition text-xs';
+    let stateColor = 'text-slate-400';
+    if (c.tcp_state === 'ESTABLISHED') stateColor = 'text-emerald-400 font-bold';
+    else if (c.tcp_state === 'TIME_WAIT' || c.tcp_state === 'SYN_SENT') stateColor = 'text-amber-400';
+
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 font-bold text-cyan-400">${c.protocol}</td>
+      <td class="py-2.5 px-3 font-mono text-white">${c.src_address}:${c.src_port}</td>
+      <td class="py-2.5 px-3 font-mono text-white">${c.dst_address}:${c.dst_port}</td>
+      <td class="py-2.5 px-3 font-mono text-slate-400">${c.reply_src_address ? c.reply_src_address + ':' + c.reply_src_port : '-'}</td>
+      <td class="py-2.5 px-3 font-mono text-slate-400">${c.reply_dst_address ? c.reply_dst_address + ':' + c.reply_dst_port : '-'}</td>
+      <td class="py-2.5 px-3 font-mono ${stateColor}">${c.tcp_state}</td>
+      <td class="py-2.5 px-3 font-mono text-purple-300">${c.connection_mark || '-'}</td>
+      <td class="py-2.5 px-3 text-slate-500">${c.timeout ? c.timeout + 's' : '-'}</td>
+      <td class="py-2.5 px-3 text-right font-sans">
+        <button onclick="killConnection('${c.protocol}', '${c.src_address}', ${c.src_port}, '${c.dst_address}', ${c.dst_port})" class="text-xs text-rose-400 hover:text-rose-300">Kill</button>
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+async function killConnection(protocol, src, sport, dst, dport) {
+  try {
+    const res = await fetch(`${API_BASE}/firewall/connections/kill`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ protocol, src, sport, dst, dport })
+    });
+    if (res.ok) {
+      showToast('Terminated', `Killed flow ${src}:${sport} -> ${dst}:${dport}`, 'info');
+      loadConnections();
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function resetFirewallCounters() {
+  if (!confirm('Reset all firewall packet and byte counters to zero?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/firewall/reset-counters`, { method: 'POST' });
+    if (res.ok) {
+      showToast('Counters Reset', 'All rules counters reset to 0', 'success');
+      loadFirewall(false);
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// ======================== MIKROTIK ROUTING & POLICY (/ip route) ========================
+function switchRoutesSubTab(tab) {
+  state.routesSubTab = tab;
+  const tabs = ['routes', 'rules', 'tables'];
+  tabs.forEach(t => {
+    const panel = document.getElementById(`routes-subtab-${t}`);
+    const btn = document.getElementById(`routes-subtab-btn-${t}`);
+    if (panel) {
+      if (t === tab) panel.classList.remove('hidden');
+      else panel.classList.add('hidden');
+    }
+    if (btn) {
+      if (t === tab) {
+        btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-cyan-500 text-white transition flex items-center gap-2';
+      } else {
+        btn.className = 'px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2';
+      }
+    }
+  });
+}
+
 async function loadRoutes() {
+  try {
+    const [routesRes, rulesRes, tablesRes] = await Promise.all([
+      fetch(`${API_BASE}/routes`),
+      fetch(`${API_BASE}/routing/rules`),
+      fetch(`${API_BASE}/routing/tables`)
+    ]);
+
+    if (routesRes.ok) state.routes = await routesRes.json();
+    if (rulesRes.ok) state.policyRules = await rulesRes.json();
+    if (tablesRes.ok) state.routingTables = await tablesRes.json();
+
+    document.getElementById('routes-badge-routes').innerText = (state.routes || []).length;
+    document.getElementById('routes-badge-rules').innerText = (state.policyRules || []).length;
+    document.getElementById('routes-badge-tables').innerText = (state.routingTables || []).length;
+    document.getElementById('badge-routes-count').innerText = (state.routes || []).length;
+
+    renderRoutesList();
+    renderPolicyRules();
+    renderRoutingTables();
+  } catch (err) {
+    console.error('Failed to load routing:', err);
+  }
+}
+
+function renderRoutesList() {
+  const tableFilter = document.getElementById('routes-filter-table')?.value || '';
   const tbody = document.getElementById('routes-table-body');
-  tbody.innerHTML = `<tr><td colspan="5" class="py-4 text-center text-slate-500 font-sans">Loading routes...</td></tr>`;
+  const empty = document.getElementById('routes-empty');
+  if (!tbody) return;
+  tbody.innerHTML = '';
+
+  // Update table filter select
+  const sel = document.getElementById('routes-filter-table');
+  if (sel) {
+    const cur = sel.value;
+    sel.innerHTML = '<option value="">All Tables</option>';
+    (state.routingTables || []).forEach(t => {
+      const opt = document.createElement('option');
+      opt.value = t.name;
+      opt.innerText = t.name;
+      if (t.name === cur) opt.selected = true;
+      sel.appendChild(opt);
+    });
+  }
+
+  const routes = (state.routes || []).filter(r => !tableFilter || r.routing_table === tableFilter);
+  document.getElementById('routes-count-badge').innerText = `${routes.length} Routes`;
+
+  if (routes.length === 0) {
+    empty?.classList.remove('hidden');
+    return;
+  }
+  empty?.classList.add('hidden');
+
+  routes.forEach(r => {
+    const tr = document.createElement('tr');
+    tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`;
+
+    // MikroTik Flags string: D=Dynamic, A=Active, C=Connect, S=Static, X=Disabled
+    let flags = '';
+    if (r.dynamic) flags += 'D';
+    if (r.active) flags += 'A';
+    if (r.connect) flags += 'C';
+    if (r.static) flags += 'S';
+    if (r.disabled) flags += 'X';
+
+    const isDefault = r.dst_address === 'default' || r.dst_address === '0.0.0.0/0';
+
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 text-center">
+        <span class="px-1.5 py-0.5 rounded text-[10px] font-mono font-bold bg-slate-800 ${r.active ? 'text-cyan-400 border border-cyan-800/40' : 'text-slate-500'}">
+          ${flags || '-'}
+        </span>
+      </td>
+      <td class="py-2.5 px-3 font-bold ${isDefault ? 'text-cyan-300' : 'text-white'}">${r.dst_address}</td>
+      <td class="py-2.5 px-3 text-slate-300 font-mono">${r.gateway || '<span class="text-slate-600 font-sans">Direct link</span>'}</td>
+      <td class="py-2.5 px-3 text-slate-400 font-mono">${r.distance || 1}</td>
+      <td class="py-2.5 px-3 font-mono font-semibold text-purple-300">${r.routing_table || 'main'}</td>
+      <td class="py-2.5 px-3 text-emerald-400 font-mono">${r.interface || '-'}</td>
+      <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[140px]">${r.comment || '-'}</td>
+      <td class="py-2.5 px-3 text-right space-x-2 font-sans whitespace-nowrap">
+        ${r.static ? `
+          <button onclick="toggleRouteState('${r.id}', ${!r.disabled})" class="text-xs ${r.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline">${r.disabled ? 'Enable' : 'Disable'}</button>
+          <button onclick="openEditRouteModal('${r.id}')" class="text-xs text-slate-400 hover:text-white">Edit</button>
+          <button onclick="deleteRoute('${r.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
+        ` : `<span class="text-slate-600 text-[10px]">Kernel</span>`}
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function openAddRouteModal() {
+  document.getElementById('route-modal-title').innerText = 'Add Static Route';
+  document.getElementById('route-modal-id').value = '';
+  document.getElementById('route-modal-dst').value = '0.0.0.0/0';
+  document.getElementById('route-modal-gw').value = '';
+  document.getElementById('route-modal-distance').value = '1';
+  document.getElementById('route-modal-comment').value = '';
+  document.getElementById('route-modal-disabled').checked = false;
+
+  populateInterfaceSelect('route-modal-iface');
+
+  // Populate tables
+  const sel = document.getElementById('route-modal-table');
+  sel.innerHTML = '';
+  (state.routingTables || [{ name: 'main' }]).forEach(t => {
+    const opt = document.createElement('option');
+    opt.value = t.name;
+    opt.innerText = t.name;
+    if (t.name === 'main') opt.selected = true;
+    sel.appendChild(opt);
+  });
+
+  document.getElementById('route-modal').classList.remove('hidden');
+}
+
+function openEditRouteModal(id) {
+  const r = (state.routes || []).find(route => route.id === id);
+  if (!r) return;
+
+  document.getElementById('route-modal-title').innerText = `Edit Route: ${r.dst_address}`;
+  document.getElementById('route-modal-id').value = r.id;
+  document.getElementById('route-modal-dst').value = r.dst_address;
+  document.getElementById('route-modal-gw').value = r.gateway || '';
+  document.getElementById('route-modal-distance').value = r.distance || 1;
+  document.getElementById('route-modal-comment').value = r.comment || '';
+  document.getElementById('route-modal-disabled').checked = !!r.disabled;
+
+  populateInterfaceSelect('route-modal-iface', r.interface);
+
+  const sel = document.getElementById('route-modal-table');
+  sel.innerHTML = '';
+  (state.routingTables || [{ name: 'main' }]).forEach(t => {
+    const opt = document.createElement('option');
+    opt.value = t.name;
+    opt.innerText = t.name;
+    if (t.name === r.routing_table) opt.selected = true;
+    sel.appendChild(opt);
+  });
+
+  document.getElementById('route-modal').classList.remove('hidden');
+}
+
+function closeRouteModal() {
+  document.getElementById('route-modal').classList.add('hidden');
+}
+
+async function submitSaveRoute(e) {
+  e.preventDefault();
+  const id = document.getElementById('route-modal-id').value;
+  const dst = document.getElementById('route-modal-dst').value.trim();
+  const gw = document.getElementById('route-modal-gw').value.trim();
+  const table = document.getElementById('route-modal-table').value;
+  const distance = parseInt(document.getElementById('route-modal-distance').value, 10) || 1;
+  const iface = document.getElementById('route-modal-iface').value;
+  const comment = document.getElementById('route-modal-comment').value.trim();
+  const disabled = document.getElementById('route-modal-disabled').checked;
+
   try {
-    const res = await fetch(`${API_BASE}/routes`);
-    const routes = await res.json();
-    tbody.innerHTML = '';
-    if (!routes || routes.length === 0) {
-      tbody.innerHTML = `<tr><td colspan="5" class="py-4 text-center text-slate-500 font-sans">No active routes found.</td></tr>`;
-      return;
+    const res = await fetch(`${API_BASE}/routes`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        id,
+        dst_address: dst,
+        gateway: gw,
+        routing_table: table,
+        distance,
+        interface: iface,
+        comment,
+        disabled
+      })
+    });
+    if (res.ok) {
+      showToast('Route Saved', `Route to ${dst} installed`, 'success');
+      closeRouteModal();
+      loadRoutes();
+    } else {
+      const err = await res.json();
+      showToast('Error', err.message, 'error');
     }
-    routes.forEach(r => {
-      const isDefault = r.destination === 'default' || r.destination === '0.0.0.0/0';
-      const tr = document.createElement('tr');
-      tr.className = 'hover:bg-slate-800/40 transition font-mono text-xs';
-      tr.innerHTML = `
-        <td class="py-3 px-4 font-bold ${isDefault ? 'text-brand-400' : 'text-white'}">${r.destination}</td>
-        <td class="py-3 px-4 text-slate-300">${r.gateway || '<span class="text-slate-600 font-sans">On-link</span>'}</td>
-        <td class="py-3 px-4 text-white font-medium">${r.interface || '-'}</td>
-        <td class="py-3 px-4 text-slate-400 font-sans">${r.protocol}</td>
-        <td class="py-3 px-4 text-right text-slate-400 font-sans">${r.scope}</td>
-      `;
-      tbody.appendChild(tr);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleRouteState(id, disabled) {
+  try {
+    const res = await fetch(`${API_BASE}/routes/${encodeURIComponent(id)}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled })
+    });
+    if (res.ok) loadRoutes();
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteRoute(id) {
+  if (!confirm('Are you sure you want to delete this route?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/routes/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Deleted', 'Route removed', 'success');
+      loadRoutes();
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// ----------------- Policy Rules (/ip route rule) -----------------
+function renderPolicyRules() {
+  const tbody = document.getElementById('rules-table-body');
+  const empty = document.getElementById('rules-empty');
+  if (!tbody) return;
+  tbody.innerHTML = '';
+
+  const rules = state.policyRules || [];
+  document.getElementById('rules-count-badge').innerText = `${rules.length} Rules`;
+
+  if (rules.length === 0) {
+    empty?.classList.remove('hidden');
+    return;
+  }
+  empty?.classList.add('hidden');
+
+  rules.forEach(pr => {
+    const tr = document.createElement('tr');
+    tr.className = 'hover:bg-slate-800/40 transition text-xs';
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 font-mono text-cyan-400 font-bold">${pr.priority}</td>
+      <td class="py-2.5 px-3 font-mono text-white">${pr.src_address || '<span class="text-slate-600">all</span>'}</td>
+      <td class="py-2.5 px-3 font-mono text-white">${pr.dst_address || '<span class="text-slate-600">all</span>'}</td>
+      <td class="py-2.5 px-3 font-mono font-bold text-purple-300">${pr.routing_mark || '<span class="text-slate-600">-</span>'}</td>
+      <td class="py-2.5 px-3 font-mono font-bold text-amber-300">${pr.table}</td>
+      <td class="py-2.5 px-3 font-mono text-slate-300">${pr.action}</td>
+      <td class="py-2.5 px-3 text-right font-sans">
+        ${pr.priority < 32766 && pr.priority > 0 ? `
+          <button onclick="deletePolicyRule('${pr.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
+        ` : `<span class="text-slate-600 text-[10px]">Default</span>`}
+      </td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function openAddPolicyRuleModal() {
+  document.getElementById('prule-modal-pref').value = (state.policyRules || []).length * 10 + 1000;
+  document.getElementById('prule-modal-mark').value = '';
+  document.getElementById('prule-modal-src').value = '';
+  document.getElementById('prule-modal-dst').value = '';
+
+  const sel = document.getElementById('prule-modal-table');
+  sel.innerHTML = '';
+  (state.routingTables || []).forEach(t => {
+    const opt = document.createElement('option');
+    opt.value = t.name;
+    opt.innerText = t.name;
+    sel.appendChild(opt);
+  });
+
+  document.getElementById('prule-modal').classList.remove('hidden');
+}
+
+function closePolicyRuleModal() {
+  document.getElementById('prule-modal').classList.add('hidden');
+}
+
+async function submitSavePolicyRule(e) {
+  e.preventDefault();
+  const pref = parseInt(document.getElementById('prule-modal-pref').value, 10) || 1000;
+  const mark = document.getElementById('prule-modal-mark').value.trim();
+  const src = document.getElementById('prule-modal-src').value.trim();
+  const dst = document.getElementById('prule-modal-dst').value.trim();
+  const table = document.getElementById('prule-modal-table').value;
+  const action = document.getElementById('prule-modal-action').value;
+
+  try {
+    const res = await fetch(`${API_BASE}/routing/rules`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ priority: pref, routing_mark: mark, src_address: src, dst_address: dst, table, action })
     });
+    if (res.ok) {
+      showToast('Rule Added', `Policy rule installed for table ${table}`, 'success');
+      closePolicyRuleModal();
+      loadRoutes();
+    } else {
+      const err = await res.json();
+      showToast('Error', err.message, 'error');
+    }
   } catch (err) {
-    tbody.innerHTML = `<tr><td colspan="5" class="py-4 text-center text-rose-400 font-sans">Failed to load routes.</td></tr>`;
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deletePolicyRule(id) {
+  if (!confirm('Are you sure you want to remove this policy rule?')) return;
+  try {
+    const res = await fetch(`${API_BASE}/routing/rules/${encodeURIComponent(id)}`, { method: 'DELETE' });
+    if (res.ok) {
+      showToast('Rule Removed', 'Policy rule deleted', 'success');
+      loadRoutes();
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// ----------------- Routing Tables -----------------
+function renderRoutingTables() {
+  const tbody = document.getElementById('tables-table-body');
+  if (!tbody) return;
+  tbody.innerHTML = '';
+
+  (state.routingTables || []).forEach(t => {
+    const tr = document.createElement('tr');
+    tr.className = 'hover:bg-slate-800/40 transition text-xs font-mono';
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 text-cyan-400 font-bold">${t.table_id}</td>
+      <td class="py-2.5 px-3 font-bold text-white">${t.name}</td>
+      <td class="py-2.5 px-3 text-slate-400 italic">${t.comment || '-'}</td>
+    `;
+    tbody.appendChild(tr);
+  });
+}
+
+function openAddTableModal() {
+  document.getElementById('table-modal-name').value = '';
+  document.getElementById('table-modal-id').value = '';
+  document.getElementById('table-modal-comment').value = '';
+  document.getElementById('table-modal').classList.remove('hidden');
+}
+
+function closeTableModal() {
+  document.getElementById('table-modal').classList.add('hidden');
+}
+
+async function submitSaveTable(e) {
+  e.preventDefault();
+  const name = document.getElementById('table-modal-name').value.trim();
+  const id = parseInt(document.getElementById('table-modal-id').value, 10) || 0;
+  const comment = document.getElementById('table-modal-comment').value.trim();
+
+  try {
+    const res = await fetch(`${API_BASE}/routing/tables`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ name, table_id: id, comment })
+    });
+    if (res.ok) {
+      showToast('Table Created', `Routing table ${name} registered`, 'success');
+      closeTableModal();
+      loadRoutes();
+    } else {
+      const err = await res.json();
+      showToast('Error', err.message, 'error');
+    }
+  } catch (err) {
+    showToast('Error', err.message, 'error');
   }
 }
 

+ 914 - 16
internal/web/static/index.html

@@ -113,14 +113,26 @@
           <span id="badge-vlans-count" class="text-[10px] bg-cyan-950 text-cyan-400 border border-cyan-800/50 px-1.5 py-0.2 rounded-full font-mono">0</span>
         </a>
 
+        <a href="#firewall" onclick="navigate('firewall')" id="nav-firewall"
+           class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
+          <div class="flex items-center space-x-2.5">
+            <svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
+            </svg>
+            <span>Firewall</span>
+          </div>
+          <span id="badge-firewall-count" class="text-[10px] bg-emerald-950 text-emerald-400 border border-emerald-800/50 px-1.5 py-0.2 rounded-full font-mono">0</span>
+        </a>
+
         <a href="#routes" onclick="navigate('routes')" id="nav-routes"
            class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
           <div class="flex items-center space-x-2.5">
-            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+            <svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
               <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
             </svg>
-            <span>Routing Table</span>
+            <span>Routes & Policy</span>
           </div>
+          <span id="badge-routes-count" class="text-[10px] bg-slate-800 text-slate-300 px-1.5 py-0.2 rounded-full font-mono">0</span>
         </a>
 
         <!-- CATEGORY: WAN & ACCESS -->
@@ -785,29 +797,392 @@
           </div>
         </section>
 
-        <!-- ================= SECTION: ROUTING TABLE ================= -->
+        <!-- ================= SECTION: FIREWALL (/ip firewall) ================= -->
+        <section id="section-firewall" class="hidden space-y-6">
+          <div class="bg-gradient-to-r from-emerald-950/40 via-slate-900 to-slate-900 border border-emerald-900/30 rounded-xl p-5 flex flex-wrap items-center justify-between gap-4">
+            <div>
+              <div class="flex items-center gap-2 mb-1">
+                <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-semibold">/ip firewall</span>
+                <h2 class="text-base font-bold text-white">Firewall & Traffic Controller</h2>
+              </div>
+              <p class="text-xs text-slate-400">Complete MikroTik-grade stateful filter, NAT, packet/connection marking (Mangle), address lists and live connection tracker.</p>
+            </div>
+
+            <div class="flex items-center space-x-2.5">
+              <button id="fw-primary-add-btn" onclick="openCurrentFWModal()" class="text-xs font-semibold px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-500/20 transition flex items-center gap-1.5">
+                <span>+ Add Rule</span>
+              </button>
+              <button onclick="resetFirewallCounters()" class="text-xs font-medium px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition" title="Reset all packet & byte counters to zero">
+                Reset Counters
+              </button>
+            </div>
+          </div>
+
+          <!-- Sub Tabs: Filter / NAT / Mangle / Address Lists / Connections -->
+          <div class="flex border-b border-slate-800 space-x-1 overflow-x-auto">
+            <button id="fw-subtab-btn-filter" onclick="switchFWSubTab('filter')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-emerald-500 text-white transition flex items-center gap-2 whitespace-nowrap">
+              <span>Filter Rules</span>
+              <span id="fw-badge-filter" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+            </button>
+            <button id="fw-subtab-btn-nat" onclick="switchFWSubTab('nat')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
+              <span>NAT</span>
+              <span id="fw-badge-nat" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+            </button>
+            <button id="fw-subtab-btn-mangle" onclick="switchFWSubTab('mangle')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
+              <span>Mangle</span>
+              <span id="fw-badge-mangle" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+            </button>
+            <button id="fw-subtab-btn-addrlist" onclick="switchFWSubTab('addrlist')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
+              <span>Address Lists</span>
+              <span id="fw-badge-addrlist" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+            </button>
+            <button id="fw-subtab-btn-connections" onclick="switchFWSubTab('connections')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
+              <span>Connections</span>
+              <span id="fw-badge-connections" class="px-1.5 py-0.2 rounded text-[10px] bg-emerald-950 text-emerald-400 border border-emerald-800/40">0</span>
+            </button>
+          </div>
+
+          <!-- ================= TAB 1: FILTER RULES ================= -->
+          <div id="fw-subtab-filter" class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between flex-wrap gap-3">
+              <div class="flex items-center gap-3">
+                <h3 class="text-sm font-bold text-white">Firewall Filter Rules</h3>
+                <span id="filter-count-badge" class="text-xs font-mono text-slate-400">0 Rules</span>
+              </div>
+              <div class="flex items-center gap-2">
+                <label class="text-xs text-slate-400 font-sans">Chain:</label>
+                <select id="filter-filter-chain" onchange="renderFilterRules()" class="bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-white font-mono">
+                  <option value="">All Chains</option>
+                  <option value="input">input</option>
+                  <option value="forward">forward</option>
+                  <option value="output">output</option>
+                </select>
+              </div>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3 w-10 text-center">#</th>
+                    <th class="py-2.5 px-3 w-12 text-center" title="Flags: X=Disabled, C=Comment">Flag</th>
+                    <th class="py-2.5 px-3">Chain</th>
+                    <th class="py-2.5 px-3">Src. Address</th>
+                    <th class="py-2.5 px-3">Dst. Address</th>
+                    <th class="py-2.5 px-3">Protocol</th>
+                    <th class="py-2.5 px-3">Ports</th>
+                    <th class="py-2.5 px-3">In / Out</th>
+                    <th class="py-2.5 px-3">Action</th>
+                    <th class="py-2.5 px-3">Packets / Bytes</th>
+                    <th class="py-2.5 px-3">Comment</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Order & Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="filter-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+
+            <div id="filter-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No filter rules found in this chain. Click "+ Add Rule" to create one.
+            </div>
+          </div>
+
+          <!-- ================= TAB 2: NAT RULES ================= -->
+          <div id="fw-subtab-nat" class="hidden bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between flex-wrap gap-3">
+              <div class="flex items-center gap-3">
+                <h3 class="text-sm font-bold text-white">NAT Rules</h3>
+                <span id="nat-count-badge" class="text-xs font-mono text-slate-400">0 Rules</span>
+              </div>
+              <div class="flex items-center gap-2">
+                <label class="text-xs text-slate-400 font-sans">Chain:</label>
+                <select id="nat-filter-chain" onchange="renderNATRules()" class="bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-white font-mono">
+                  <option value="">All Chains</option>
+                  <option value="srcnat">srcnat (SNAT / Masquerade)</option>
+                  <option value="dstnat">dstnat (Port Forwarding)</option>
+                </select>
+              </div>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3 w-10 text-center">#</th>
+                    <th class="py-2.5 px-3 w-12 text-center">Flag</th>
+                    <th class="py-2.5 px-3">Chain</th>
+                    <th class="py-2.5 px-3">Src. Address</th>
+                    <th class="py-2.5 px-3">Dst. Address</th>
+                    <th class="py-2.5 px-3">Proto/Port</th>
+                    <th class="py-2.5 px-3">Out / In</th>
+                    <th class="py-2.5 px-3">Action</th>
+                    <th class="py-2.5 px-3">To Address:Port</th>
+                    <th class="py-2.5 px-3">Packets / Bytes</th>
+                    <th class="py-2.5 px-3">Comment</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Order & Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="nat-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+
+            <div id="nat-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No NAT rules configured. Click "+ Add Rule" to add masquerade or port forwarding.
+            </div>
+          </div>
+
+          <!-- ================= TAB 3: MANGLE RULES ================= -->
+          <div id="fw-subtab-mangle" class="hidden bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between flex-wrap gap-3">
+              <div class="flex items-center gap-3">
+                <h3 class="text-sm font-bold text-white">Mangle Packet & Connection Marking</h3>
+                <span id="mangle-count-badge" class="text-xs font-mono text-slate-400">0 Rules</span>
+              </div>
+              <div class="flex items-center gap-2">
+                <label class="text-xs text-slate-400 font-sans">Chain:</label>
+                <select id="mangle-filter-chain" onchange="renderMangleRules()" class="bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-white font-mono">
+                  <option value="">All Chains</option>
+                  <option value="prerouting">prerouting</option>
+                  <option value="forward">forward</option>
+                  <option value="postrouting">postrouting</option>
+                  <option value="input">input</option>
+                  <option value="output">output</option>
+                </select>
+              </div>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3 w-10 text-center">#</th>
+                    <th class="py-2.5 px-3 w-12 text-center">Flag</th>
+                    <th class="py-2.5 px-3">Chain</th>
+                    <th class="py-2.5 px-3">Src. Address</th>
+                    <th class="py-2.5 px-3">Dst. Address</th>
+                    <th class="py-2.5 px-3">Action</th>
+                    <th class="py-2.5 px-3">New Mark / Value</th>
+                    <th class="py-2.5 px-3 text-center">Pass</th>
+                    <th class="py-2.5 px-3">Packets / Bytes</th>
+                    <th class="py-2.5 px-3">Comment</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Order & Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="mangle-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+
+            <div id="mangle-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No mangle rules configured. Click "+ Add Rule" to mark connections or routing for Multi-WAN.
+            </div>
+          </div>
+
+          <!-- ================= TAB 4: ADDRESS LISTS ================= -->
+          <div id="fw-subtab-addrlist" class="hidden bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between flex-wrap gap-3">
+              <div class="flex items-center gap-3">
+                <h3 class="text-sm font-bold text-white">Address Lists (ipset)</h3>
+                <span id="addrlist-count-badge" class="text-xs font-mono text-slate-400">0 Entries</span>
+              </div>
+              <div class="flex items-center gap-2">
+                <label class="text-xs text-slate-400 font-sans">List:</label>
+                <select id="addrlist-filter-name" onchange="renderAddressLists()" class="bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-white font-mono">
+                  <option value="">All Lists</option>
+                </select>
+              </div>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3 w-10 text-center">#</th>
+                    <th class="py-2.5 px-3 w-12 text-center">Flag</th>
+                    <th class="py-2.5 px-3">List Name</th>
+                    <th class="py-2.5 px-3">IP Address / CIDR</th>
+                    <th class="py-2.5 px-3">Comment</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="addrlist-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+
+            <div id="addrlist-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No address list entries configured. Click "+ Add Item" to define a group of IPs/subnets.
+            </div>
+          </div>
+
+          <!-- ================= TAB 5: ACTIVE CONNECTIONS ================= -->
+          <div id="fw-subtab-connections" class="hidden bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between flex-wrap gap-3">
+              <div class="flex items-center gap-3">
+                <h3 class="text-sm font-bold text-white">Active Conntrack Connections</h3>
+                <span id="conns-count-badge" class="text-xs font-mono bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 px-2 py-0.5 rounded-full">0 Flows</span>
+              </div>
+              <div class="flex items-center gap-2 flex-wrap">
+                <input type="text" id="conns-search-input" oninput="renderConnections()" placeholder="Filter IP, port..." class="bg-slate-800 border border-slate-700 rounded-lg px-3 py-1 text-xs text-white font-mono focus:outline-none focus:border-emerald-500">
+                <select id="conns-filter-proto" onchange="renderConnections()" class="bg-slate-800 border border-slate-700 rounded-lg px-2 py-1 text-xs text-white font-mono">
+                  <option value="">All Proto</option>
+                  <option value="TCP">TCP</option>
+                  <option value="UDP">UDP</option>
+                  <option value="ICMP">ICMP</option>
+                </select>
+                <button onclick="loadConnections()" class="text-xs text-emerald-400 hover:text-emerald-300 font-semibold px-2 py-1 bg-slate-800 rounded border border-slate-700">Refresh</button>
+              </div>
+            </div>
+
+            <div class="overflow-x-auto max-h-[550px] overflow-y-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/80 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans sticky top-0 z-10 backdrop-blur-sm">
+                  <tr>
+                    <th class="py-2.5 px-3">Proto</th>
+                    <th class="py-2.5 px-3">Source Address</th>
+                    <th class="py-2.5 px-3">Destination Address</th>
+                    <th class="py-2.5 px-3">Reply Source</th>
+                    <th class="py-2.5 px-3">Reply Dest</th>
+                    <th class="py-2.5 px-3">TCP State</th>
+                    <th class="py-2.5 px-3">Mark</th>
+                    <th class="py-2.5 px-3">Timeout</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Action</th>
+                  </tr>
+                </thead>
+                <tbody id="conns-table-body" class="divide-y divide-slate-800/60"></tbody>
+              </table>
+            </div>
+
+            <div id="conns-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No active connections found matching your search.
+            </div>
+          </div>
+        </section>
+
+        <!-- ================= SECTION: ROUTES & POLICY (/ip route & rules) ================= -->
         <section id="section-routes" class="hidden space-y-6">
-          <div class="bg-slate-900/90 border border-slate-800 rounded-xl p-5">
-            <div class="flex items-center justify-between mb-4">
-              <div>
-                <h3 class="text-sm font-bold text-white">Kernel Routing Table</h3>
-                <p class="text-xs text-slate-400">Active IPv4 & IPv6 Linux kernel routes and gateways</p>
+          <div class="bg-gradient-to-r from-cyan-950/40 via-slate-900 to-slate-900 border border-cyan-900/30 rounded-xl p-5 flex flex-wrap items-center justify-between gap-4">
+            <div>
+              <div class="flex items-center gap-2 mb-1">
+                <span class="px-2 py-0.5 rounded text-[10px] font-mono bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 font-semibold">/ip route</span>
+                <h2 class="text-base font-bold text-white">IP Routes & Policy Rules</h2>
+              </div>
+              <p class="text-xs text-slate-400">Kernel FIB/RIB routing tables, static gateways, multipath uplinks, and policy routing (ip rule / fwmark lookup).</p>
+            </div>
+
+            <div class="flex items-center space-x-2.5">
+              <button onclick="openAddRouteModal()" class="text-xs font-semibold px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white shadow-lg shadow-cyan-500/20 transition">
+                + Add Route
+              </button>
+              <button onclick="openAddPolicyRuleModal()" class="text-xs font-semibold px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-white border border-slate-700 transition">
+                + Add Rule
+              </button>
+              <button onclick="openAddTableModal()" class="text-xs font-semibold px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 transition">
+                + New Table
+              </button>
+            </div>
+          </div>
+
+          <!-- Sub Tabs: Routes / Policy Rules / Tables -->
+          <div class="flex border-b border-slate-800 space-x-1">
+            <button id="routes-subtab-btn-routes" onclick="switchRoutesSubTab('routes')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-cyan-500 text-white transition flex items-center gap-2">
+              <span>Routes</span>
+              <span id="routes-badge-routes" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+            </button>
+            <button id="routes-subtab-btn-rules" onclick="switchRoutesSubTab('rules')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2">
+              <span>Policy Rules (/ip route rule)</span>
+              <span id="routes-badge-rules" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+            </button>
+            <button id="routes-subtab-btn-tables" onclick="switchRoutesSubTab('tables')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2">
+              <span>Routing Tables</span>
+              <span id="routes-badge-tables" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
+            </button>
+          </div>
+
+          <!-- Sub-Tab 1: Routes Table -->
+          <div id="routes-subtab-routes" class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between flex-wrap gap-3">
+              <div class="flex items-center gap-3">
+                <h3 class="text-sm font-bold text-white">Active Routing Table (RIB)</h3>
+                <span id="routes-count-badge" class="text-xs font-mono text-slate-400">0 Routes</span>
+              </div>
+              <div class="flex items-center gap-2">
+                <label class="text-xs text-slate-400 font-sans">Table Filter:</label>
+                <select id="routes-filter-table" onchange="renderRoutesList()" class="bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-white font-mono">
+                  <option value="">All Tables</option>
+                  <option value="main">main</option>
+                </select>
+                <button onclick="loadRoutes()" class="text-xs text-cyan-400 hover:text-cyan-300 font-semibold px-2 py-1 bg-slate-800 rounded border border-slate-700">Refresh</button>
               </div>
-              <button onclick="loadRoutes()" class="text-xs text-brand-400 hover:text-brand-300 font-medium">Refresh Routes</button>
             </div>
 
             <div class="overflow-x-auto">
               <table class="w-full text-left text-xs font-mono">
-                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[11px] font-sans">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
                   <tr>
-                    <th class="py-3 px-4 font-semibold">Destination Subnet</th>
-                    <th class="py-3 px-4 font-semibold">Gateway</th>
-                    <th class="py-3 px-4 font-semibold">Interface</th>
-                    <th class="py-3 px-4 font-semibold">Protocol</th>
-                    <th class="py-3 px-4 font-semibold text-right">Scope</th>
+                    <th class="py-2.5 px-3 w-16 text-center" title="Flags: D=Dynamic, A=Active, C=Connect, S=Static, X=Disabled">Flags</th>
+                    <th class="py-2.5 px-3">Dst. Address</th>
+                    <th class="py-2.5 px-3">Gateway</th>
+                    <th class="py-2.5 px-3">Distance</th>
+                    <th class="py-2.5 px-3">Table / Mark</th>
+                    <th class="py-2.5 px-3">Interface</th>
+                    <th class="py-2.5 px-3">Comment</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Actions</th>
                   </tr>
                 </thead>
-                <tbody id="routes-table-body" class="divide-y divide-slate-800"></tbody>
+                <tbody id="routes-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+
+            <div id="routes-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No routes found.
+            </div>
+          </div>
+
+          <!-- Sub-Tab 2: Policy Rules (/ip route rule) -->
+          <div id="routes-subtab-rules" class="hidden bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <h3 class="text-sm font-bold text-white">Policy Routing Rules (ip rule)</h3>
+              <span id="rules-count-badge" class="text-xs font-mono text-slate-400">0 Rules</span>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3 w-16">Pref</th>
+                    <th class="py-2.5 px-3">Src. Address</th>
+                    <th class="py-2.5 px-3">Dst. Address</th>
+                    <th class="py-2.5 px-3">Routing Mark (fwmark)</th>
+                    <th class="py-2.5 px-3">Lookup Table</th>
+                    <th class="py-2.5 px-3">Action</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="rules-table-body" class="divide-y divide-slate-800/80"></tbody>
+              </table>
+            </div>
+
+            <div id="rules-empty" class="hidden p-10 text-center text-slate-500 text-xs font-sans">
+              No policy rules configured. Click "+ Add Rule" to route traffic based on routing-mark or source IP.
+            </div>
+          </div>
+
+          <!-- Sub-Tab 3: Routing Tables -->
+          <div id="routes-subtab-tables" class="hidden bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <h3 class="text-sm font-bold text-white">Defined Routing Tables (/etc/iproute2/rt_tables)</h3>
+              <span id="tables-count-badge" class="text-xs font-mono text-slate-400">0 Tables</span>
+            </div>
+
+            <div class="overflow-x-auto">
+              <table class="w-full text-left text-xs font-mono">
+                <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
+                  <tr>
+                    <th class="py-2.5 px-3 w-20">FIB ID</th>
+                    <th class="py-2.5 px-3">Table Name</th>
+                    <th class="py-2.5 px-3">Comment / Purpose</th>
+                  </tr>
+                </thead>
+                <tbody id="tables-table-body" class="divide-y divide-slate-800/80"></tbody>
               </table>
             </div>
           </div>
@@ -1290,6 +1665,529 @@
     </div>
   </div>
 
+  <!-- ================= MODAL: FIREWALL FILTER RULE ================= -->
+  <div id="filter-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-xl shadow-2xl overflow-hidden animate-in max-h-[90vh] flex flex-col">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <h3 id="filter-modal-title" class="text-sm font-bold text-white">Add Firewall Filter Rule</h3>
+        <button onclick="closeFilterModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form onsubmit="submitSaveFilterRule(event)" class="p-6 space-y-4 overflow-y-auto flex-1 text-xs">
+        <input type="hidden" id="filter-modal-id">
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1">Chain *</label>
+            <select id="filter-modal-chain" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+              <option value="input">input (traffic to router)</option>
+              <option value="forward" selected>forward (routed traffic)</option>
+              <option value="output">output (traffic from router)</option>
+            </select>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1">Action *</label>
+            <select id="filter-modal-action" onchange="toggleFilterActionFields()" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+              <option value="accept" selected>accept</option>
+              <option value="drop">drop</option>
+              <option value="reject">reject</option>
+              <option value="log">log</option>
+              <option value="jump">jump</option>
+              <option value="return">return</option>
+            </select>
+          </div>
+        </div>
+
+        <div id="filter-action-reject-box" class="hidden p-3 bg-rose-950/20 border border-rose-900/30 rounded-lg">
+          <label class="block font-semibold text-rose-300 mb-1">Reject With</label>
+          <select id="filter-modal-rejectwith" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            <option value="icmp-network-unreachable">icmp-network-unreachable</option>
+            <option value="icmp-host-unreachable">icmp-host-unreachable</option>
+            <option value="icmp-port-unreachable">icmp-port-unreachable</option>
+            <option value="tcp-reset">tcp-reset (TCP RST)</option>
+          </select>
+        </div>
+
+        <div class="p-3 bg-slate-950/60 border border-slate-800/80 rounded-xl space-y-3">
+          <span class="block font-bold text-slate-400 uppercase tracking-wider text-[10px]">Match Properties</span>
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Protocol</label>
+              <select id="filter-modal-proto" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                <option value="all">all</option>
+                <option value="tcp">tcp (6)</option>
+                <option value="udp">udp (17)</option>
+                <option value="icmp">icmp (1)</option>
+                <option value="gre">gre (47)</option>
+                <option value="esp">esp (50)</option>
+              </select>
+            </div>
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Connection Mark</label>
+              <input type="text" id="filter-modal-connmark" placeholder="e.g. wan2_conn" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+          </div>
+
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Src. Address / CIDR</label>
+              <input type="text" id="filter-modal-src" placeholder="192.168.1.0/24 or !10.0.0.1" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Dst. Address / CIDR</label>
+              <input type="text" id="filter-modal-dst" placeholder="0.0.0.0/0 or !192.168.10.1" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+          </div>
+
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Src. Port(s)</label>
+              <input type="text" id="filter-modal-sport" placeholder="80,443 or 1000-2000" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Dst. Port(s)</label>
+              <input type="text" id="filter-modal-dport" placeholder="80,443,8080" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+          </div>
+
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">In. Interface</label>
+              <select id="filter-modal-iniface" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono"></select>
+            </div>
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Out. Interface</label>
+              <select id="filter-modal-outiface" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono"></select>
+            </div>
+          </div>
+
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Connection State</label>
+            <div class="flex flex-wrap gap-3">
+              <label class="flex items-center space-x-1.5 cursor-pointer">
+                <input type="checkbox" id="filter-modal-cs-established" class="rounded bg-slate-800 border-slate-700 text-emerald-500">
+                <span class="text-slate-300">established</span>
+              </label>
+              <label class="flex items-center space-x-1.5 cursor-pointer">
+                <input type="checkbox" id="filter-modal-cs-related" class="rounded bg-slate-800 border-slate-700 text-emerald-500">
+                <span class="text-slate-300">related</span>
+              </label>
+              <label class="flex items-center space-x-1.5 cursor-pointer">
+                <input type="checkbox" id="filter-modal-cs-new" class="rounded bg-slate-800 border-slate-700 text-emerald-500">
+                <span class="text-slate-300">new</span>
+              </label>
+              <label class="flex items-center space-x-1.5 cursor-pointer">
+                <input type="checkbox" id="filter-modal-cs-invalid" class="rounded bg-slate-800 border-slate-700 text-rose-500">
+                <span class="text-slate-300">invalid</span>
+              </label>
+            </div>
+          </div>
+
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Src. Address List</label>
+              <select id="filter-modal-srclist" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                <option value="">(None)</option>
+              </select>
+            </div>
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Dst. Address List</label>
+              <select id="filter-modal-dstlist" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                <option value="">(None)</option>
+              </select>
+            </div>
+          </div>
+        </div>
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1">Comment</label>
+          <input type="text" id="filter-modal-comment" placeholder="e.g. Drop invalid packets" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white">
+        </div>
+
+        <div class="pt-2">
+          <label class="flex items-center space-x-2 cursor-pointer">
+            <input type="checkbox" id="filter-modal-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-500">
+            <span class="font-medium text-slate-300">Disabled (X)</span>
+          </label>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeFilterModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-500/20">Save Filter Rule</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: FIREWALL NAT RULE ================= -->
+  <div id="nat-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-xl shadow-2xl overflow-hidden animate-in max-h-[90vh] flex flex-col">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <h3 id="nat-modal-title" class="text-sm font-bold text-white">Add NAT Rule</h3>
+        <button onclick="closeNATModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form onsubmit="submitSaveNATRule(event)" class="p-6 space-y-4 overflow-y-auto flex-1 text-xs">
+        <input type="hidden" id="nat-modal-id">
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1">Chain *</label>
+            <select id="nat-modal-chain" onchange="toggleNATChainFields()" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+              <option value="srcnat" selected>srcnat (Outgoing / Masquerade)</option>
+              <option value="dstnat">dstnat (Incoming / Port Forwarding)</option>
+            </select>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1">Action *</label>
+            <select id="nat-modal-action" onchange="toggleNATActionFields()" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+              <option value="masquerade" selected>masquerade</option>
+              <option value="src-nat">src-nat</option>
+              <option value="dst-nat">dst-nat</option>
+              <option value="redirect">redirect</option>
+              <option value="accept">accept</option>
+            </select>
+          </div>
+        </div>
+
+        <div id="nat-action-to-box" class="p-3 bg-slate-950/60 border border-slate-800/80 rounded-xl space-y-3">
+          <span class="block font-bold text-slate-400 uppercase tracking-wider text-[10px]">NAT Target (To Addresses / Ports)</span>
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">To Addresses</label>
+              <input type="text" id="nat-modal-toaddr" placeholder="192.168.10.50" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">To Ports</label>
+              <input type="text" id="nat-modal-toport" placeholder="80 or 8080" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+          </div>
+        </div>
+
+        <div class="p-3 bg-slate-950/60 border border-slate-800/80 rounded-xl space-y-3">
+          <span class="block font-bold text-slate-400 uppercase tracking-wider text-[10px]">Match Properties</span>
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Protocol</label>
+              <select id="nat-modal-proto" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                <option value="all">all</option>
+                <option value="tcp">tcp</option>
+                <option value="udp">udp</option>
+                <option value="icmp">icmp</option>
+              </select>
+            </div>
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Dst. Port(s)</label>
+              <input type="text" id="nat-modal-dport" placeholder="80,443" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+          </div>
+
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Src. Address / CIDR</label>
+              <input type="text" id="nat-modal-src" placeholder="192.168.10.0/24" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Dst. Address / CIDR</label>
+              <input type="text" id="nat-modal-dst" placeholder="e.g. WAN Public IP" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+          </div>
+
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">In. Interface</label>
+              <select id="nat-modal-iniface" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono"></select>
+            </div>
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Out. Interface</label>
+              <select id="nat-modal-outiface" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono"></select>
+            </div>
+          </div>
+        </div>
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1">Comment</label>
+          <input type="text" id="nat-modal-comment" placeholder="e.g. Masquerade to WAN" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white">
+        </div>
+
+        <div class="pt-2">
+          <label class="flex items-center space-x-2 cursor-pointer">
+            <input type="checkbox" id="nat-modal-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-500">
+            <span class="font-medium text-slate-300">Disabled (X)</span>
+          </label>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeNATModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-500/20">Save NAT Rule</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: FIREWALL MANGLE RULE ================= -->
+  <div id="mangle-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-xl shadow-2xl overflow-hidden animate-in max-h-[90vh] flex flex-col">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <h3 id="mangle-modal-title" class="text-sm font-bold text-white">Add Mangle Rule</h3>
+        <button onclick="closeMangleModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form onsubmit="submitSaveMangleRule(event)" class="p-6 space-y-4 overflow-y-auto flex-1 text-xs">
+        <input type="hidden" id="mangle-modal-id">
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1">Chain *</label>
+            <select id="mangle-modal-chain" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+              <option value="prerouting" selected>prerouting (Policy Routing / Marks)</option>
+              <option value="forward">forward</option>
+              <option value="postrouting">postrouting</option>
+              <option value="input">input</option>
+              <option value="output">output</option>
+            </select>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1">Action *</label>
+            <select id="mangle-modal-action" onchange="toggleMangleActionFields()" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+              <option value="mark-connection" selected>mark-connection</option>
+              <option value="mark-routing">mark-routing (Policy Routing)</option>
+              <option value="mark-packet">mark-packet (QoS / Queues)</option>
+              <option value="change-mss">change-mss (TCP MSS Clamp)</option>
+              <option value="set-dscp">set-dscp</option>
+              <option value="accept">accept</option>
+            </select>
+          </div>
+        </div>
+
+        <div id="mangle-target-box" class="p-3 bg-slate-950/60 border border-slate-800/80 rounded-xl space-y-3">
+          <span class="block font-bold text-slate-400 uppercase tracking-wider text-[10px]">Mark Target & Passthrough</span>
+          <div class="grid grid-cols-2 gap-3">
+            <div id="mangle-field-newmark">
+              <label class="block font-semibold text-slate-300 mb-1">New Mark Name</label>
+              <input type="text" id="mangle-modal-newmark" placeholder="e.g. wan2_conn or to_WAN2" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+            <div class="flex items-center pt-5">
+              <label class="flex items-center space-x-2 cursor-pointer">
+                <input type="checkbox" id="mangle-modal-passthrough" checked class="rounded bg-slate-800 border-slate-700 text-emerald-500">
+                <span class="font-medium text-slate-300">Passthrough</span>
+              </label>
+            </div>
+          </div>
+        </div>
+
+        <div class="p-3 bg-slate-950/60 border border-slate-800/80 rounded-xl space-y-3">
+          <span class="block font-bold text-slate-400 uppercase tracking-wider text-[10px]">Match Criteria</span>
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Src. Address / CIDR</label>
+              <input type="text" id="mangle-modal-src" placeholder="192.168.10.0/24" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Dst. Address / CIDR</label>
+              <input type="text" id="mangle-modal-dst" placeholder="!192.168.0.0/16" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+          </div>
+
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">Connection Mark</label>
+              <input type="text" id="mangle-modal-connmark" placeholder="e.g. wan2_conn" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+            </div>
+            <div>
+              <label class="block font-semibold text-slate-300 mb-1">In. Interface</label>
+              <select id="mangle-modal-iniface" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono"></select>
+            </div>
+          </div>
+        </div>
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1">Comment</label>
+          <input type="text" id="mangle-modal-comment" placeholder="e.g. Mark routing for Office traffic" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white">
+        </div>
+
+        <div class="pt-2">
+          <label class="flex items-center space-x-2 cursor-pointer">
+            <input type="checkbox" id="mangle-modal-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-500">
+            <span class="font-medium text-slate-300">Disabled (X)</span>
+          </label>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeMangleModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-500/20">Save Mangle Rule</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: ADDRESS LIST ENTRY ================= -->
+  <div id="addrlist-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <h3 id="addrlist-modal-title" class="text-sm font-bold text-white">Add Address List Entry</h3>
+        <button onclick="closeAddrListModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form onsubmit="submitSaveAddrList(event)" class="p-6 space-y-4 text-xs">
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">List Name *</label>
+          <input type="text" id="addrlist-modal-name" placeholder="e.g. LAN_Subnets, Blocked_IPs" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+        </div>
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">IP Address / Subnet (CIDR) *</label>
+          <input type="text" id="addrlist-modal-addr" placeholder="192.168.10.0/24 or 203.0.113.5" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-emerald-500">
+        </div>
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+          <input type="text" id="addrlist-modal-comment" placeholder="Optional description" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
+        </div>
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeAddrListModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-500/20">Add to List</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: ADD / EDIT ROUTE ================= -->
+  <div id="route-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <h3 id="route-modal-title" class="text-sm font-bold text-white">Add Static Route</h3>
+        <button onclick="closeRouteModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form onsubmit="submitSaveRoute(event)" class="p-6 space-y-4 text-xs">
+        <input type="hidden" id="route-modal-id">
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Dst. Address (CIDR) *</label>
+          <input type="text" id="route-modal-dst" value="0.0.0.0/0" placeholder="0.0.0.0/0 or 10.0.0.0/8" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-cyan-500">
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Gateway (IP or Interface) *</label>
+            <input type="text" id="route-modal-gw" placeholder="192.168.1.1 or wg0" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-cyan-500">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Routing Table / Mark</label>
+            <select id="route-modal-table" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-cyan-500">
+              <option value="main" selected>main</option>
+            </select>
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Distance (Metric)</label>
+            <input type="number" id="route-modal-distance" value="1" min="1" max="255" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Interface (Optional)</label>
+            <select id="route-modal-iface" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono"></select>
+          </div>
+        </div>
+
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+          <input type="text" id="route-modal-comment" placeholder="e.g. Primary WAN Default Gateway" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
+        </div>
+
+        <div class="pt-2">
+          <label class="flex items-center space-x-2 cursor-pointer">
+            <input type="checkbox" id="route-modal-disabled" class="rounded bg-slate-800 border-slate-700 text-rose-500">
+            <span class="font-medium text-slate-300">Disabled (X)</span>
+          </label>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeRouteModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-cyan-600 hover:bg-cyan-500 text-white shadow-lg shadow-cyan-500/20">Save Route</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: ADD POLICY ROUTING RULE ================= -->
+  <div id="prule-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <h3 id="prule-modal-title" class="text-sm font-bold text-white">Add Policy Routing Rule (/ip route rule)</h3>
+        <button onclick="closePolicyRuleModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form onsubmit="submitSavePolicyRule(event)" class="p-6 space-y-4 text-xs">
+        <input type="hidden" id="prule-modal-id">
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Priority / Preference</label>
+            <input type="number" id="prule-modal-pref" placeholder="1000" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Routing Mark (fwmark)</label>
+            <input type="text" id="prule-modal-mark" placeholder="e.g. to_WAN2 or 100" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Src. Address (CIDR)</label>
+            <input type="text" id="prule-modal-src" placeholder="192.168.10.0/24" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Dst. Address (CIDR)</label>
+            <input type="text" id="prule-modal-dst" placeholder="0.0.0.0/0" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+          </div>
+        </div>
+
+        <div class="grid grid-cols-2 gap-4">
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Lookup Table *</label>
+            <select id="prule-modal-table" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono"></select>
+          </div>
+          <div>
+            <label class="block font-semibold text-slate-300 mb-1.5">Action</label>
+            <select id="prule-modal-action" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+              <option value="lookup" selected>lookup (standard)</option>
+              <option value="unreachable">unreachable</option>
+              <option value="blackhole">blackhole</option>
+            </select>
+          </div>
+        </div>
+
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closePolicyRuleModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-cyan-600 hover:bg-cyan-500 text-white shadow-lg shadow-cyan-500/20">Save Policy Rule</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: CREATE ROUTING TABLE ================= -->
+  <div id="table-modal" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-in">
+      <div class="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
+        <h3 class="text-sm font-bold text-white">Create New Routing Table</h3>
+        <button onclick="closeTableModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form onsubmit="submitSaveTable(event)" class="p-6 space-y-4 text-xs">
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Table Name *</label>
+          <input type="text" id="table-modal-name" placeholder="e.g. wan2_traffic, vpn_table" required class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-cyan-500">
+        </div>
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">FIB Table ID (Optional)</label>
+          <input type="number" id="table-modal-id" min="1" max="252" placeholder="Auto (100+)" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white font-mono">
+        </div>
+        <div>
+          <label class="block font-semibold text-slate-300 mb-1.5">Comment</label>
+          <input type="text" id="table-modal-comment" placeholder="Purpose of this routing table" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white">
+        </div>
+        <div class="pt-4 border-t border-slate-800 flex justify-end space-x-3">
+          <button type="button" onclick="closeTableModal()" class="px-4 py-2 rounded-lg text-slate-400 hover:bg-slate-800">Cancel</button>
+          <button type="submit" class="px-5 py-2 rounded-lg font-semibold bg-cyan-600 hover:bg-cyan-500 text-white shadow-lg shadow-cyan-500/20">Create Table</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
   <script src="app.js"></script>
 </body>
 </html>