Просмотр исходного кода

Release 1.3.0: native Linux firewall forms, safe rule compilation and runtime fixes

Proxmox Router 2 дней назад
Родитель
Сommit
5113dd3f29

+ 5 - 3
README.md

@@ -2,7 +2,7 @@
 
 DebTik is a Debian/Proxmox router appliance with an embedded web interface, REST API and RouterOS-style CLI. It manages network interfaces, routing, firewall/NAT, DHCP/DNS, WireGuard, IPsec, PPP, VRRP and AeroTUN. AeroTUN v3 uses pinned peer identities and encrypted tunnels; it works with native TUN or a veth fallback when `/dev/net/tun` is unavailable.
 
-Current release: **1.2.2**. See [functionality corrections and verification](docs/functionality-fixes.md) for network persistence, firewall isolation/IPv6, service error handling and certificate renewal changes.
+Current release: **1.3.0**. See [functionality corrections and verification](docs/functionality-fixes.md) for network persistence, firewall isolation/IPv6, service error handling and certificate renewal changes.
 
 ## Install
 
@@ -21,10 +21,10 @@ The internal Git server uses a private CA. Trust that CA before cloning, or repl
 ```sh
 git -c http.sslVerify=false \
   -c 'http.pinnedpubkey=sha256//u4xyt0njDzn4BAQnS6OTGrXBxVE8vMg5BSNIuryUsMo=' \
-  clone --depth 1 --branch v1.2.2 https://gogs.eparmak.local/eparmak/DebTik.git
+  clone --depth 1 --branch v1.3.0 https://gogs.eparmak.local/eparmak/DebTik.git
 ```
 
-This verifies the server's pinned key for this command; it does not change global Git settings. Reverify the pin if the server's TLS key changes. For a reproducible fresh installation, `v1.2.2` contains the complete dependency installer.
+This verifies the server's pinned key for this command; it does not change global Git settings. Reverify the pin if the server's TLS key changes. For a reproducible fresh installation, `v1.3.0` contains the complete dependency installer and native Linux firewall corrections.
 
 `install.sh` installs and verifies the dependencies listed in [Dependencies](docs/dependencies.md), including Go, WireGuard, firewall, BIRD, DHCP/DNS, PPP clients and servers, strongSwan plugins, diagnostics and the RSTP daemon. It builds `proxmox-router` and `debtik-cli`, installs the updater helpers, and starts `proxmox-router.service`. It can be run again; existing `/etc/proxmox-router` files and tunnel keys are preserved. Previous binaries are saved as `/usr/local/bin/*.previous`. For native TUN in an LXC container, enable `/dev/net/tun` on the Proxmox host; otherwise AeroTUN uses veth.
 
@@ -58,6 +58,8 @@ In **Updates**, enter an HTTP(S) or SSH Git repository, then save and check for
 
 `install.sh` must have been run from a Git checkout at least once to install the updater helper. Git access from the appliance to the selected repository and the required Go toolchain are needed. Git credentials belong in the appliance's Git/SSH configuration; repository URLs containing embedded credentials are rejected. For a private Git server whose CA is not trusted by Debian, enter its verified `sha256//...` TLS public key pin in Updates. Rotate the pin when that server changes its TLS key.
 
+See [native Linux firewall behavior and supported options](docs/firewall-native.md).
+
 ## Development and checks
 
 ```sh

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.2.2
+1.3.0

+ 2 - 2
docs/dependencies.md

@@ -7,7 +7,7 @@ version without installing anything. Debian 13, systemd and root are required.
 
 | Feature | Debian packages |
 | --- | --- |
-| Shell and system management | bash, coreutils, grep, gawk, sed, findutils, util-linux, systemd, procps, tzdata |
+| Shell and system management | bash, coreutils, grep, gawk, sed, findutils, util-linux, systemd, procps, kmod, tzdata |
 | Downloads, Git, SSH repositories and helpers | ca-certificates, curl, git, openssh-client, tar, python3, openssl |
 | Interfaces, bridges and firewall | iproute2, bridge-utils, iptables, ipset, conntrack |
 | WireGuard | wireguard-tools |
@@ -60,7 +60,7 @@ on an existing installation; the updater does not run apt automatically.
 
 The dependency installation and command check were exercised on the existing
 AMD64 appliance. RSTP daemon/control integration was verified using a real bridge
-in an isolated network namespace. All 42 runtime packages and the source-build
+in an isolated network namespace. All 43 runtime packages and the source-build
 tools were checked against Debian 13 ARM64 package metadata. The server and CLI
 are cross-built for ARM64; a complete fresh ARM64 installation remains a separate
 end-to-end deployment check.

+ 91 - 0
docs/firewall-native.md

@@ -0,0 +1,91 @@
+# Native Linux firewall — DebTik 1.3.0
+
+The firewall forms describe the implemented iptables/ip6tables backend.
+Unsupported RouterOS template options have been removed from the forms. The API
+rejects unsupported actions and populated legacy fields with a clear error;
+it never substitutes ACCEPT or MASQUERADE for an unknown action.
+`GET /api/firewall/capabilities` lists the implemented fields/actions.
+
+Supported matches include IPv4/IPv6 addresses, protocols, source/destination/any
+ports, explicit interfaces, nonempty LAN/WAN role lists, Linux packet/connection
+marks, assigned conntrack helpers, connection/NAT states, address lists, IPv4
+GeoIP, content string, MAC, bridge physical ports, IPsec policy, DSCP/TOS, TCP
+MSS/flags, packet length, random percentage, TTL/hop-limit, UTC schedules and
+packet rate limits. Mangle also supports nth, connection limits, address types
+and IPv4 non-initial fragment matching. Kernel validation checks every change
+before application. Matches needing a kernel extension fail explicitly if the
+host does not provide it.
+
+Ports accept `443`, `80,443`, `1000-2000` or `1000:2000`; inversion is supported
+for the standard address, interface, protocol, mark, list and port matches.
+Negated interface *lists* are not implemented; their inversion controls were
+removed. Use an explicit negated interface where appropriate. An empty or unknown
+interface role returns an error instead of dropping the entire rule silently.
+A rule may select an explicit interface or an interface list for each direction.
+
+Packet and routing mark criteria refer to the same Linux fwmark. Separate
+conflicting packet/routing mark criteria are rejected. Marks set by mangle retain
+the existing named-mark mapping used by policy routing. Connection helpers match
+already assigned helpers; selecting a helper does not configure conntrack helper
+assignment. Content is a packet string match, not TLS inspection or full stream
+reassembly. Bridge-port matching depends on the host's bridge/netfilter setup.
+
+Schedules use UTC and accept `HH:MM[:SS]-HH:MM[:SS]`, with weekday selection in
+Filter/NAT. Rate limits accept `100/second,5`, `100/5s,5`, or the compatible
+`100,5:packet` notation (rate, burst). UI custom chains are editable and limited
+to 16 characters, allowing the internal owned prefix to fit Linux chain limits.
+Custom chains need a jump from another chain to receive traffic.
+
+Filter supports accept/drop/reject, jump/return, logging, passthrough counters and
+adding source/destination IPs to a dynamic list. NAT supports masquerade, SNAT,
+DNAT, redirect and IPv4 NETMAP, plus the shared non-translating actions. NETMAP
+requires a CIDR and translates addresses only; port translation uses the other
+NAT actions. Masquerade supports to-ports for TCP/UDP. Mangle supports native
+connection/packet/routing marks, MSS, DSCP (including 0), TTL/hop-limit and Linux
+packet priority, plus the shared actions. MSS rewriting handles a selected TCP
+protocol without duplicate protocol options. IPv6 ICMP is matched as ICMPv6.
+
+The Log checkbox produces a logging rule before the selected action. For mangle,
+`passthrough=false` uses a helper and goto so it stops that managed chain even if
+the action changes a mark used by the original criterion. Foreign chains,
+policies and counters remain outside DebTik ownership.
+
+Address lists use separate static and dynamic sets and a matching union. Edits
+replace only the static set; dynamic IPs survive Save/Reapply. Dynamic timeouts
+accept `0` (no expiry), `30s`, `5m`, `1h` or `1d`. Dynamic entries appear in the UI
+and may be deleted; they are not edited as static records. At most 2,000 dynamic
+entries are included in a list response. Set names are hashed to avoid UI-name
+collisions. Unused owned sets/chains are collected only when no external kernel
+reference prevents deletion. Country sets use validated bulk restore and atomic
+swap, with unchanged-data caching rather than a process per subnet.
+
+An enabled country referenced directly or through a preset by an active rule
+cannot be disabled. No referenced GeoIP set is flushed to pretend success.
+Batch/update failures are reported; failed background updates retain the last
+successful update timestamp and expose `last_error`. GeoIP currently provides
+IPv4 data, and an IPv6 GeoIP match is rejected.
+
+Counters survive unrelated edits and reapplication. The per-rule Reset button
+resets kernel counters through a dedicated endpoint. Empty saved rule lists stay
+empty across restart; defaults are created only for a first installation. Config
+load/application failures are logged and appear in the firewall status banner.
+Saved configuration files are staged before replacement, with restoration on
+replacement failure. A failed change restores the prior managed configuration.
+
+Connection tracking reads both families, parses actual timeouts and traffic
+counters, and surfaces command failures. Deletion requires a complete tuple,
+including ports for TCP/UDP and ID/type/code for ICMP. Incomplete selectors are
+rejected before launching conntrack. Unsupported per-flow delete protocols are
+not offered as Kill actions. Comments and other stored firewall strings are
+escaped before rendering.
+
+Legacy unsupported settings remain readable so an operator can review them.
+Saving a rule with removed settings asks the UI operator to review their removal;
+unsupported active settings are rejected rather than silently ignored at startup.
+Disabled rules are not compiled. Configuration health is distinct from a successful
+HTTP read of the configuration.
+
+Verification covers real private kernel namespaces, rollback, both IP families,
+packet/counter behavior, dynamic expiration/preservation, GeoIP in-use guards,
+request validation, browser form submissions, legacy/custom-chain handling and
+XSS regression. Production firewall edits are not used for destructive tests.

+ 2 - 0
internal/api/api.go

@@ -95,6 +95,8 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("POST /api/routing/tables", s.handleSaveRoutingTable)
 
 	// Firewall Endpoints (/ip firewall)
+	s.mux.HandleFunc("GET /api/firewall/capabilities", s.handleFirewallCapabilities)
+	s.mux.HandleFunc("POST /api/firewall/{kind}/{id}/reset-counters", s.handleResetRuleCounters)
 	s.mux.HandleFunc("GET /api/firewall/summary", s.handleFirewallSummary)
 	s.mux.HandleFunc("POST /api/firewall/reset-counters", s.handleResetFirewallCounters)
 

+ 38 - 11
internal/api/firewall_handlers.go

@@ -31,13 +31,19 @@ func (s *Server) handleListFilterRules(w http.ResponseWriter, r *http.Request) {
 
 func (s *Server) handleSaveFilterRule(w http.ResponseWriter, r *http.Request) {
 	var rule firewall.FilterRule
-	if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
+	decoder := json.NewDecoder(r.Body)
+	decoder.DisallowUnknownFields()
+	if err := decoder.Decode(&rule); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body")
 		return
 	}
 	if rule.ID == "" {
 		rule.ID = fmt.Sprintf("filter-%d", time.Now().UnixNano())
 	}
+	if err := firewall.ValidateRule("filter", rule); err != nil {
+		writeError(w, http.StatusUnprocessableEntity, err.Error())
+		return
+	}
 	if err := s.fwMgr.SaveFilterRule(rule); err != nil {
 		writeError(w, http.StatusInternalServerError, err.Error())
 		return
@@ -88,13 +94,19 @@ func (s *Server) handleListNATRules(w http.ResponseWriter, r *http.Request) {
 
 func (s *Server) handleSaveNATRule(w http.ResponseWriter, r *http.Request) {
 	var rule firewall.NATRule
-	if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
+	decoder := json.NewDecoder(r.Body)
+	decoder.DisallowUnknownFields()
+	if err := decoder.Decode(&rule); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body")
 		return
 	}
 	if rule.ID == "" {
 		rule.ID = fmt.Sprintf("nat-%d", time.Now().UnixNano())
 	}
+	if err := firewall.ValidateRule("nat", rule); err != nil {
+		writeError(w, http.StatusUnprocessableEntity, err.Error())
+		return
+	}
 	if err := s.fwMgr.SaveNATRule(rule); err != nil {
 		writeError(w, http.StatusInternalServerError, err.Error())
 		return
@@ -145,13 +157,19 @@ func (s *Server) handleListMangleRules(w http.ResponseWriter, r *http.Request) {
 
 func (s *Server) handleSaveMangleRule(w http.ResponseWriter, r *http.Request) {
 	var rule firewall.MangleRule
-	if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
+	decoder := json.NewDecoder(r.Body)
+	decoder.DisallowUnknownFields()
+	if err := decoder.Decode(&rule); err != nil {
 		writeError(w, http.StatusBadRequest, "invalid request body")
 		return
 	}
 	if rule.ID == "" {
 		rule.ID = fmt.Sprintf("mangle-%d", time.Now().UnixNano())
 	}
+	if err := firewall.ValidateRule("mangle", rule); err != nil {
+		writeError(w, http.StatusUnprocessableEntity, err.Error())
+		return
+	}
 	if err := s.fwMgr.SaveMangleRule(rule); err != nil {
 		writeError(w, http.StatusInternalServerError, err.Error())
 		return
@@ -327,18 +345,16 @@ func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request) {
 }
 
 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"`
-	}
+	var req firewall.ConnectionSelector
 	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 {
+	if err := firewall.ValidateConnectionSelector(req); err != nil {
+		writeError(w, http.StatusUnprocessableEntity, err.Error())
+		return
+	}
+	if err := firewall.KillFlow(req); err != nil {
 		writeError(w, http.StatusInternalServerError, err.Error())
 		return
 	}
@@ -430,3 +446,14 @@ func (s *Server) handleSaveRoutingTable(w http.ResponseWriter, r *http.Request)
 	}
 	writeJSON(w, http.StatusOK, map[string]any{"success": true, "name": table.Name})
 }
+
+func (s *Server) handleFirewallCapabilities(w http.ResponseWriter, r *http.Request) {
+	writeJSON(w, 200, firewall.Capabilities())
+}
+func (s *Server) handleResetRuleCounters(w http.ResponseWriter, r *http.Request) {
+	if err := s.fwMgr.ResetRuleCounters(r.PathValue("kind"), r.PathValue("id")); err != nil {
+		writeError(w, 400, err.Error())
+		return
+	}
+	writeJSON(w, 200, map[string]any{"success": true})
+}

+ 41 - 0
internal/api/firewall_validation_test.go

@@ -0,0 +1,41 @@
+package api
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func TestFirewallRejectsUnsupportedAndIncompleteRequests(t *testing.T) {
+	s := &Server{} // Validation must complete without invoking any manager or tool.
+	for _, tc := range []struct {
+		handler http.HandlerFunc
+		body    string
+		status  int
+	}{
+		{s.handleSaveFilterRule, `{"chain":"input","action":"tarpit"}`, 422},
+		{s.handleSaveFilterRule, `{"chain":"input","action":"drop","tls_host":"*.example.com"}`, 422},
+		{s.handleSaveNATRule, `{"chain":"srcnat","action":"same"}`, 422},
+		{s.handleSaveMangleRule, `{"chain":"output","action":"sniff-pc"}`, 422},
+		{s.handleSaveFilterRule, `{"chain":"input","action":"accept","typo":true}`, 400},
+		{s.handleKillConnection, `{}`, 422},
+		{s.handleKillConnection, `{"protocol":"tcp","src":"192.0.2.1","dst":"198.51.100.1"}`, 422},
+	} {
+		r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body))
+		w := httptest.NewRecorder()
+		tc.handler(w, r)
+		if w.Code != tc.status {
+			t.Fatalf("%s returned %d: %s", tc.body, w.Code, w.Body.String())
+		}
+	}
+}
+func TestFirewallRoutePatterns(t *testing.T) {
+	s := &Server{mux: http.NewServeMux(), fsHandler: http.NotFoundHandler()}
+	s.registerRoutes()
+	w := httptest.NewRecorder()
+	s.mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/firewall/capabilities", nil))
+	if w.Code != 200 {
+		t.Fatal(w.Code)
+	}
+}

+ 475 - 0
internal/firewall/advanced.go

@@ -0,0 +1,475 @@
+package firewall
+
+import (
+	"fmt"
+	"net"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+)
+
+func inferProtocol(v map[string]any) string {
+	inv, p := plain(strings.ToLower(text(v, "protocol")))
+	family, _ := v["ip_version"].(float64)
+	if p == "6" {
+		p = "tcp"
+	}
+	if p == "17" {
+		p = "udp"
+	}
+	if p == "1" {
+		p = "icmp"
+	}
+	if p == "58" || p == "icmpv6" {
+		p = "ipv6-icmp"
+	}
+	if family == 6 && p == "icmp" {
+		p = "ipv6-icmp"
+	}
+	if p == "" || p == "all" {
+		if text(v, "icmp_options") != "" {
+			p = "icmp"
+			if family == 6 {
+				p = "ipv6-icmp"
+			}
+		} else if text(v, "src_port") != "" || text(v, "dst_port") != "" || text(v, "any_port") != "" || text(v, "tcp_flags") != "" || text(v, "tcp_mss") != "" || text(v, "action") == "change-mss" {
+			p = "tcp"
+		}
+	}
+	if inv != "" {
+		return "!" + p
+	}
+	return p
+}
+func parseRange(s string, max int) (string, error) {
+	s = portValue(s)
+	parts := strings.Split(s, ":")
+	if len(parts) > 2 {
+		return "", fmt.Errorf("invalid range")
+	}
+	for _, p := range parts {
+		if e := intRange(p, 0, max); e != nil {
+			return "", e
+		}
+	}
+	if len(parts) == 2 {
+		a, _ := strconv.Atoi(parts[0])
+		b, _ := strconv.Atoi(parts[1])
+		if a > b {
+			return "", fmt.Errorf("reversed range")
+		}
+	}
+	return s, nil
+}
+func tcpFlags(s string) (string, string, error) {
+	var mask, on []string
+	for _, part := range strings.Split(strings.ToUpper(s), ",") {
+		inv, p := plain(strings.TrimSpace(part))
+		switch p {
+		case "SYN", "ACK", "FIN", "RST", "URG", "PSH":
+		default:
+			return "", "", fmt.Errorf("unsupported TCP flag %q", p)
+		}
+		mask = append(mask, p)
+		if inv == "" {
+			on = append(on, p)
+		}
+	}
+	set := "NONE"
+	if len(on) > 0 {
+		set = strings.Join(on, ",")
+	}
+	return strings.Join(mask, ","), set, nil
+}
+func ttlParts(s string) (string, string, error) {
+	op := "eq"
+	n := s
+	if i := strings.IndexByte(s, ':'); i >= 0 {
+		n = s[i+1:]
+		switch s[:i] {
+		case "equal":
+			op = "eq"
+		case "less-than":
+			op = "lt"
+		case "greater-than":
+			op = "gt"
+		default:
+			return "", "", fmt.Errorf("TTL expects equal:N, less-than:N, or greater-than:N")
+		}
+	}
+	if err := intRange(n, 0, 255); err != nil {
+		return "", "", err
+	}
+	return op, n, nil
+}
+
+var ratePattern = regexp.MustCompile(`^([0-9]+(?:\.[0-9]+)?)(?:/([0-9]*)(s|sec|second|m|min|minute|h|hour))?$`)
+
+func rateParts(s string) (string, string, error) {
+	s = strings.TrimSuffix(s, ":packet")
+	parts := strings.Split(s, ",")
+	if len(parts) > 2 {
+		return "", "", fmt.Errorf("limit expects rate[,burst], for example 100/second,5")
+	}
+	m := ratePattern.FindStringSubmatch(parts[0])
+	if m == nil {
+		return "", "", fmt.Errorf("invalid packet rate")
+	}
+	rate, _ := strconv.ParseFloat(m[1], 64)
+	period := 1.0
+	if m[2] != "" {
+		period, _ = strconv.ParseFloat(m[2], 64)
+	}
+	switch m[3] {
+	case "m", "min", "minute":
+		period *= 60
+	case "h", "hour":
+		period *= 3600
+	}
+	if period <= 0 || rate <= 0 {
+		return "", "", fmt.Errorf("rate must be positive")
+	}
+	burst := "5"
+	if len(parts) == 2 {
+		burst = parts[1]
+		if e := intRange(burst, 1, 2147483647); e != nil {
+			return "", "", e
+		}
+	}
+	return strconv.FormatFloat(rate/period, 'f', 6, 64) + "/second", burst, nil
+}
+
+var weekdays = map[string]string{"sun": "Sun", "mon": "Mon", "tue": "Tue", "wed": "Wed", "thu": "Thu", "fri": "Fri", "sat": "Sat"}
+
+func clock(s string) (string, error) {
+	layout := "15:04:05"
+	if len(s) == 5 {
+		layout = "15:04"
+	}
+	t, e := time.Parse(layout, s)
+	if e != nil {
+		return "", fmt.Errorf("time must use HH:MM:SS")
+	}
+	return t.Format("15:04:05"), nil
+}
+func validateAdvanced(v map[string]any) error {
+	p := inferProtocol(v)
+	_, proto := plain(p)
+	if proto == "ipv6-icmp" && v["ip_version"] != float64(6) {
+		return fmt.Errorf("ICMPv6 requires IPv6")
+	}
+	if p != "" && p != "all" && !safeIdentifier.MatchString(proto) {
+		return fmt.Errorf("invalid protocol")
+	}
+	if strings.HasPrefix(p, "!") && proto == "all" {
+		return fmt.Errorf("cannot invert the all-protocols selection")
+	}
+	ports := text(v, "src_port") != "" || text(v, "dst_port") != "" || text(v, "any_port") != ""
+	if ports && (strings.HasPrefix(p, "!") || (proto != "tcp" && proto != "udp")) {
+		return fmt.Errorf("port matching requires positive TCP or UDP protocol")
+	}
+	if text(v, "tcp_flags") != "" || text(v, "tcp_mss") != "" {
+		if p != "tcp" {
+			return fmt.Errorf("TCP flags/MSS matching requires TCP")
+		}
+	}
+	if s := text(v, "tcp_flags"); s != "" {
+		if _, _, e := tcpFlags(s); e != nil {
+			return e
+		}
+	}
+	if text(v, "action") == "change-mss" && text(v, "tcp_flags") != "" {
+		_, on, _ := tcpFlags(text(v, "tcp_flags"))
+		found := false
+		for _, flag := range strings.Split(on, ",") {
+			found = found || flag == "SYN"
+		}
+		if !found {
+			return fmt.Errorf("MSS rewriting requires SYN in the TCP flags")
+		}
+	}
+	if len(text(v, "log_prefix")) > 29 {
+		return fmt.Errorf("log prefix is limited to 29 bytes")
+	}
+	if len(text(v, "content")) > 128 {
+		return fmt.Errorf("content matching is limited to 128 bytes")
+	}
+
+	for _, key := range []string{"tcp_mss", "packet_size"} {
+		if s := text(v, key); s != "" {
+			if _, e := parseRange(s, 65535); e != nil {
+				return fmt.Errorf("%s: %w", key, e)
+			}
+		}
+	}
+	if s := text(v, "src_mac_address"); s != "" {
+		_, s = plain(s)
+		if _, e := net.ParseMAC(s); e != nil {
+			return fmt.Errorf("invalid source MAC")
+		}
+	}
+	if s := text(v, "dscp"); s != "" {
+		if e := intRange(s, 0, 63); e != nil {
+			return e
+		}
+	}
+	if s := text(v, "tos"); s != "" {
+		s = normalizeTOS(s)
+		parts := strings.Split(s, "/")
+		if len(parts) > 2 {
+			return fmt.Errorf("invalid TOS")
+		}
+		for _, n := range parts {
+			if _, e := strconv.ParseUint(n, 0, 8); e != nil {
+				return fmt.Errorf("invalid TOS byte or mask")
+			}
+		}
+	}
+	if s := text(v, "connection_type"); s != "" && !safeIdentifier.MatchString(s) {
+		return fmt.Errorf("invalid connection helper")
+	}
+	if s := text(v, "ttl"); s != "" {
+		if _, _, e := ttlParts(s); e != nil {
+			return e
+		}
+	}
+	if text(v, "action") == "change-ttl" {
+		if e := intRange(strings.TrimLeft(text(v, "new_ttl"), "+-"), 0, 255); e != nil {
+			return fmt.Errorf("invalid new TTL")
+		}
+	}
+	if s := text(v, "random"); s != "" {
+		n, e := strconv.ParseFloat(strings.TrimSuffix(s, "%"), 64)
+		if e != nil || n < 0 || n > 100 {
+			return fmt.Errorf("random must be 0–100 percent")
+		}
+	}
+	if s := text(v, "icmp_options"); s != "" {
+		if p != "icmp" && p != "ipv6-icmp" {
+			return fmt.Errorf("ICMP options require ICMP protocol")
+		}
+		_, s = plain(s)
+		for _, n := range strings.Split(s, ":") {
+			if e := intRange(n, 0, 255); e != nil {
+				return fmt.Errorf("invalid ICMP type/code")
+			}
+		}
+		if len(strings.Split(s, ":")) > 2 {
+			return fmt.Errorf("invalid ICMP options")
+		}
+	}
+	if s := text(v, "ipsec_policy"); s != "" {
+		parts := strings.Split(strings.ReplaceAll(s, ":", ","), ",")
+		if len(parts) != 2 || (parts[0] != "in" && parts[0] != "out") || (parts[1] != "ipsec" && parts[1] != "none") {
+			return fmt.Errorf("IPsec policy must be in,ipsec / out,ipsec / in,none / out,none")
+		}
+	}
+	if s := text(v, "limit"); s != "" {
+		if _, _, e := rateParts(s); e != nil {
+			return e
+		}
+	}
+	if s := text(v, "time"); s != "" {
+		parts := strings.Split(s, "-")
+		if len(parts) != 2 {
+			return fmt.Errorf("time expects start-stop in UTC")
+		}
+		for _, part := range parts {
+			if _, e := clock(part); e != nil {
+				return e
+			}
+		}
+	}
+	if s := text(v, "days"); s != "" {
+		for _, day := range strings.Split(s, ",") {
+			if weekdays[strings.ToLower(day)] == "" {
+				return fmt.Errorf("invalid weekday %q", day)
+			}
+		}
+	}
+	if s := text(v, "nth"); s != "" {
+		parts := strings.Split(s, ",")
+		if len(parts) != 2 {
+			return fmt.Errorf("nth expects every,packet (packet starts at 1)")
+		}
+		every, e := strconv.Atoi(parts[0])
+		packet, e2 := strconv.Atoi(parts[1])
+		if e != nil || e2 != nil || every < 1 || packet < 1 || packet > every {
+			return fmt.Errorf("invalid nth values")
+		}
+	}
+	if s := text(v, "connection_limit"); s != "" {
+		parts := strings.Split(s, ",")
+		if len(parts) != 2 {
+			return fmt.Errorf("connection limit expects count,mask")
+		}
+		if e := intRange(parts[0], 1, 2147483647); e != nil {
+			return e
+		}
+		max := 32
+		if v["ip_version"] == float64(6) {
+			max = 128
+		}
+		if e := intRange(parts[1], 0, max); e != nil {
+			return e
+		}
+	}
+	for _, key := range []string{"src_address_type", "dst_address_type"} {
+		s := text(v, key)
+		if s != "" {
+			switch strings.ToUpper(s) {
+			case "LOCAL", "UNICAST", "BROADCAST", "MULTICAST", "ANYCAST", "BLACKHOLE", "UNREACHABLE", "PROHIBIT":
+			default:
+				return fmt.Errorf("unsupported address type")
+			}
+		}
+	}
+	if s := text(v, "ip_fragment"); s != "" {
+		if s != "yes" && s != "no" {
+			return fmt.Errorf("fragment expects yes/no")
+		}
+		if v["ip_version"] == float64(6) {
+			return fmt.Errorf("fragment matching is IPv4-only")
+		}
+	}
+	return nil
+}
+func appendAdvanced(args []string, rule any) []string {
+	v := values(rule)
+	if s := text(v, "any_port"); s != "" {
+		inv, s := plain(s)
+		args = append(args, "-m multiport "+inv+"--ports "+portValue(s))
+	}
+	if s := text(v, "packet_mark"); s != "" {
+		inv, s := plain(s)
+		args = append(args, fmt.Sprintf("-m mark %s--mark %d", inv, MarkNameToID(s)))
+	}
+	if s := text(v, "connection_type"); s != "" {
+		args = append(args, "-m helper --helper "+s)
+	}
+	if items, ok := v["connection_nat_state"].([]any); ok && len(items) > 0 {
+		var states []string
+		for _, i := range items {
+			state := strings.ToUpper(i.(string))
+			state = strings.ReplaceAll(state, "SRCNAT", "SNAT")
+			state = strings.ReplaceAll(state, "DSTNAT", "DNAT")
+			states = append(states, state)
+		}
+		args = append(args, "-m conntrack --ctstate "+strings.Join(states, ","))
+	}
+	if s := text(v, "content"); s != "" {
+		args = append(args, "-m string --algo bm --string "+quote(s))
+	}
+	if s := text(v, "src_mac_address"); s != "" {
+		inv, s := plain(s)
+		args = append(args, "-m mac "+inv+"--mac-source "+s)
+	}
+	for _, direction := range []string{"in", "out"} {
+		if s := text(v, direction+"_bridge_port"); s != "" {
+			inv, s := plain(s)
+			args = append(args, "-m physdev "+inv+"--physdev-"+direction+" "+s)
+		}
+	}
+	if s := text(v, "ipsec_policy"); s != "" {
+		p := strings.Split(strings.ReplaceAll(s, ":", ","), ",")
+		args = append(args, "-m policy --dir "+p[0]+" --pol "+p[1])
+	}
+	if s := text(v, "dscp"); s != "" {
+		args = append(args, "-m dscp --dscp "+s)
+	}
+	if s := text(v, "tcp_mss"); s != "" {
+		r, _ := parseRange(s, 65535)
+		args = append(args, "-m tcpmss --mss "+r)
+	}
+	if s := text(v, "tos"); s != "" {
+		args = append(args, "-m tos --tos "+normalizeTOS(s))
+	}
+	if s := text(v, "packet_size"); s != "" {
+		r, _ := parseRange(s, 65535)
+		args = append(args, "-m length --length "+r)
+	}
+	if s := text(v, "random"); s != "" {
+		n, _ := strconv.ParseFloat(strings.TrimSuffix(s, "%"), 64)
+		args = append(args, fmt.Sprintf("-m statistic --mode random --probability %.8f", n/100))
+	}
+	if s := text(v, "tcp_flags"); s != "" {
+		mask, on, _ := tcpFlags(s)
+		args = append(args, "--tcp-flags "+mask+" "+on)
+	}
+	if s := text(v, "icmp_options"); s != "" {
+		inv, s := plain(s)
+		flag := "--icmp-type"
+		if inferProtocol(v) == "ipv6-icmp" {
+			flag = "--icmpv6-type"
+		}
+		args = append(args, inv+flag+" "+strings.ReplaceAll(s, ":", "/"))
+	}
+	if s := text(v, "ttl"); s != "" {
+		op, n, _ := ttlParts(s)
+		module := "ttl"
+		if v["ip_version"] == float64(6) {
+			module = "hl"
+		}
+		args = append(args, "-m "+module+" --"+module+"-"+op+" "+n)
+	}
+	if s := text(v, "limit"); s != "" {
+		rate, burst, _ := rateParts(s)
+		args = append(args, "-m limit --limit "+rate+" --limit-burst "+burst)
+	}
+	if text(v, "time") != "" || text(v, "days") != "" {
+		args = append(args, "-m time")
+		if s := text(v, "time"); s != "" {
+			p := strings.Split(s, "-")
+			start, _ := clock(p[0])
+			stop, _ := clock(p[1])
+			args = append(args, "--timestart "+start+" --timestop "+stop)
+			if start > stop {
+				args = append(args, "--contiguous")
+			}
+		}
+		if s := text(v, "days"); s != "" {
+			var days []string
+			for _, d := range strings.Split(s, ",") {
+				days = append(days, weekdays[strings.ToLower(d)])
+			}
+			args = append(args, "--weekdays "+strings.Join(days, ","))
+		}
+	}
+	if s := text(v, "nth"); s != "" {
+		p := strings.Split(s, ",")
+		packet, _ := strconv.Atoi(p[1])
+		args = append(args, fmt.Sprintf("-m statistic --mode nth --every %s --packet %d", p[0], packet-1))
+	}
+	if s := text(v, "connection_limit"); s != "" {
+		p := strings.Split(s, ",")
+		args = append(args, "-m connlimit --connlimit-above "+p[0]+" --connlimit-mask "+p[1])
+	}
+	for _, direction := range []string{"src", "dst"} {
+		if s := text(v, direction+"_address_type"); s != "" {
+			args = append(args, "-m addrtype --"+direction+"-type "+strings.ToUpper(s))
+		}
+	}
+	if s := text(v, "ip_fragment"); s != "" {
+		if s == "yes" {
+			args = append(args, "-f")
+		} else {
+			args = append(args, "! -f")
+		}
+	}
+	return args
+}
+
+func normalizeTOS(s string) string {
+	switch s {
+	case "min-delay":
+		return "0x10/0x1e"
+	case "max-throughput":
+		return "0x08/0x1e"
+	case "max-reliability":
+		return "0x04/0x1e"
+	case "min-cost":
+		return "0x02/0x1e"
+	}
+	return s
+}

+ 169 - 100
internal/firewall/conntrack.go

@@ -4,146 +4,215 @@ import (
 	"bufio"
 	"bytes"
 	"fmt"
+	"net"
 	"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
+	for _, family := range []string{"ipv4", "ipv6"} {
+		out, err := exec.Command("conntrack", "-L", "-f", family, "-o", "extended").CombinedOutput()
+		if err != nil {
+			return nil, fmt.Errorf("cannot read %s connection tracking: %s: %w", family, out, err)
 		}
-
-		conn := parseConntrackLine(line)
-		if conn.Protocol != "" && conn.SrcAddress != "" {
-			conns = append(conns, conn)
+		scanner := bufio.NewScanner(bytes.NewReader(out))
+		scanner.Buffer(make([]byte, 4096), 1024*1024)
+		for scanner.Scan() {
+			c := parseConntrackLine(scanner.Text())
+			if c.Protocol != "" && c.SrcAddress != "" {
+				conns = append(conns, c)
+			}
+		}
+		if err := scanner.Err(); err != nil {
+			return nil, err
 		}
 	}
-
 	if conns == nil {
-		conns = make([]ActiveConnection, 0)
+		conns = []ActiveConnection{}
 	}
 	return conns, nil
 }
-
 func parseConntrackLine(line string) ActiveConnection {
-	var conn ActiveConnection
+	var c ActiveConnection
 	fields := strings.Fields(line)
 	if len(fields) < 4 {
-		return conn
+		return c
 	}
-
-	// 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
+	start := 0
+	c.IPVersion = 4
+	if fields[0] == "ipv4" || fields[0] == "ipv6" {
+		if fields[0] == "ipv6" {
+			c.IPVersion = 6
+		}
+		start = 2
+	}
+	if len(fields) <= start+2 {
+		return c
+	}
+	c.Protocol = strings.ToUpper(fields[start])
+	if c.Protocol == "ICMPV6" || c.Protocol == "IPV6-ICMP" {
+		c.Protocol = "ICMPV6"
+	}
+	c.Timeout, _ = strconv.Atoi(fields[start+2])
+	var src, dst []string
+	var sport, dport []int
+	for _, field := range fields[start+3:] {
+		kv := strings.SplitN(field, "=", 2)
+		if len(kv) == 2 {
+			n, _ := strconv.Atoi(kv[1])
+			switch kv[0] {
+			case "src":
+				src = append(src, kv[1])
+			case "dst":
+				dst = append(dst, kv[1])
+			case "sport":
+				sport = append(sport, n)
+			case "dport":
+				dport = append(dport, n)
+			case "mark":
+				c.ConnectionMark = kv[1]
+			case "zone":
+				c.Zone = n
+			case "bytes":
+				v, _ := strconv.ParseUint(kv[1], 10, 64)
+				c.Bytes += v
+			case "packets":
+				v, _ := strconv.ParseUint(kv[1], 10, 64)
+				c.Packets += v
+			case "id":
+				if c.ICMPID == nil {
+					value := n
+					c.ICMPID = &value
+				}
+			case "type":
+				if c.ICMPType == nil {
+					value := n
+					c.ICMPType = &value
+				}
+			case "code":
+				if c.ICMPCode == nil {
+					value := n
+					c.ICMPCode = &value
+				}
 			}
+			continue
+		}
+		if field == "[ASSURED]" {
+			c.Assured = true
 		}
-		if f == "ESTABLISHED" || f == "TIME_WAIT" || f == "CLOSE_WAIT" || f == "FIN_WAIT" || f == "SYN_SENT" || f == "SYN_RECV" {
-			state = f
+		switch field {
+		case "ESTABLISHED", "TIME_WAIT", "CLOSE_WAIT", "FIN_WAIT", "SYN_SENT", "SYN_RECV", "CLOSE", "LAST_ACK", "LISTEN":
+			c.TCPState = field
 		}
 	}
-
-	conn.Protocol = protocol
-	conn.Timeout = timeout
-	conn.TCPState = state
-	if conn.TCPState == "" {
-		conn.TCPState = protocol
+	if len(src) > 0 {
+		c.SrcAddress = src[0]
 	}
-
-	if len(srcList) > 0 {
-		conn.SrcAddress = srcList[0]
+	if len(src) > 1 {
+		c.ReplySrcAddress = src[1]
 	}
-	if len(dstList) > 0 {
-		conn.DstAddress = dstList[0]
+	if len(dst) > 0 {
+		c.DstAddress = dst[0]
 	}
-	if len(sportList) > 0 {
-		conn.SrcPort = sportList[0]
+	if len(dst) > 1 {
+		c.ReplyDstAddress = dst[1]
 	}
-	if len(dportList) > 0 {
-		conn.DstPort = dportList[0]
+	if len(sport) > 0 {
+		c.SrcPort = sport[0]
 	}
-
-	// Reply pair
-	if len(srcList) > 1 {
-		conn.ReplySrcAddress = srcList[1]
+	if len(sport) > 1 {
+		c.ReplySrcPort = sport[1]
+	}
+	if len(dport) > 0 {
+		c.DstPort = dport[0]
 	}
-	if len(dstList) > 1 {
-		conn.ReplyDstAddress = dstList[1]
+	if len(dport) > 1 {
+		c.ReplyDstPort = dport[1]
 	}
-	if len(sportList) > 1 {
-		conn.ReplySrcPort = sportList[1]
+	if c.TCPState == "" {
+		c.TCPState = c.Protocol
 	}
-	if len(dportList) > 1 {
-		conn.ReplyDstPort = dportList[1]
+	c.ID = fmt.Sprintf("%d-%d-%s-%s:%d-%s:%d", c.IPVersion, c.Zone, c.Protocol, c.SrcAddress, c.SrcPort, c.DstAddress, c.DstPort)
+	if c.ICMPID != nil && c.ICMPType != nil && c.ICMPCode != nil {
+		c.ID += fmt.Sprintf("-%d-%d-%d", *c.ICMPID, *c.ICMPType, *c.ICMPCode)
 	}
+	return c
+}
 
-	conn.ID = fmt.Sprintf("%s-%s:%d-%s:%d", conn.Protocol, conn.SrcAddress, conn.SrcPort, conn.DstAddress, conn.DstPort)
-	return conn
+type ConnectionSelector struct {
+	Protocol  string `json:"protocol"`
+	Src       string `json:"src"`
+	Sport     int    `json:"sport"`
+	Dst       string `json:"dst"`
+	Dport     int    `json:"dport"`
+	IPVersion int    `json:"ip_version"`
+	Zone      int    `json:"zone"`
+	ICMPID    *int   `json:"icmp_id"`
+	ICMPType  *int   `json:"icmp_type"`
+	ICMPCode  *int   `json:"icmp_code"`
 }
 
-// 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))
+func connectionDeleteArgs(s ConnectionSelector) ([]string, error) {
+	src, dst := net.ParseIP(s.Src), net.ParseIP(s.Dst)
+	if src == nil || dst == nil || (src.To4() == nil) != (dst.To4() == nil) {
+		return nil, fmt.Errorf("a complete source/destination IP tuple is required")
 	}
-	if src != "" {
-		args = append(args, "-s", src)
+	family := "ipv4"
+	version := 4
+	if src.To4() == nil {
+		family = "ipv6"
+		version = 6
 	}
-	if dst != "" {
-		args = append(args, "-d", dst)
+	if s.IPVersion != 0 && s.IPVersion != version {
+		return nil, fmt.Errorf("IP family does not match the addresses")
 	}
-	if sport > 0 {
-		args = append(args, "--sport", strconv.Itoa(sport))
+	if s.Zone < 0 || s.Zone > 65535 {
+		return nil, fmt.Errorf("invalid connection zone")
 	}
-	if dport > 0 {
-		args = append(args, "--dport", strconv.Itoa(dport))
+	proto := strings.ToLower(s.Protocol)
+	if proto == "ipv6-icmp" {
+		proto = "icmpv6"
 	}
-
-	cmd := exec.Command("conntrack", args...)
-	out, err := cmd.CombinedOutput()
+	args := []string{"-D", "-f", family, "-p", proto, "-s", src.String(), "-d", dst.String(), "--zone", strconv.Itoa(s.Zone)}
+	switch proto {
+	case "tcp", "udp", "sctp", "dccp":
+		if s.Sport < 1 || s.Sport > 65535 || s.Dport < 1 || s.Dport > 65535 {
+			return nil, fmt.Errorf("both source and destination ports are required")
+		}
+		args = append(args, "--sport", strconv.Itoa(s.Sport), "--dport", strconv.Itoa(s.Dport))
+	case "icmp", "icmpv6":
+		if (proto == "icmpv6") != (version == 6) {
+			return nil, fmt.Errorf("ICMP family mismatch")
+		}
+		if s.ICMPID == nil || s.ICMPType == nil || s.ICMPCode == nil || *s.ICMPID < 0 || *s.ICMPID > 65535 || *s.ICMPType < 0 || *s.ICMPType > 255 || *s.ICMPCode < 0 || *s.ICMPCode > 255 {
+			return nil, fmt.Errorf("ICMP ID, type and code are required")
+		}
+		prefix := "--" + proto
+		args = append(args, prefix+"-id", strconv.Itoa(*s.ICMPID), prefix+"-type", strconv.Itoa(*s.ICMPType), prefix+"-code", strconv.Itoa(*s.ICMPCode))
+	case "gre", "esp", "ah":
+		return nil, fmt.Errorf("deleting %s requires protocol-specific selectors and is not supported", proto)
+	default:
+		return nil, fmt.Errorf("unsupported connection protocol")
+	}
+	return args, nil
+}
+func KillFlow(s ConnectionSelector) error {
+	args, err := connectionDeleteArgs(s)
+	if err != nil {
+		return err
+	}
+	out, err := exec.Command("conntrack", args...).CombinedOutput()
 	if err != nil {
-		return fmt.Errorf("failed to terminate connection: %s: %w", string(out), err)
+		return fmt.Errorf("failed to terminate connection: %s: %w", out, err)
 	}
 	return nil
 }
+func KillConnection(protocol, src string, sport int, dst string, dport int) error {
+	return KillFlow(ConnectionSelector{Protocol: protocol, Src: src, Sport: sport, Dst: dst, Dport: dport})
+}
+func ValidateConnectionSelector(s ConnectionSelector) error {
+	_, err := connectionDeleteArgs(s)
+	return err
+}

+ 64 - 0
internal/firewall/counters.go

@@ -0,0 +1,64 @@
+package firewall
+
+import (
+	"fmt"
+	"os/exec"
+	"strings"
+)
+
+func (m *FirewallManager) ResetRuleCounters(kind, id string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	found := false
+	switch kind {
+	case "filter":
+		for _, r := range m.filterRules {
+			found = found || r.ID == id
+		}
+	case "nat":
+		for _, r := range m.natRules {
+			found = found || r.ID == id
+		}
+	case "mangle":
+		for _, r := range m.mangleRules {
+			found = found || r.ID == id
+		}
+	default:
+		return fmt.Errorf("unknown rule kind")
+	}
+	if !found {
+		return fmt.Errorf("rule not found")
+	}
+	for _, binary := range []string{"iptables", "ip6tables"} {
+		out, err := exec.Command(binary + "-save").Output()
+		if err != nil {
+			return err
+		}
+		table := ""
+		numbers := map[string]int{}
+		for _, line := range strings.Split(string(out), "\n") {
+			if strings.HasPrefix(line, "*") {
+				table = line[1:]
+				numbers = map[string]int{}
+			}
+			if !strings.HasPrefix(line, "-A ") {
+				continue
+			}
+			fields := strings.Fields(line)
+			chain := fields[1]
+			numbers[chain]++
+			if table != kind && !(kind == "filter" && table == "filter") {
+				continue
+			}
+			if !strings.HasPrefix(chain, "DT_") {
+				continue
+			}
+			if strings.Contains(line, "--comment "+quote("id:"+id)) || strings.Contains(line, "--comment "+quote("id:"+id+":log")) {
+				if out, err := exec.Command(binary, "-t", table, "-Z", chain, fmt.Sprint(numbers[chain])).CombinedOutput(); err != nil {
+					return fmt.Errorf("reset counter: %s: %w", out, err)
+				}
+			}
+		}
+	}
+	return nil
+}

+ 42 - 0
internal/firewall/decorate.go

@@ -0,0 +1,42 @@
+package firewall
+
+import (
+	"crypto/sha256"
+	"fmt"
+	"strings"
+)
+
+func decorateRuleLines(kind string, rule any, lines []string) []string {
+	v := values(rule)
+	id := text(v, "id")
+	var out []string
+	for i, line := range lines {
+		marker := "-m comment --comment " + quote("id:"+id)
+		at := strings.LastIndex(line, marker)
+		if at < 0 {
+			out = append(out, line)
+			continue
+		}
+		end := at + len(marker)
+		match, target := line[:end], strings.TrimSpace(line[end:])
+		if log, _ := v["log"].(bool); log && text(v, "action") != "log" {
+			prefix := text(v, "log_prefix")
+			logMatch := strings.Replace(match, marker, "-m comment --comment "+quote("id:"+id+":log"), 1)
+			out = append(out, logMatch+" -j LOG --log-prefix "+quote(prefix))
+		}
+		pass, _ := v["passthrough"].(bool)
+		action := text(v, "action")
+		if kind == "mangle" && !pass && action != "accept" && action != "drop" && action != "return" && action != "jump" {
+			h := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", id, i)))
+			chain := fmt.Sprintf("R_%x", h[:6])
+			out = append(out, ":"+chain+" - [0:0]", match+" -g "+chain)
+			if target != "" {
+				out = append(out, "-A "+chain+" "+target)
+			}
+			out = append(out, "-A "+chain+" -j RETURN")
+		} else {
+			out = append(out, line)
+		}
+	}
+	return out
+}

+ 96 - 0
internal/firewall/dynamic_lists.go

@@ -0,0 +1,96 @@
+package firewall
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"net"
+	"os/exec"
+	"strconv"
+	"strings"
+)
+
+func dynamicEntryID(name, address string) string {
+	b, _ := json.Marshal([]string{name, address})
+	return "dynamic-" + base64.RawURLEncoding.EncodeToString(b)
+}
+func deleteDynamicEntry(id string) error {
+	b, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(id, "dynamic-"))
+	if err != nil {
+		return fmt.Errorf("invalid dynamic entry ID")
+	}
+	var fields []string
+	if json.Unmarshal(b, &fields) != nil || len(fields) != 2 || net.ParseIP(fields[1]) == nil {
+		return fmt.Errorf("invalid dynamic entry ID")
+	}
+	if err := validateListName(fields[0]); err != nil {
+		return err
+	}
+	name := GetDynamicIPSetName(fields[0])
+	if net.ParseIP(fields[1]).To4() == nil {
+		name += "_v6"
+	}
+	return runIPSet("del", name, fields[1], "-exist")
+}
+func (m *FirewallManager) addressListReferences() []string {
+	var refs []string
+	for _, r := range m.filterRules {
+		if !r.Disabled {
+			refs = append(refs, r.SrcAddressList, r.DstAddressList, r.AddressList)
+		}
+	}
+	for _, r := range m.natRules {
+		if !r.Disabled {
+			refs = append(refs, r.SrcAddressList, r.DstAddressList, r.AddressList)
+		}
+	}
+	for _, r := range m.mangleRules {
+		if !r.Disabled {
+			refs = append(refs, r.SrcAddressList, r.DstAddressList, r.AddressList)
+		}
+	}
+	return refs
+}
+func (m *FirewallManager) dynamicEntries() []AddressListEntry {
+	names := map[string]bool{}
+	for _, e := range m.addressLists {
+		names[e.Name] = true
+	}
+	for _, name := range m.addressListReferences() {
+		name = strings.TrimPrefix(name, "!")
+		if name != "" && !strings.HasPrefix(name, "geo:") && !strings.HasPrefix(name, "geo_") {
+			names[name] = true
+		}
+	}
+	var result []AddressListEntry
+	for name := range names {
+		for _, suffix := range []string{"", "_v6"} {
+			set := GetDynamicIPSetName(name) + suffix
+			out, err := exec.Command("ipset", "save", set).Output()
+			if err != nil {
+				continue
+			}
+			for _, line := range strings.Split(string(out), "\n") {
+				parts := strings.Fields(line)
+				if len(parts) < 3 || parts[0] != "add" {
+					continue
+				}
+				ip := net.ParseIP(parts[2])
+				if ip == nil {
+					continue
+				}
+				remaining := 0
+				for i := 3; i+1 < len(parts); i++ {
+					if parts[i] == "timeout" {
+						remaining, _ = strconv.Atoi(parts[i+1])
+					}
+				}
+				result = append(result, AddressListEntry{ID: dynamicEntryID(name, ip.String()), Name: name, Address: ip.String(), Dynamic: true, Timeout: remaining})
+				if len(result) >= 2000 {
+					return result
+				}
+			}
+		}
+	}
+	return result
+}

+ 16 - 1
internal/firewall/family.go

@@ -8,6 +8,21 @@ import (
 )
 
 func ApplyDualStack(filters []FilterRule, nats []NATRule, mangles []MangleRule) error {
+	for _, r := range filters {
+		if err := validateRule("filter", r); err != nil {
+			return err
+		}
+	}
+	for _, r := range nats {
+		if err := validateRule("nat", r); err != nil {
+			return err
+		}
+	}
+	for _, r := range mangles {
+		if err := validateRule("mangle", r); err != nil {
+			return err
+		}
+	}
 	var f4, f6 []FilterRule
 	var n4, n6 []NATRule
 	var m4, m6 []MangleRule
@@ -71,7 +86,7 @@ func ApplyDualStack(filters []FilterRule, nats []NATRule, mangles []MangleRule)
 	}
 	// Validate all families before applying either one.
 	for i := range families {
-		live, err := exec.Command(families[i].binary + "-save").Output()
+		live, err := exec.Command(families[i].binary+"-save", "-c").Output()
 		if err != nil {
 			return err
 		}

+ 49 - 0
internal/firewall/geo_refs.go

@@ -0,0 +1,49 @@
+package firewall
+
+import "strings"
+
+func geoMembers(code string) []string {
+	code = strings.ToUpper(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(code, "!"), "geo:"), "geo_"), "!"))
+	switch code {
+	case "EU":
+		return strings.Fields("AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE")
+	case "BALKANS":
+		return strings.Fields("GR CY AL BG RO RS MK ME BA HR SI TR")
+	case "BLOCKLIST", "HIGHRISK":
+		return strings.Fields("CN RU IR KP BY")
+	}
+	return []string{code}
+}
+func (m *FirewallManager) geoReferenced(code string) bool {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	check := func(src, dst string) bool {
+		for _, value := range []string{src, dst} {
+			if value == "" {
+				continue
+			}
+			for _, member := range geoMembers(value) {
+				if member == code {
+					return true
+				}
+			}
+		}
+		return false
+	}
+	for _, r := range m.filterRules {
+		if !r.Disabled && check(r.SrcGeoIP, r.DstGeoIP) {
+			return true
+		}
+	}
+	for _, r := range m.natRules {
+		if !r.Disabled && check(r.SrcGeoIP, r.DstGeoIP) {
+			return true
+		}
+	}
+	for _, r := range m.mangleRules {
+		if !r.Disabled && check(r.SrcGeoIP, r.DstGeoIP) {
+			return true
+		}
+	}
+	return false
+}

+ 187 - 70
internal/firewall/geoip.go

@@ -3,8 +3,10 @@ package firewall
 import (
 	"bufio"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
+	"net"
 	"net/http"
 	"os"
 	"os/exec"
@@ -38,6 +40,9 @@ type GeoIPManager struct {
 	timer      *time.Timer
 	stopCh     chan struct{}
 	isUpdating bool
+	lastError  string
+	referenced func(string) bool
+	syncMu     sync.Mutex
 }
 
 func NewGeoIPManager() *GeoIPManager {
@@ -61,23 +66,33 @@ func NewGeoIPManager() *GeoIPManager {
 	// 3. Scan cached zone files
 	m.scanCachedZones()
 
-	// 4. Pre-download / sync initial enabled countries if missing
+	// Restore cached enabled countries without hiding a failed restore.
 	go func() {
 		m.mu.RLock()
-		enabled := make([]string, len(m.config.EnabledCountries))
-		copy(enabled, m.config.EnabledCountries)
+		var enabled []string
+		for code, c := range m.countries {
+			if c.Enabled {
+				enabled = append(enabled, code)
+			}
+		}
 		m.mu.RUnlock()
-
+		var failures []error
 		for _, code := range enabled {
-			zonePath := filepath.Join(GeoIPZonesDir, strings.ToLower(code)+".zone")
-			if _, err := os.Stat(zonePath); os.IsNotExist(err) {
-				_, _ = m.SyncCountryZone(code)
-			} else {
-				if err := m.ApplyCountryIPSet(code); err != nil {
-					fmt.Fprintf(os.Stderr, "GeoIP restore failed for %s: %v\n", code, err)
+			zone := filepath.Join(GeoIPZonesDir, strings.ToLower(code)+".zone")
+			if _, err := os.Stat(zone); os.IsNotExist(err) {
+				if _, err = m.SyncCountryZone(code); err != nil {
+					failures = append(failures, fmt.Errorf("%s: %w", code, err))
 				}
+			} else if err := m.ApplyCountryIPSet(code); err != nil {
+				failures = append(failures, fmt.Errorf("%s: %w", code, err))
 			}
 		}
+		if err := errors.Join(failures...); err != nil {
+			m.mu.Lock()
+			m.lastError = err.Error()
+			m.mu.Unlock()
+			fmt.Fprintf(os.Stderr, "GeoIP restore: %v\n", err)
+		}
 	}()
 
 	// 5. Start background auto-update scheduler
@@ -94,7 +109,11 @@ func (m *GeoIPManager) loadConfig() {
 	}
 
 	if data, err := os.ReadFile(GeoIPConfigFile); err == nil {
-		_ = json.Unmarshal(data, &m.config)
+		if err := json.Unmarshal(data, &m.config); err != nil {
+			m.lastError = "Invalid GeoIP configuration: " + err.Error()
+			m.config.AutoUpdate = false
+			m.config.EnabledCountries = nil
+		}
 	}
 
 	if m.config.IntervalHours <= 0 {
@@ -110,7 +129,9 @@ func (m *GeoIPManager) loadConfig() {
 }
 
 func (m *GeoIPManager) saveConfig() error {
-	_ = os.MkdirAll(GeoIPDir, 0755)
+	if err := os.MkdirAll(GeoIPDir, 0755); err != nil {
+		return err
+	}
 
 	m.config.EnabledCountries = make([]string, 0)
 	for code, c := range m.countries {
@@ -124,7 +145,7 @@ func (m *GeoIPManager) saveConfig() error {
 	if err != nil {
 		return err
 	}
-	return os.WriteFile(GeoIPConfigFile, data, 0644)
+	return writeAtomic(GeoIPConfigFile, data)
 }
 
 func (m *GeoIPManager) scanCachedZones() {
@@ -243,49 +264,75 @@ func (m *GeoIPManager) GetStatus() map[string]any {
 		"enabled_count":     enabledCount,
 		"total_subnets":     totalSubnets,
 		"is_updating":       m.isUpdating,
+		"last_error":        m.lastError,
 	}
 }
 
-func (m *GeoIPManager) SetConfig(autoUpdate bool, intervalHours int) error {
+func (m *GeoIPManager) SetConfig(auto bool, hours int) error {
 	m.mu.Lock()
-	if intervalHours <= 0 {
-		intervalHours = 24
+	old := m.config
+	if hours < 1 || hours > 8760 {
+		m.mu.Unlock()
+		return fmt.Errorf("interval must be 1–8760 hours")
+	}
+	m.config.AutoUpdate = auto
+	m.config.IntervalHours = hours
+	if err := m.saveConfig(); err != nil {
+		m.config = old
+		m.mu.Unlock()
+		return err
 	}
-	m.config.AutoUpdate = autoUpdate
-	m.config.IntervalHours = intervalHours
-	_ = m.saveConfig()
 	m.mu.Unlock()
-
 	m.startScheduler()
 	return nil
 }
-
 func (m *GeoIPManager) ToggleCountry(code string, enable bool) error {
 	code = strings.ToUpper(code)
-
-	m.mu.Lock()
-	c, ok := m.countries[code]
+	m.mu.RLock()
+	_, ok := m.countries[code]
+	m.mu.RUnlock()
 	if !ok {
-		m.mu.Unlock()
-		return fmt.Errorf("country %q not found", code)
+		return fmt.Errorf("unknown country %q", code)
+	}
+	if !enable && m.referenced != nil && m.referenced(code) {
+		return fmt.Errorf("country %s is used by an active firewall rule; change that rule first", code)
 	}
-	c.Enabled = enable
-	_ = m.saveConfig()
-	m.mu.Unlock()
-
 	if enable {
-		zoneFile := filepath.Join(GeoIPZonesDir, strings.ToLower(code)+".zone")
-		if _, err := os.Stat(zoneFile); os.IsNotExist(err) {
-			_, err = m.SyncCountryZone(code)
+		if _, err := os.Stat(filepath.Join(GeoIPZonesDir, strings.ToLower(code)+".zone")); os.IsNotExist(err) {
+			if _, err = m.SyncCountryZone(code); err != nil {
+				return err
+			}
+		}
+		if err := m.ApplyCountryIPSet(code); err != nil {
 			return err
 		}
-		return m.ApplyCountryIPSet(code)
+	} else {
+		name := GetGeoIPSetName(code)
+		if output, err := exec.Command("ipset", "list", name, "-terse").CombinedOutput(); err == nil {
+			if err := runIPSet("destroy", name); err != nil {
+				return fmt.Errorf("GeoIP set is referenced; it was preserved: %w", err)
+			}
+		} else if !strings.Contains(string(output), "does not exist") {
+			return fmt.Errorf("cannot inspect GeoIP set: %s: %w", output, err)
+		}
 	}
-
-	// Disable -> destroy ipset
-	setName := "mk_geo_" + code
-	_ = exec.Command("ipset", "flush", setName).Run()
-	_ = exec.Command("ipset", "destroy", setName).Run()
+	m.mu.Lock()
+	c := m.countries[code]
+	old := c.Enabled
+	oldConfig := m.config
+	c.Enabled = enable
+	if err := m.saveConfig(); err != nil {
+		c.Enabled = old
+		m.config = oldConfig
+		m.mu.Unlock()
+		if !enable && old {
+			if restoreErr := m.ApplyCountryIPSet(code); restoreErr != nil {
+				return fmt.Errorf("persist: %v; restore set: %w", err, restoreErr)
+			}
+		}
+		return err
+	}
+	m.mu.Unlock()
 	return nil
 }
 
@@ -318,15 +365,39 @@ func (m *GeoIPManager) BatchToggle(preset string, enable bool, customCodes []str
 		targetCodes = customCodes
 	}
 
+	if len(targetCodes) == 0 && strings.ToUpper(preset) != "NONE" {
+		return fmt.Errorf("no countries selected or unknown preset")
+	}
 	for _, code := range targetCodes {
-		_ = m.ToggleCountry(code, enable)
+		m.mu.RLock()
+		_, ok := m.countries[strings.ToUpper(code)]
+		m.mu.RUnlock()
+		if !ok {
+			return fmt.Errorf("unknown country %q", code)
+		}
+		if !enable && m.referenced != nil && m.referenced(strings.ToUpper(code)) {
+			return fmt.Errorf("country %s is used by an active firewall rule", code)
+		}
 	}
-
-	return nil
+	var failures []error
+	for _, code := range targetCodes {
+		if err := m.ToggleCountry(code, enable); err != nil {
+			failures = append(failures, fmt.Errorf("%s: %w", code, err))
+		}
+	}
+	return errors.Join(failures...)
 }
 
 // SyncCountryZone downloads the country zone file from ipdeny and refreshes ipset
 func (m *GeoIPManager) SyncCountryZone(code string) (int, error) {
+	m.syncMu.Lock()
+	defer m.syncMu.Unlock()
+	m.mu.RLock()
+	_, valid := m.countries[strings.ToUpper(code)]
+	m.mu.RUnlock()
+	if !valid {
+		return 0, fmt.Errorf("unknown country %q", code)
+	}
 	code = strings.ToUpper(code)
 	url := fmt.Sprintf("https://www.ipdeny.com/ipblocks/data/countries/%s.zone", strings.ToLower(code))
 
@@ -348,21 +419,49 @@ func (m *GeoIPManager) SyncCountryZone(code string) (int, error) {
 	}
 
 	zoneFile := filepath.Join(GeoIPZonesDir, strings.ToLower(code)+".zone")
-	tmpFile := zoneFile + ".tmp"
-
-	out, err := os.Create(tmpFile)
+	out, err := os.CreateTemp(GeoIPZonesDir, ".zone-*")
+	tmpFile := ""
+	if out != nil {
+		tmpFile = out.Name()
+	}
+	defer os.Remove(tmpFile)
 	if err != nil {
 		return 0, err
 	}
 
-	if _, err := io.Copy(out, resp.Body); err != nil {
+	if _, err := io.Copy(out, io.LimitReader(resp.Body, 32*1024*1024+1)); err != nil {
 		out.Close()
 		_ = os.Remove(tmpFile)
 		return 0, err
 	}
-	out.Close()
-
-	_ = os.Rename(tmpFile, zoneFile)
+	if err := out.Close(); err != nil {
+		return 0, err
+	}
+	data, err := os.ReadFile(tmpFile)
+	if err != nil {
+		return 0, err
+	}
+	if len(data) > 32*1024*1024 {
+		return 0, fmt.Errorf("GeoIP download too large")
+	}
+	countValid := 0
+	for _, line := range strings.Split(string(data), "\n") {
+		line = strings.TrimSpace(line)
+		if line == "" || strings.HasPrefix(line, "#") {
+			continue
+		}
+		ip, _, e := net.ParseCIDR(line)
+		if e != nil || ip.To4() == nil {
+			return 0, fmt.Errorf("invalid IPv4 CIDR in country download")
+		}
+		countValid++
+	}
+	if countValid == 0 {
+		return 0, fmt.Errorf("empty country download")
+	}
+	if err := os.Rename(tmpFile, zoneFile); err != nil {
+		return 0, err
+	}
 
 	count, _ := countLines(zoneFile)
 
@@ -389,6 +488,12 @@ func (m *GeoIPManager) SyncCountryZone(code string) (int, error) {
 
 // ApplyCountryIPSet uses high-speed `ipset restore` to load the country's subnets
 func (m *GeoIPManager) ApplyCountryIPSet(code string) error {
+	m.mu.RLock()
+	_, valid := m.countries[strings.ToUpper(code)]
+	m.mu.RUnlock()
+	if !valid {
+		return fmt.Errorf("unknown country %q", code)
+	}
 	code = strings.ToUpper(code)
 	zoneFile := filepath.Join(GeoIPZonesDir, strings.ToLower(code)+".zone")
 
@@ -442,37 +547,46 @@ func (m *GeoIPManager) UpdateNow() error {
 		return fmt.Errorf("update already in progress")
 	}
 	m.isUpdating = true
+	m.lastError = ""
+	var enabled []string
+	for code, c := range m.countries {
+		if c.Enabled {
+			enabled = append(enabled, code)
+		}
+	}
 	m.mu.Unlock()
-
 	go func() {
-		defer func() {
-			m.mu.Lock()
-			m.isUpdating = false
-			m.config.LastUpdated = time.Now()
-			m.config.NextUpdate = time.Now().Add(time.Duration(m.config.IntervalHours) * time.Hour)
-			_ = m.saveConfig()
-			m.mu.Unlock()
-		}()
-
-		m.mu.RLock()
-		var enabled []string
-		for code, c := range m.countries {
-			if c.Enabled {
-				enabled = append(enabled, code)
+		var failures []error
+		for _, code := range enabled {
+			if _, err := m.SyncCountryZone(code); err != nil {
+				failures = append(failures, fmt.Errorf("%s: %w", code, err))
 			}
 		}
-		m.mu.RUnlock()
-
-		for _, code := range enabled {
-			_, _ = m.SyncCountryZone(code)
+		m.mu.Lock()
+		defer m.mu.Unlock()
+		m.isUpdating = false
+		m.config.NextUpdate = time.Now().Add(time.Duration(m.config.IntervalHours) * time.Hour)
+		if len(failures) == 0 {
+			m.config.LastUpdated = time.Now()
+		}
+		if err := m.saveConfig(); err != nil {
+			failures = append(failures, err)
+		}
+		if err := errors.Join(failures...); err != nil {
+			m.lastError = err.Error()
 		}
 	}()
-
 	return nil
 }
 
 // GetCountrySubnets returns first N subnets from cached zone file for preview
 func (m *GeoIPManager) GetCountrySubnets(code string, limit int) []string {
+	m.mu.RLock()
+	_, valid := m.countries[strings.ToUpper(code)]
+	m.mu.RUnlock()
+	if !valid {
+		return nil
+	}
 	code = strings.ToUpper(code)
 	zoneFile := filepath.Join(GeoIPZonesDir, strings.ToLower(code)+".zone")
 
@@ -556,7 +670,10 @@ func (m *GeoIPManager) EnsureCountryOrPreset(code string) error {
 	m.mu.Lock()
 	if c, ok := m.countries[code]; ok {
 		c.Enabled = true
-		_ = m.saveConfig()
+		if err := m.saveConfig(); err != nil {
+			m.mu.Unlock()
+			return err
+		}
 	}
 	m.mu.Unlock()
 

+ 111 - 46
internal/firewall/ipset.go

@@ -1,34 +1,64 @@
 package firewall
 
 import (
+	"crypto/sha256"
 	"fmt"
 	"net"
+	"os/exec"
 	"strings"
 )
 
-// SyncAddressLists updates Linux ipsets to match the configured AddressList entries and referenced lists
-func SyncAddressLists(entries []AddressListEntry, referencedLists ...string) error {
-	// Group addresses by list name
-	listMap := make(map[string][]string)
+func listToken(name string) string { h := sha256.Sum256([]byte(name)); return fmt.Sprintf("%x", h[:8]) }
+func GetIPSetName(name string) string {
+	name = strings.TrimPrefix(name, "!")
+	if strings.HasPrefix(name, "geo:") {
+		return GetGeoIPSetName(name)
+	}
+	if strings.HasPrefix(name, "geo_") {
+		return GetGeoIPSetName(name)
+	}
+	return "dt_al_" + listToken(name)
+}
+func GetDynamicIPSetName(name string) string { return "dt_ad_" + listToken(name) }
+func staticIPSetName(name string) string     { return "dt_as_" + listToken(name) }
+func GetGeoIPSetName(code string) string {
+	code = strings.TrimPrefix(code, "!")
+	code = strings.TrimPrefix(code, "geo:")
+	code = strings.TrimPrefix(code, "geo_")
+	return "mk_geo_" + strings.ToUpper(code)
+}
+func SyncAddressLists(entries []AddressListEntry, referenced ...string) error {
+	lists := map[string][]string{}
+	// Retain empty lists represented by disabled entries, so disabling the last
+	// static entry clears its addresses without deleting a dynamic collection.
 	for _, e := range entries {
-		if e.Disabled || e.Name == "" || e.Address == "" {
-			continue
+		if err := validateListName(e.Name); err != nil {
+			return err
+		}
+		if _, ok := lists[e.Name]; !ok {
+			lists[e.Name] = nil
+		}
+		if !e.Disabled {
+			if e.Address == "" {
+				return fmt.Errorf("address is required")
+			}
+			lists[e.Name] = append(lists[e.Name], e.Address)
 		}
-		listMap[e.Name] = append(listMap[e.Name], e.Address)
 	}
-
-	// Ensure any custom list explicitly referenced in rules exists even if empty
-	for _, ref := range referencedLists {
-		ref = strings.TrimPrefix(ref, "!")
-		if ref != "" && !strings.HasPrefix(ref, "geo:") && !strings.HasPrefix(ref, "geo_") {
-			if _, ok := listMap[ref]; !ok {
-				listMap[ref] = []string{}
+	for _, name := range referenced {
+		name = strings.TrimPrefix(name, "!")
+		if name != "" && !strings.HasPrefix(name, "geo:") && !strings.HasPrefix(name, "geo_") {
+			if err := validateListName(name); err != nil {
+				return err
+			}
+			if _, ok := lists[name]; !ok {
+				lists[name] = nil
 			}
 		}
 	}
-
-	for name, addrs := range listMap {
-		var v4, v6 []string
+	desired := map[string]bool{}
+	for name, addrs := range lists {
+		families := map[string][]string{"inet": nil, "inet6": nil}
 		for _, address := range addrs {
 			ip := net.ParseIP(address)
 			if ip == nil {
@@ -37,46 +67,81 @@ func SyncAddressLists(entries []AddressListEntry, referencedLists ...string) err
 			if ip == nil {
 				return fmt.Errorf("invalid address: %s", address)
 			}
+			family := "inet6"
 			if ip.To4() != nil {
-				v4 = append(v4, address)
-			} else {
-				v6 = append(v6, address)
+				family = "inet"
 			}
+			families[family] = append(families[family], address)
 		}
-		if err := replaceSet(GetIPSetName(name), "inet", v4); err != nil {
-			return err
+		for family, addresses := range families {
+			suffix := ""
+			if family == "inet6" {
+				suffix = "_v6"
+			}
+			static, dynamic, union := staticIPSetName(name)+suffix, GetDynamicIPSetName(name)+suffix, GetIPSetName(name)+suffix
+			desired[static] = true
+			desired[dynamic] = true
+			desired[union] = true
+			if err := replaceSet(static, family, addresses); err != nil {
+				return err
+			}
+			if err := runIPSet("create", dynamic, "hash:net", "family", family, "timeout", "0", "maxelem", "262144", "-exist"); err != nil {
+				return err
+			}
+			if err := runIPSet("create", union, "list:set", "size", "2", "-exist"); err != nil {
+				return err
+			}
+			for _, child := range []string{static, dynamic} {
+				if err := runIPSet("add", union, child, "-exist"); err != nil {
+					return err
+				}
+			}
 		}
-		if err := replaceSet(GetIPSetName(name)+"_v6", "inet6", v6); err != nil {
-			return err
+	}
+	// Only collect our own sets and only if the kernel reports no references.
+	out, err := exec.Command("ipset", "list", "-name").Output()
+	if err != nil {
+		return err
+	}
+	for _, prefix := range []string{"dt_al_", "dt_as_", "dt_ad_"} {
+		for _, name := range strings.Fields(string(out)) {
+			if strings.HasPrefix(name, prefix) && !desired[name] {
+				_ = exec.Command("ipset", "destroy", name).Run()
+			}
 		}
 	}
-
 	return nil
 }
 
-// GetIPSetName returns the internal ipset name for an address list
-func GetIPSetName(listName string) string {
-	if listName == "" {
-		return ""
+func collectUnusedAddressLists(entries []AddressListEntry, refs []string) {
+	desired := map[string]bool{}
+	for _, e := range entries {
+		refs = append(refs, e.Name)
 	}
-	clean := strings.TrimPrefix(listName, "!")
-	if strings.HasPrefix(clean, "geo:") {
-		code := strings.ToUpper(strings.TrimPrefix(clean, "geo:"))
-		return "mk_geo_" + code
+	for _, name := range refs {
+		name = strings.TrimPrefix(name, "!")
+		if name == "" || strings.HasPrefix(name, "geo:") || strings.HasPrefix(name, "geo_") {
+			continue
+		}
+		for _, suffix := range []string{"", "_v6"} {
+			desired[GetIPSetName(name)+suffix] = true
+			desired[GetDynamicIPSetName(name)+suffix] = true
+			desired[staticIPSetName(name)+suffix] = true
+		}
 	}
-	if strings.HasPrefix(clean, "geo_") {
-		return "mk_" + clean
+	out, err := exec.Command("ipset", "list", "-name").Output()
+	if err != nil {
+		return
 	}
-	return "mk_" + strings.ReplaceAll(clean, " ", "_")
-}
-
-// GetGeoIPSetName returns the internal ipset name for a Geo-Location country or preset
-func GetGeoIPSetName(code string) string {
-	if code == "" {
-		return ""
+	for _, prefix := range []string{"dt_al_", "dt_as_", "dt_ad_"} {
+		for _, name := range strings.Fields(string(out)) {
+			if strings.HasPrefix(name, prefix) && !desired[name] {
+				if exec.Command("ipset", "destroy", name).Run() == nil {
+					setApplyMu.Lock()
+					delete(setDigests, name)
+					setApplyMu.Unlock()
+				}
+			}
+		}
 	}
-	clean := strings.TrimPrefix(code, "!")
-	clean = strings.TrimPrefix(clean, "geo:")
-	clean = strings.TrimPrefix(clean, "geo_")
-	return "mk_geo_" + strings.ToUpper(clean)
 }

+ 46 - 24
internal/firewall/ipset_atomic.go

@@ -5,49 +5,71 @@ import (
 	"fmt"
 	"net"
 	"os/exec"
+	"strconv"
+	"strings"
 	"sync"
 )
 
 var setApplyMu sync.Mutex
+var setDigests = map[string][32]byte{}
 
+func runIPSet(args ...string) error {
+	out, err := exec.Command("ipset", args...).CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("ipset %v: %s: %w", args, out, err)
+	}
+	return nil
+}
 func replaceSet(name, family string, addresses []string) error {
 	setApplyMu.Lock()
 	defer setApplyMu.Unlock()
 	if len(name) > 31 {
-		return fmt.Errorf("address list name too long: %s", name)
+		return fmt.Errorf("address list name too long")
 	}
+	unique := map[string]bool{}
+	var clean []string
 	for _, address := range addresses {
 		ip := net.ParseIP(address)
+		if ip != nil {
+			address = ip.String()
+		}
 		if ip == nil {
-			ip, _, _ = net.ParseCIDR(address)
+			var network *net.IPNet
+			var err error
+			ip, network, err = net.ParseCIDR(address)
+			if err == nil {
+				address = network.String()
+			}
 		}
 		if ip == nil || (ip.To4() != nil) != (family == "inet") {
-			return fmt.Errorf("invalid %s address: %s", family, address)
+			return fmt.Errorf("invalid %s address %q", family, address)
 		}
-	}
-	hash := sha256.Sum256([]byte(name))
-	temp := fmt.Sprintf("dt_tmp_%x", hash[:8])
-	run := func(args ...string) error {
-		out, err := exec.Command("ipset", args...).CombinedOutput()
-		if err != nil {
-			return fmt.Errorf("ipset %v: %s: %w", args, out, err)
+		if !unique[address] {
+			unique[address] = true
+			clean = append(clean, address)
 		}
-		return nil
 	}
-	if err := run("create", temp, "hash:net", "family", family, "maxelem", "262144", "-exist"); err != nil {
-		return err
-	}
-	defer exec.Command("ipset", "destroy", temp).Run()
-	if err := run("flush", temp); err != nil {
-		return err
-	}
-	for _, address := range addresses {
-		if err := run("add", temp, address, "-exist"); err != nil {
-			return err
+	digest := sha256.Sum256([]byte(family + "\n" + strings.Join(clean, "\n")))
+	if setDigests[name] == digest {
+		out, err := exec.Command("ipset", "list", name, "-terse").Output()
+		if err == nil && strings.Contains(string(out), "Number of entries: "+strconv.Itoa(len(clean))+"\n") {
+			return nil
 		}
 	}
-	if err := run("create", name, "hash:net", "family", family, "maxelem", "262144", "-exist"); err != nil {
-		return err
+	h := sha256.Sum256([]byte(name))
+	temp := fmt.Sprintf("dt_tmp_%x", h[:8])
+	var restore strings.Builder
+	fmt.Fprintf(&restore, "create %s hash:net family %s maxelem 1048576 -exist\nflush %s\n", temp, family, temp)
+	for _, address := range clean {
+		fmt.Fprintf(&restore, "add %s %s -exist\n", temp, address)
+	}
+	fmt.Fprintf(&restore, "create %s hash:net family %s maxelem 1048576 -exist\nswap %s %s\ndestroy %s\n", name, family, temp, name, temp)
+	cmd := exec.Command("ipset", "restore")
+	cmd.Stdin = strings.NewReader(restore.String())
+	if out, err := cmd.CombinedOutput(); err != nil {
+		_ = exec.Command("ipset", "destroy", temp).Run()
+		return fmt.Errorf("ipset bulk restore %s: %s: %w", name, out, err)
 	}
-	return run("swap", temp, name)
+	setDigests[name] = digest
+	return nil
 }

+ 93 - 38
internal/firewall/iptables.go

@@ -19,7 +19,7 @@ func MarkNameToID(name string) int {
 	if name == "" {
 		return 0
 	}
-	if id, err := strconv.Atoi(name); err == nil && id > 0 {
+	if id, err := strconv.Atoi(name); err == nil && id >= 0 {
 		return id
 	}
 	h := fnv.New32a()
@@ -90,6 +90,9 @@ func resolveInterfaceList(listName string) []string {
 	if err != nil {
 		return nil
 	}
+	return resolveRoles(data, listName)
+}
+func resolveRoles(data []byte, listName string) []string {
 	var meta struct {
 		Interfaces map[string]struct {
 			Role string `json:"role"`
@@ -158,6 +161,9 @@ func BuildIPTablesRestore(filterRules []FilterRule, natRules []NATRule, mangleRu
 	// Custom user chains in mangle
 	userMangleChains := make(map[string]bool)
 	for _, r := range mangleRules {
+		if r.Disabled {
+			continue
+		}
 		ch := strings.ToUpper(r.Chain)
 		if ch != "PREROUTING" && ch != "POSTROUTING" && ch != "INPUT" && ch != "OUTPUT" && ch != "FORWARD" && ch != "" {
 			if !userMangleChains[ch] {
@@ -195,6 +201,9 @@ func BuildIPTablesRestore(filterRules []FilterRule, natRules []NATRule, mangleRu
 
 	userNATChains := make(map[string]bool)
 	for _, r := range natRules {
+		if r.Disabled {
+			continue
+		}
 		ch := strings.ToUpper(r.Chain)
 		if ch != "PREROUTING" && ch != "POSTROUTING" && ch != "OUTPUT" && ch != "INPUT" && ch != "SRCNAT" && ch != "DSTNAT" && ch != "" {
 			if !userNATChains[ch] {
@@ -231,6 +240,9 @@ func BuildIPTablesRestore(filterRules []FilterRule, natRules []NATRule, mangleRu
 
 	userFilterChains := make(map[string]bool)
 	for _, r := range filterRules {
+		if r.Disabled {
+			continue
+		}
 		ch := strings.ToUpper(r.Chain)
 		if ch != "INPUT" && ch != "FORWARD" && ch != "OUTPUT" && ch != "" {
 			if !userFilterChains[ch] {
@@ -263,6 +275,7 @@ func BuildIPTablesRestore(filterRules []FilterRule, natRules []NATRule, mangleRu
 }
 
 func buildFilterRuleLines(r FilterRule) []string {
+	r.Protocol = inferProtocol(values(r))
 	combos := getInterfaceCombinations(r.InInterface, r.InInterfaceList, r.OutInterface, r.OutInterfaceList)
 	if len(combos) == 0 {
 		return nil
@@ -283,6 +296,8 @@ func buildFilterRuleLines(r FilterRule) []string {
 		// Match criteria
 		args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, inIface, outIface, r.ConnectionState, r.ConnectionMark, r.RoutingMark, r.SrcAddressList, r.DstAddressList, r.SrcGeoIP, r.DstGeoIP)
 
+		args = appendAdvanced(args, r)
+
 		// Comment ID for counters mapping & user comment
 		args = append(args, fmt.Sprintf("-m comment --comment \"id:%s\"", r.ID))
 
@@ -307,30 +322,36 @@ func buildFilterRuleLines(r FilterRule) []string {
 		case "log":
 			args = append(args, "-j LOG")
 			if r.LogPrefix != "" {
-				args = append(args, fmt.Sprintf("--log-prefix \"%s: \"", r.LogPrefix))
+				args = append(args, "--log-prefix "+quote(r.LogPrefix))
 			}
 		case "add-src-to-address-list":
 			if r.AddressList != "" {
-				args = append(args, fmt.Sprintf("-j SET --add-set %s src", GetIPSetName(r.AddressList)))
+				args = append(args, fmt.Sprintf("-j SET --add-set %s src", GetDynamicIPSetName(r.AddressList)))
+				if seconds, _ := timeoutSeconds(r.AddressListTimeout); seconds > 0 {
+					args = append(args, fmt.Sprintf("--timeout %d", seconds))
+				}
 			}
 		case "add-dst-to-address-list":
 			if r.AddressList != "" {
-				args = append(args, fmt.Sprintf("-j SET --add-set %s dst", GetIPSetName(r.AddressList)))
+				args = append(args, fmt.Sprintf("-j SET --add-set %s dst", GetDynamicIPSetName(r.AddressList)))
+				if seconds, _ := timeoutSeconds(r.AddressListTimeout); seconds > 0 {
+					args = append(args, fmt.Sprintf("--timeout %d", seconds))
+				}
 			}
 		case "passthrough":
-			// in iptables, passthrough continues evaluation
-			continue
+			// A rule without a target counts matching packets and continues.
 		default:
-			args = append(args, "-j ACCEPT")
+			continue
 		}
 
 		lines = append(lines, strings.Join(args, " "))
 	}
 
-	return lines
+	return decorateRuleLines("filter", r, lines)
 }
 
 func buildNATRuleLines(r NATRule) []string {
+	r.Protocol = inferProtocol(values(r))
 	combos := getInterfaceCombinations(r.InInterface, r.InInterfaceList, r.OutInterface, r.OutInterfaceList)
 	if len(combos) == 0 {
 		return nil
@@ -350,13 +371,17 @@ func buildNATRuleLines(r NATRule) []string {
 		var args []string
 		args = append(args, fmt.Sprintf("-A %s", chain))
 
-		args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, inIface, outIface, nil, "", "", r.SrcAddressList, r.DstAddressList, r.SrcGeoIP, r.DstGeoIP)
+		args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, inIface, outIface, nil, r.ConnectionMark, r.RoutingMark, r.SrcAddressList, r.DstAddressList, r.SrcGeoIP, r.DstGeoIP)
+		args = appendAdvanced(args, r)
 		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")
+			if r.ToPorts != "" {
+				args = append(args, "--to-ports "+r.ToPorts)
+			}
 		case "src-nat":
 			if r.ToAddresses != "" {
 				target := r.ToAddresses
@@ -388,31 +413,41 @@ func buildNATRuleLines(r NATRule) []string {
 		case "log":
 			args = append(args, "-j LOG")
 			if r.LogPrefix != "" {
-				args = append(args, fmt.Sprintf("--log-prefix \"%s: \"", r.LogPrefix))
+				args = append(args, "--log-prefix "+quote(r.LogPrefix))
 			}
 		case "add-src-to-address-list":
 			if r.AddressList != "" {
-				args = append(args, fmt.Sprintf("-j SET --add-set %s src", GetIPSetName(r.AddressList)))
+				args = append(args, fmt.Sprintf("-j SET --add-set %s src", GetDynamicIPSetName(r.AddressList)))
+				if seconds, _ := timeoutSeconds(r.AddressListTimeout); seconds > 0 {
+					args = append(args, fmt.Sprintf("--timeout %d", seconds))
+				}
 			}
 		case "add-dst-to-address-list":
 			if r.AddressList != "" {
-				args = append(args, fmt.Sprintf("-j SET --add-set %s dst", GetIPSetName(r.AddressList)))
+				args = append(args, fmt.Sprintf("-j SET --add-set %s dst", GetDynamicIPSetName(r.AddressList)))
+				if seconds, _ := timeoutSeconds(r.AddressListTimeout); seconds > 0 {
+					args = append(args, fmt.Sprintf("--timeout %d", seconds))
+				}
 			}
 		case "accept":
 			args = append(args, "-j ACCEPT")
 		case "return":
 			args = append(args, "-j RETURN")
+		case "netmap":
+			args = append(args, "-j NETMAP --to "+r.ToAddresses)
+		case "passthrough":
 		default:
-			args = append(args, "-j MASQUERADE")
+			continue
 		}
 
 		lines = append(lines, strings.Join(args, " "))
 	}
 
-	return lines
+	return decorateRuleLines("nat", r, lines)
 }
 
 func buildMangleRuleLines(r MangleRule) []string {
+	r.Protocol = inferProtocol(values(r))
 	combos := getInterfaceCombinations(r.InInterface, r.InInterfaceList, r.OutInterface, r.OutInterfaceList)
 	if len(combos) == 0 {
 		return nil
@@ -431,6 +466,7 @@ func buildMangleRuleLines(r MangleRule) []string {
 		args = append(args, fmt.Sprintf("-A %s", chain))
 
 		args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, inIface, outIface, r.ConnectionState, r.ConnectionMark, r.RoutingMark, r.SrcAddressList, r.DstAddressList, r.SrcGeoIP, r.DstGeoIP)
+		args = appendAdvanced(args, r)
 		args = append(args, fmt.Sprintf("-m comment --comment \"id:%s\"", r.ID))
 
 		act := strings.ToLower(r.Action)
@@ -451,24 +487,31 @@ func buildMangleRuleLines(r MangleRule) []string {
 				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.TCPFlags == "" {
+				args = append(args, "--tcp-flags SYN,RST SYN")
+			}
+			args = append(args, "-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", "change-dscp":
-			if r.NewDSCP > 0 {
+			if r.NewDSCP >= 0 {
 				args = append(args, fmt.Sprintf("-j DSCP --set-dscp %d", r.NewDSCP))
 			}
 		case "change-ttl":
 			if r.NewTTL != "" {
+				module, flag := "TTL", "ttl"
+				if r.IPVersion == 6 {
+					module, flag = "HL", "hl"
+				}
 				if strings.HasPrefix(r.NewTTL, "+") {
-					args = append(args, fmt.Sprintf("-j TTL --ttl-inc %s", strings.TrimPrefix(r.NewTTL, "+")))
+					args = append(args, fmt.Sprintf("-j %s --%s-inc %s", module, flag, strings.TrimPrefix(r.NewTTL, "+")))
 				} else if strings.HasPrefix(r.NewTTL, "-") {
-					args = append(args, fmt.Sprintf("-j TTL --ttl-dec %s", strings.TrimPrefix(r.NewTTL, "-")))
+					args = append(args, fmt.Sprintf("-j %s --%s-dec %s", module, flag, strings.TrimPrefix(r.NewTTL, "-")))
 				} else {
-					args = append(args, fmt.Sprintf("-j TTL --ttl-set %s", r.NewTTL))
+					args = append(args, fmt.Sprintf("-j %s --%s-set %s", module, flag, r.NewTTL))
 				}
 			}
 		case "set-priority":
@@ -484,22 +527,27 @@ func buildMangleRuleLines(r MangleRule) []string {
 		case "log":
 			args = append(args, "-j LOG")
 			if r.LogPrefix != "" {
-				args = append(args, fmt.Sprintf("--log-prefix \"%s: \"", r.LogPrefix))
+				args = append(args, "--log-prefix "+quote(r.LogPrefix))
 			}
 		case "add-src-to-address-list":
 			if r.AddressList != "" {
-				args = append(args, fmt.Sprintf("-j SET --add-set %s src", GetIPSetName(r.AddressList)))
+				args = append(args, fmt.Sprintf("-j SET --add-set %s src", GetDynamicIPSetName(r.AddressList)))
+				if seconds, _ := timeoutSeconds(r.AddressListTimeout); seconds > 0 {
+					args = append(args, fmt.Sprintf("--timeout %d", seconds))
+				}
 			}
 		case "add-dst-to-address-list":
 			if r.AddressList != "" {
-				args = append(args, fmt.Sprintf("-j SET --add-set %s dst", GetIPSetName(r.AddressList)))
+				args = append(args, fmt.Sprintf("-j SET --add-set %s dst", GetDynamicIPSetName(r.AddressList)))
+				if seconds, _ := timeoutSeconds(r.AddressListTimeout); seconds > 0 {
+					args = append(args, fmt.Sprintf("--timeout %d", seconds))
+				}
 			}
 		case "accept":
 			args = append(args, "-j ACCEPT")
 		case "return":
 			args = append(args, "-j RETURN")
-		case "clear-df", "strip-ipv4-options", "route", "sniff-pc", "sniff-tzsp", "passthrough":
-			continue
+		case "passthrough":
 		default:
 			continue
 		}
@@ -507,7 +555,7 @@ func buildMangleRuleLines(r MangleRule) []string {
 		lines = append(lines, strings.Join(args, " "))
 	}
 
-	return lines
+	return decorateRuleLines("mangle", r, lines)
 }
 
 func appendMatchCriteria(args []string, srcAddr, dstAddr, proto, srcPort, dstPort, inIface, outIface string, ctStates []string, connMark, routingMark, srcList, dstList, srcGeoIP, dstGeoIP string) []string {
@@ -527,27 +575,32 @@ func appendMatchCriteria(args []string, srcAddr, dstAddr, proto, srcPort, dstPor
 		}
 	}
 
-	effectiveProto := strings.ToLower(proto)
+	protoInvert, protoName := plain(strings.ToLower(proto))
+	effectiveProto := protoName
 	if effectiveProto != "" && effectiveProto != "all" {
-		args = append(args, fmt.Sprintf("-p %s", effectiveProto))
+		args = append(args, fmt.Sprintf("%s-p %s", protoInvert, 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))
+		inv, normalized := plain(srcPort)
+		normalized = portValue(normalized)
+		if strings.Contains(normalized, ",") {
+			args = append(args, fmt.Sprintf("-m multiport %s--sports %s", inv, normalized))
 		} else {
-			args = append(args, fmt.Sprintf("--sport %s", srcPort))
+			args = append(args, fmt.Sprintf("%s--sport %s", inv, normalized))
 		}
 	}
 
 	if dstPort != "" && (effectiveProto == "tcp" || effectiveProto == "udp") {
-		if strings.Contains(dstPort, ",") {
-			args = append(args, fmt.Sprintf("-m multiport --dports %s", dstPort))
+		inv, normalized := plain(dstPort)
+		normalized = portValue(normalized)
+		if strings.Contains(normalized, ",") {
+			args = append(args, fmt.Sprintf("-m multiport %s--dports %s", inv, normalized))
 		} else {
-			args = append(args, fmt.Sprintf("--dport %s", dstPort))
+			args = append(args, fmt.Sprintf("%s--dport %s", inv, normalized))
 		}
 	}
 
@@ -576,13 +629,15 @@ func appendMatchCriteria(args []string, srcAddr, dstAddr, proto, srcPort, dstPor
 	}
 
 	if connMark != "" {
-		markID := MarkNameToID(connMark)
-		args = append(args, fmt.Sprintf("-m connmark --mark %d", markID))
+		inv, name := plain(connMark)
+		markID := MarkNameToID(name)
+		args = append(args, fmt.Sprintf("-m connmark %s--mark %d", inv, markID))
 	}
 
 	if routingMark != "" {
-		markID := MarkNameToID(routingMark)
-		args = append(args, fmt.Sprintf("-m mark --mark %d", markID))
+		inv, name := plain(routingMark)
+		markID := MarkNameToID(name)
+		args = append(args, fmt.Sprintf("-m mark %s--mark %d", inv, markID))
 	}
 
 	if srcList != "" {
@@ -634,7 +689,7 @@ func appendMatchCriteria(args []string, srcAddr, dstAddr, proto, srcPort, dstPor
 
 // ApplyIPTables executes `iptables-restore` with the given rule set payload
 func ApplyIPTables(payload string) error {
-	live, err := exec.Command("iptables-save").Output()
+	live, err := exec.Command("iptables-save", "-c").Output()
 	if err != nil {
 		return fmt.Errorf("cannot snapshot existing firewall: %w", err)
 	}

+ 11 - 6
internal/firewall/iptables_test.go

@@ -7,12 +7,17 @@ import (
 
 func TestBuildNATRuleLines_InterfaceList(t *testing.T) {
 	rule := NATRule{
-		ID:               "nat-test-1",
-		Order:            1,
-		Chain:            "srcnat",
-		Action:           "masquerade",
-		OutInterfaceList: "WAN",
-		Disabled:         false,
+		ID:           "nat-test-1",
+		Order:        1,
+		Chain:        "srcnat",
+		Action:       "masquerade",
+		OutInterface: "wan0",
+		Disabled:     false,
+	}
+
+	resolved := resolveRoles([]byte(`{"interfaces":{"wan0":{"role":"WAN"},"lan0":{"role":"LAN"}}}`), "WAN")
+	if len(resolved) != 1 || resolved[0] != "wan0" {
+		t.Fatal(resolved)
 	}
 
 	lines := buildNATRuleLines(rule)

+ 102 - 50
internal/firewall/manager.go

@@ -2,7 +2,9 @@ package firewall
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
+	"log"
 	"os"
 	"os/exec"
 	"strings"
@@ -20,6 +22,8 @@ const (
 type FirewallManager struct {
 	mu           sync.Mutex
 	committed    []byte
+	lastError    string
+	applied      bool
 	filterRules  []FilterRule
 	natRules     []NATRule
 	mangleRules  []MangleRule
@@ -35,20 +39,36 @@ func NewFirewallManager() *FirewallManager {
 		addressLists: make([]AddressListEntry, 0),
 		geoIPMgr:     NewGeoIPManager(),
 	}
-	m.load()
+	m.geoIPMgr.referenced = m.geoReferenced
+	loadErr := m.load()
 	m.remember()
-
-	// 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()
+	if loadErr != nil {
+		m.lastError = loadErr.Error()
+		log.Printf("Firewall configuration not loaded: %v", loadErr)
+		return m
+	}
+	// Defaults are installed only once, never because a user intentionally saved
+	// an empty list or deleted all rules.
+	fresh := true
+	for _, path := range []string{FilterStorePath, NATStorePath, MangleStorePath, AddressListsStorePath} {
+		if _, err := os.Stat(path); !os.IsNotExist(err) {
+			fresh = false
+		}
+	}
+	if fresh {
+		if err := m.initDefaultRules(); err != nil {
+			m.lastError = err.Error()
+			return m
+		}
+	}
+	if err := m.Apply(); err != nil {
+		log.Printf("Firewall not applied: %v", err)
 	}
-
-	_ = m.Apply()
 	m.remember()
 	return m
 }
 
-func (m *FirewallManager) initDefaultRules() {
+func (m *FirewallManager) initDefaultRules() error {
 	if len(m.natRules) == 0 {
 		// Default Masquerade NAT only on WAN
 		m.natRules = append(m.natRules, NATRule{
@@ -85,35 +105,36 @@ func (m *FirewallManager) initDefaultRules() {
 		})
 	}
 
-	_ = m.save()
+	return 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) load() error {
+	var failures []error
+	for path, target := range map[string]any{FilterStorePath: &m.filterRules, NATStorePath: &m.natRules, MangleStorePath: &m.mangleRules, AddressListsStorePath: &m.addressLists} {
+		data, err := os.ReadFile(path)
+		if os.IsNotExist(err) {
+			continue
+		}
+		if err == nil {
+			err = json.Unmarshal(data, target)
+		}
+		if err != nil {
+			failures = append(failures, fmt.Errorf("%s: %w", path, err))
+		}
 	}
-
 	if m.filterRules == nil {
-		m.filterRules = make([]FilterRule, 0)
+		m.filterRules = []FilterRule{}
 	}
 	if m.natRules == nil {
-		m.natRules = make([]NATRule, 0)
+		m.natRules = []NATRule{}
 	}
 	if m.mangleRules == nil {
-		m.mangleRules = make([]MangleRule, 0)
+		m.mangleRules = []MangleRule{}
 	}
 	if m.addressLists == nil {
-		m.addressLists = make([]AddressListEntry, 0)
+		m.addressLists = []AddressListEntry{}
 	}
+	return errors.Join(failures...)
 }
 
 func (m *FirewallManager) snapshot() []byte {
@@ -134,29 +155,15 @@ func (m *FirewallManager) save() error {
 	if err := os.MkdirAll("/etc/proxmox-router/firewall", 0755); err != nil {
 		return err
 	}
+	files := map[string][]byte{}
 	for path, value := range map[string]any{FilterStorePath: m.filterRules, NATStorePath: m.natRules, MangleStorePath: m.mangleRules, AddressListsStorePath: m.addressLists} {
 		data, err := json.MarshalIndent(value, "", "  ")
 		if err != nil {
 			return err
 		}
-		f, err := os.CreateTemp("/etc/proxmox-router/firewall", ".config-*")
-		if err != nil {
-			return err
-		}
-		_, err = f.Write(data)
-		closeErr := f.Close()
-		if err == nil {
-			err = closeErr
-		}
-		if err == nil {
-			err = os.Rename(f.Name(), path)
-		}
-		_ = os.Remove(f.Name())
-		if err != nil {
-			return err
-		}
+		files[path] = data
 	}
-	return nil
+	return saveFileGroup(files)
 }
 func (m *FirewallManager) commitChange() error {
 	before := append([]byte(nil), m.committed...)
@@ -169,7 +176,9 @@ func (m *FirewallManager) commitChange() error {
 		rollbackErr := m.Apply()
 		saveErr := m.save()
 		if rollbackErr != nil || saveErr != nil {
-			return fmt.Errorf("firewall change failed: %v; rollback apply: %v; rollback save: %v", err, rollbackErr, saveErr)
+			m.lastError = fmt.Sprintf("firewall change failed: %v; rollback apply: %v; rollback save: %v", err, rollbackErr, saveErr)
+			m.applied = false
+			return errors.New(m.lastError)
 		}
 		return err
 	}
@@ -179,7 +188,30 @@ func (m *FirewallManager) commitChange() error {
 func (m *FirewallManager) Reapply() error { m.mu.Lock(); defer m.mu.Unlock(); return m.Apply() }
 
 // Apply re-synchronizes AddressLists & GeoIP sets with ipset and applies iptables
-func (m *FirewallManager) Apply() error {
+func (m *FirewallManager) Apply() (result error) {
+	defer func() {
+		m.applied = result == nil
+		m.lastError = ""
+		if result != nil {
+			m.lastError = result.Error()
+		}
+	}()
+	for _, r := range m.filterRules {
+		if err := validateRule("filter", r); err != nil {
+			return err
+		}
+	}
+	for _, r := range m.natRules {
+		if err := validateRule("nat", r); err != nil {
+			return err
+		}
+	}
+	for _, r := range m.mangleRules {
+		if err := validateRule("mangle", r); err != nil {
+			return err
+		}
+	}
+
 	// Sync GeoIP country sets
 	if m.geoIPMgr != nil {
 
@@ -275,7 +307,11 @@ func (m *FirewallManager) Apply() error {
 		return err
 	}
 
-	return ApplyDualStack(m.filterRules, m.natRules, m.mangleRules)
+	if err := ApplyDualStack(m.filterRules, m.natRules, m.mangleRules); err != nil {
+		return err
+	}
+	collectUnusedAddressLists(m.addressLists, refLists)
+	return nil
 }
 
 func (m *FirewallManager) GeoIP() *GeoIPManager {
@@ -599,10 +635,13 @@ func (m *FirewallManager) ListAddressLists() []AddressListEntry {
 
 	res := make([]AddressListEntry, len(m.addressLists))
 	copy(res, m.addressLists)
-	return res
+	return append(res, m.dynamicEntries()...)
 }
 
 func (m *FirewallManager) SaveAddressListEntry(e AddressListEntry) error {
+	if e.Dynamic || e.Timeout != 0 {
+		return fmt.Errorf("dynamic entries are managed by packet rules")
+	}
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
@@ -626,6 +665,11 @@ func (m *FirewallManager) SaveAddressListEntry(e AddressListEntry) error {
 }
 
 func (m *FirewallManager) DeleteAddressListEntry(id string) error {
+	if strings.HasPrefix(id, "dynamic-") {
+		m.mu.Lock()
+		defer m.mu.Unlock()
+		return deleteDynamicEntry(id)
+	}
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
@@ -684,7 +728,11 @@ func (m *FirewallManager) GetSummary() FirewallSummary {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
-	conns, _ := ListActiveConnections()
+	conns, connErr := ListActiveConnections()
+	connectionError := ""
+	if connErr != nil {
+		connectionError = connErr.Error()
+	}
 
 	geoCount := 0
 	geoSubnets := 0
@@ -706,6 +754,8 @@ func (m *FirewallManager) GetSummary() FirewallSummary {
 		GeoCountriesCount: geoCount,
 		GeoSubnetsCount:   geoSubnets,
 		ActiveConnections: len(conns),
+		ConnectionsError:  connectionError,
+		Applied:           m.applied, LastError: m.lastError,
 	}
 }
 
@@ -746,7 +796,9 @@ func (m *FirewallManager) RenameInterface(oldName, newName string) {
 		}
 	}
 	if changed {
-		_ = m.save()
-		_ = m.Apply()
+		if err := m.commitChange(); err != nil {
+			m.lastError = err.Error()
+			log.Printf("Firewall rename failed: %v", err)
+		}
 	}
 }

+ 192 - 0
internal/firewall/native_test.go

@@ -0,0 +1,192 @@
+package firewall
+
+import (
+	"encoding/json"
+	"os"
+	"os/exec"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestNativeValidationAndCriteria(t *testing.T) {
+	for _, r := range []FilterRule{{Chain: "input", Action: "tarpit"}, {Chain: "input", Action: "accept", Layer7Protocol: "regex"}, {Chain: "input", Action: "drop", TLSHost: "*.example.com"}, {Chain: "input", Action: "accept", Protocol: "tcp\" -j ACCEPT"}, {Chain: "input", Action: "accept", ID: "id\" -j ACCEPT"}} {
+		if validateRule("filter", r) == nil {
+			t.Fatalf("accepted unsafe/unsupported rule: %+v", r)
+		}
+	}
+	r := FilterRule{ID: "filter-test", Chain: "forward", Action: "drop", Protocol: "tcp", DstPort: "!80-90", AnyPort: "443", PacketMark: "!100", SrcMACAddress: "02:00:00:00:00:01", DSCP: "0", PacketSize: "64-1500", TCPFlags: "syn,!ack", Limit: "100,5:packet", Time: "08:00-17:00", Days: "mon,fri", Log: true, LogPrefix: "audit\"prefix"}
+	if err := validateRule("filter", r); err != nil {
+		t.Fatal(err)
+	}
+	payload := BuildIPTablesRestore([]FilterRule{r}, nil, nil)
+	for _, want := range []string{"! --dport 80:90", "--ports 443", "-m mark ! --mark 100", "--mac-source 02:00:00:00:00:01", "--dscp 0", "--length 64:1500", "--tcp-flags SYN,ACK SYN", "--limit 100.000000/second", "--weekdays Mon,Fri", "-j LOG"} {
+		if !strings.Contains(payload, want) {
+			t.Errorf("missing %q in %s", want, payload)
+		}
+	}
+	if validateRule("filter", FilterRule{Chain: "input", Action: "accept", Time: "24:00-25:00"}) == nil {
+		t.Fatal("invalid time accepted")
+	}
+	if _, err := timeoutSeconds("1d"); err != nil {
+		t.Fatal(err)
+	}
+	if GetIPSetName("a b") == GetIPSetName("a_b") {
+		t.Fatal("colliding set names")
+	}
+	for _, s := range []ConnectionSelector{{}, {Protocol: "tcp", Src: "192.0.2.1", Dst: "198.51.100.1"}, {Protocol: "udp", Src: "192.0.2.1", Dst: "bad", Sport: 10, Dport: 11}} {
+		if _, err := connectionDeleteArgs(s); err == nil {
+			t.Fatal("incomplete tuple accepted")
+		}
+	}
+	c := parseConntrackLine("ipv4 2 tcp 6 432000 ESTABLISHED src=192.0.2.1 dst=198.51.100.1 sport=123 dport=443 packets=4 bytes=400 src=198.51.100.1 dst=192.0.2.1 sport=443 dport=123 packets=3 bytes=300 [ASSURED] mark=7 zone=2")
+	if c.Timeout != 432000 || c.Bytes != 700 || c.Packets != 7 || c.Zone != 2 || c.TCPState != "ESTABLISHED" {
+		t.Fatalf("bad conntrack parse: %+v", c)
+	}
+}
+func TestFirewallNativeKernel(t *testing.T) {
+	if os.Getenv("DEBTIK_FIREWALL_NATIVE") != "1" {
+		t.Skip("isolated mount/network namespaces only")
+	}
+	run := func(binary string, args ...string) string {
+		t.Helper()
+		out, err := exec.Command(binary, args...).CombinedOutput()
+		if err != nil {
+			t.Fatalf("%s %v: %s: %v", binary, args, out, err)
+		}
+		return string(out)
+	}
+	run("ip", "link", "set", "lo", "up")
+	dir := t.TempDir()
+	run("mount", "--bind", dir, "/etc/proxmox-router/firewall")
+	meta := t.TempDir() + "/metadata.json"
+	os.WriteFile(meta, []byte(`{"interfaces":{"wan0":{"role":"WAN"},"lan0":{"role":"LAN"}}}`), 0600)
+	run("mount", "--bind", meta, "/etc/proxmox-router/metadata.json")
+	os.MkdirAll(GeoIPZonesDir, 0755)
+	os.WriteFile(GeoIPConfigFile, []byte(`{"auto_update":false,"interval_hours":24,"enabled_countries":[]}`), 0600)
+	os.WriteFile(GeoIPZonesDir+"/gr.zone", []byte("192.0.2.0/24\n"), 0600)
+	// Validate the kernel accepts every supported native match in a suitable chain.
+	samples := []map[string]any{{"any_port": "443"}, {"log": true, "log_prefix": "audit\"safe"}, {"dst_port": "!80-90"}, {"packet_mark": "!100"}, {"connection_type": "ftp"}, {"connection_state": []string{"new"}, "connection_nat_state": []string{"dstnat"}}, {"content": "example.com"}, {"src_mac_address": "02:00:00:00:00:01"}, {"in_bridge_port": "lan0"}, {"out_bridge_port": "wan0"}, {"ipsec_policy": "in,none"}, {"dscp": "0"}, {"tcp_mss": "1200-1500"}, {"tos": "min-delay"}, {"packet_size": "64-1500"}, {"random": "25"}, {"tcp_flags": "syn,!ack"}, {"ttl": "greater-than:20"}, {"limit": "100,5:packet"}, {"time": "08:00-17:00", "days": "mon,fri"}}
+	for _, sample := range samples {
+		sample["id"] = "filter-fields"
+		sample["chain"] = "forward"
+		sample["action"] = "accept"
+		sample["protocol"] = "tcp"
+		b, _ := json.Marshal(sample)
+		var r FilterRule
+		json.Unmarshal(b, &r)
+		if err := ApplyDualStack([]FilterRule{r}, nil, nil); err != nil {
+			t.Fatalf("match %v failed: %v", sample, err)
+		}
+	}
+	m := NewFirewallManager()
+	if !m.applied {
+		t.Fatal(m.lastError)
+	}
+	for _, r := range m.ListFilterRules() {
+		if err := m.DeleteFilterRule(r.ID); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if m = NewFirewallManager(); len(m.ListFilterRules()) != 0 {
+		t.Fatal("empty filter list resurrected")
+	}
+	r := FilterRule{ID: "filter-count", Chain: "output", Action: "accept", Protocol: "icmp"}
+	if err := m.SaveFilterRule(r); err != nil {
+		t.Fatal(err)
+	}
+	run("ping", "-c", "1", "-W", "1", "127.0.0.1")
+	before, _ := ReadRuleCounters(nil, nil, nil)
+	if before[r.ID][0] == 0 {
+		t.Fatal("no counter")
+	}
+	r.Comment = "comment change"
+	if err := m.SaveFilterRule(r); err != nil {
+		t.Fatal(err)
+	}
+	after, _ := ReadRuleCounters(nil, nil, nil)
+	if after[r.ID][0] != before[r.ID][0] {
+		t.Fatalf("counters lost: %v -> %v", before, after)
+	}
+	if err := m.ResetRuleCounters("filter", r.ID); err != nil {
+		t.Fatal(err)
+	}
+	counts, _ := ReadRuleCounters(nil, nil, nil)
+	if counts[r.ID][0] != 0 {
+		t.Fatal("single reset failed")
+	}
+	// First marking rule must stop the managed mangle chain even after changing
+	// the mark that formed part of its own criterion.
+	for _, r := range []MangleRule{{ID: "mangle-first", Chain: "output", Action: "mark-packet", NewPacketMark: "100", Passthrough: false, Protocol: "icmp"}, {ID: "mangle-second", Chain: "output", Action: "mark-packet", NewPacketMark: "200", Passthrough: true, Protocol: "icmp"}} {
+		if err := m.SaveMangleRule(r); err != nil {
+			t.Fatal(err)
+		}
+	}
+	run("ping", "-c", "1", "-W", "1", "127.0.0.1")
+	counts, _ = ReadRuleCounters(nil, nil, nil)
+	if counts["mangle-first"][0] == 0 || counts["mangle-second"][0] != 0 {
+		t.Fatalf("passthrough=false failed: %v", counts)
+	}
+	if err := m.DeleteFilterRule(r.ID); err != nil {
+		t.Fatal(err)
+	}
+	dyn := FilterRule{ID: "filter-dynamic", Chain: "output", Action: "add-src-to-address-list", AddressList: "dynamic", AddressListTimeout: "2s", Protocol: "icmp"}
+	if err := m.SaveFilterRule(dyn); err != nil {
+		t.Fatal(err)
+	}
+	run("ping", "-c", "1", "-W", "1", "127.0.0.1")
+	run("ipset", "test", GetDynamicIPSetName("dynamic"), "127.0.0.1")
+	visible := false
+	for _, e := range m.ListAddressLists() {
+		visible = visible || e.Dynamic && e.Address == "127.0.0.1"
+	}
+	if !visible {
+		t.Fatal("dynamic address not visible")
+	}
+	dyn.Comment = "unrelated edit"
+	if err := m.SaveFilterRule(dyn); err != nil {
+		t.Fatal(err)
+	}
+	run("ipset", "test", GetDynamicIPSetName("dynamic"), "127.0.0.1")
+	if err := m.SaveAddressListEntry(AddressListEntry{Name: "dynamic", Address: "127.0.0.2"}); err != nil {
+		t.Fatal(err)
+	}
+	run("ipset", "test", GetIPSetName("dynamic"), staticIPSetName("dynamic"))
+	run("ipset", "test", staticIPSetName("dynamic"), "127.0.0.2")
+	time.Sleep(2200 * time.Millisecond)
+	if exec.Command("ipset", "test", GetDynamicIPSetName("dynamic"), "127.0.0.1").Run() == nil {
+		t.Fatal("dynamic address did not expire")
+	}
+	if err := m.DeleteFilterRule(dyn.ID); err != nil {
+		t.Fatal(err)
+	}
+	if err := m.SaveFilterRule(FilterRule{ID: "filter-list-match", Chain: "output", Action: "drop", Protocol: "icmp", SrcAddressList: "dynamic"}); err != nil {
+		t.Fatal(err)
+	}
+	if exec.Command("ping", "-I", "127.0.0.2", "-c", "1", "-W", "1", "127.0.0.1").Run() == nil {
+		t.Fatal("static list member did not match union set")
+	}
+	if err := m.SaveFilterRule(FilterRule{ID: "filter-geo", Chain: "input", Action: "drop", SrcGeoIP: "GR"}); err != nil {
+		t.Fatal(err)
+	}
+	if m.GeoIP().ToggleCountry("GR", false) == nil {
+		t.Fatal("disabled a referenced GeoIP set")
+	}
+	run("ipset", "test", GetGeoIPSetName("GR"), "192.0.2.1")
+	if err := m.GeoIP().BatchToggle("custom", true, []string{"ZZ"}); err == nil {
+		t.Fatal("invalid batch falsely succeeded")
+	}
+	// IPv6 mangle ICMP and hop-limit are native IPv6 operations.
+	if err := ApplyDualStack(nil, nil, []MangleRule{{IPVersion: 6, ID: "mangle-v6", Chain: "output", Action: "drop", Protocol: "icmp", Passthrough: true}}); err != nil {
+		t.Fatal(err)
+	}
+	if exec.Command("ping", "-6", "-c", "1", "-W", "1", "::1").Run() == nil {
+		t.Fatal("IPv6 ICMP drop did not match")
+	}
+	for _, r := range []MangleRule{{IPVersion: 6, ID: "mangle-ttl", Chain: "output", Action: "change-ttl", NewTTL: "64", Passthrough: true}, {ID: "mangle-mss", Chain: "forward", Protocol: "tcp", Action: "change-mss", NewMSS: "1400", Passthrough: true}, {ID: "mangle-dscp", Chain: "output", Action: "change-dscp", NewDSCP: 0, Passthrough: true}} {
+		if err := ApplyDualStack(nil, nil, []MangleRule{r}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	t.Log("Native firewall integration complete")
+}

+ 62 - 0
internal/firewall/owned.go

@@ -2,6 +2,8 @@ package firewall
 
 import (
 	"fmt"
+	"regexp"
+	"strconv"
 	"strings"
 )
 
@@ -9,8 +11,28 @@ import (
 // policies and rules are left intact; only legacy DebTik ID rules are migrated.
 func ownedPayload(payload, live string) string {
 	liveTables := map[string][]string{}
+	counts := map[string][2]uint64{}
+	oldChains := map[string][]string{}
+	counted := map[string]bool{}
+	counterPattern := regexp.MustCompile(`^\[(\d+):(\d+)\] (.*)$`)
+	commentPattern := regexp.MustCompile(`--comment (?:"id:([^"]+)"|id:([^ ]+))`)
 	table := ""
 	for _, line := range strings.Split(live, "\n") {
+		var pkts, bytes uint64
+		if m := counterPattern.FindStringSubmatch(line); m != nil {
+			pkts, _ = strconv.ParseUint(m[1], 10, 64)
+			bytes, _ = strconv.ParseUint(m[2], 10, 64)
+			line = m[3]
+		}
+		if m := commentPattern.FindStringSubmatch(line); m != nil {
+			id := m[1] + m[2]
+			key := table + ":" + id
+			c := counts[key]
+			counts[key] = [2]uint64{c[0] + pkts, c[1] + bytes}
+		}
+		if strings.HasPrefix(line, ":DT_") {
+			oldChains[table] = append(oldChains[table], strings.Fields(line)[0][1:])
+		}
 		if strings.HasPrefix(line, "*") {
 			table = line[1:]
 		} else if strings.HasPrefix(line, "-A ") {
@@ -50,12 +72,52 @@ func ownedPayload(payload, live string) string {
 			line = strings.Replace(line, "-A "+fields[1]+" ", "-A "+names[fields[1]]+" ", 1)
 			for original, owned := range names {
 				line = strings.ReplaceAll(line, "-j "+original+" ", "-j "+owned+" ")
+				line = strings.ReplaceAll(line, "-g "+original+" ", "-g "+owned+" ")
+				if strings.HasSuffix(line, "-g "+original) {
+					line = strings.TrimSuffix(line, "-g "+original) + "-g " + owned
+				}
 				if strings.HasSuffix(line, "-j "+original) {
 					line = strings.TrimSuffix(line, "-j "+original) + "-j " + owned
 				}
 			}
+			if m := commentPattern.FindStringSubmatch(line); m != nil {
+				key := table + ":" + m[1] + m[2]
+				if !counted[key] {
+					c := counts[key]
+					if c[0] != 0 || c[1] != 0 {
+						line = fmt.Sprintf("[%d:%d] %s", c[0], c[1], line)
+					}
+					counted[key] = true
+				}
+			}
 			out.WriteString(line + "\n")
 		}
+		wanted := map[string]bool{}
+		for _, name := range names {
+			wanted[name] = true
+		}
+		var obsolete []string
+		for _, old := range oldChains[table] {
+			if wanted[old] {
+				continue
+			}
+			foreign := false
+			for _, rule := range liveTables[table] {
+				source := strings.Fields(rule)[1]
+				if !strings.HasPrefix(source, "DT_") && (strings.HasSuffix(rule, "-j "+old) || strings.HasSuffix(rule, "-g "+old)) {
+					foreign = true
+				}
+			}
+			if !foreign {
+				obsolete = append(obsolete, old)
+			}
+		}
+		for _, old := range obsolete {
+			fmt.Fprintf(&out, "-F %s\n", old)
+		}
+		for _, old := range obsolete {
+			fmt.Fprintf(&out, "-X %s\n", old)
+		}
 		for _, chain := range chains {
 			builtin := chain == "INPUT" || chain == "OUTPUT" || chain == "FORWARD" || chain == "PREROUTING" || chain == "POSTROUTING"
 			if !builtin {

+ 2 - 2
internal/firewall/owned_test.go

@@ -92,14 +92,14 @@ func TestFirewallFailedSaveRollsBack(t *testing.T) {
 	if err := SyncAddressLists([]AddressListEntry{{Name: "test", Address: "192.0.2.0/24"}, {Name: "test", Address: "2001:db8::/32"}}, "test"); err != nil {
 		t.Fatal(err)
 	}
-	out, err := exec.Command("ipset", "list", "mk_test_v6").CombinedOutput()
+	out, err := exec.Command("ipset", "list", staticIPSetName("test")+"_v6").CombinedOutput()
 	if err != nil || !strings.Contains(string(out), "2001:db8::/32") {
 		t.Fatal(string(out), err)
 	}
 	if err := SyncAddressLists([]AddressListEntry{{Name: "test", Address: "invalid-address"}}, "test"); err == nil {
 		t.Fatal("invalid list address accepted")
 	}
-	out, err = exec.Command("ipset", "list", "mk_test").CombinedOutput()
+	out, err = exec.Command("ipset", "list", staticIPSetName("test")).CombinedOutput()
 	if err != nil || !strings.Contains(string(out), "192.0.2.0/24") {
 		t.Fatal("failed list update destroyed old set", string(out), err)
 	}

+ 80 - 0
internal/firewall/storage.go

@@ -0,0 +1,80 @@
+package firewall
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+func stageFile(path string, data []byte) (string, error) {
+	f, err := os.CreateTemp(filepath.Dir(path), ".debtik-*")
+	if err != nil {
+		return "", err
+	}
+	name := f.Name()
+	_, err = f.Write(data)
+	if err == nil {
+		err = f.Sync()
+	}
+	closed := f.Close()
+	if err == nil {
+		err = closed
+	}
+	if err != nil {
+		os.Remove(name)
+		return "", err
+	}
+	return name, nil
+}
+func writeAtomic(path string, data []byte) error {
+	name, err := stageFile(path, data)
+	if err != nil {
+		return err
+	}
+	defer os.Remove(name)
+	return os.Rename(name, path)
+}
+func saveFileGroup(files map[string][]byte) error {
+	staged := map[string]string{}
+	original := map[string][]byte{}
+	exists := map[string]bool{}
+	defer func() {
+		for _, name := range staged {
+			os.Remove(name)
+		}
+	}()
+	for path, data := range files {
+		old, err := os.ReadFile(path)
+		if err != nil && !os.IsNotExist(err) {
+			return err
+		}
+		exists[path] = err == nil
+		original[path] = old
+		name, err := stageFile(path, data)
+		if err != nil {
+			return err
+		}
+		staged[path] = name
+	}
+	var renamed []string
+	for path, name := range staged {
+		if err := os.Rename(name, path); err != nil {
+			failures := []error{err}
+			for _, oldPath := range renamed {
+				var restore error
+				if exists[oldPath] {
+					restore = writeAtomic(oldPath, original[oldPath])
+				} else {
+					restore = os.Remove(oldPath)
+				}
+				if restore != nil {
+					failures = append(failures, fmt.Errorf("restore %s: %w", oldPath, restore))
+				}
+			}
+			return errors.Join(failures...)
+		}
+		renamed = append(renamed, path)
+	}
+	return nil
+}

+ 26 - 15
internal/firewall/types.go

@@ -2,7 +2,7 @@ package firewall
 
 import "time"
 
-// FilterRule represents a MikroTik-style /ip firewall filter rule
+// FilterRule represents a Linux firewall filter rule
 type FilterRule struct {
 	IPVersion       int      `json:"ip_version,omitempty"`
 	ID              string   `json:"id"`
@@ -32,7 +32,7 @@ type FilterRule struct {
 	Packets         uint64   `json:"packets"`
 	Bytes           uint64   `json:"bytes"`
 
-	// Extended WebFig / WinBox v4 properties
+	// Extended Linux firewall match properties
 	AnyPort            string   `json:"any_port,omitempty"`
 	InInterfaceList    string   `json:"in_interface_list,omitempty"`
 	OutInterfaceList   string   `json:"out_interface_list,omitempty"`
@@ -77,7 +77,7 @@ type FilterRule struct {
 	AddressListTimeout string `json:"address_list_timeout,omitempty"`
 }
 
-// NATRule represents a MikroTik-style /ip firewall nat rule
+// NATRule represents a Linux NAT rule
 type NATRule struct {
 	IPVersion      int    `json:"ip_version,omitempty"`
 	ID             string `json:"id"`
@@ -102,7 +102,7 @@ type NATRule struct {
 	Packets        uint64 `json:"packets"`
 	Bytes          uint64 `json:"bytes"`
 
-	// Extended WebFig / WinBox v4 properties
+	// Extended Linux firewall match properties
 	AnyPort          string `json:"any_port,omitempty"`
 	InInterfaceList  string `json:"in_interface_list,omitempty"`
 	OutInterfaceList string `json:"out_interface_list,omitempty"`
@@ -149,7 +149,7 @@ type NATRule struct {
 	AddressListTimeout string `json:"address_list_timeout,omitempty"`
 }
 
-// MangleRule represents a MikroTik-style /ip firewall mangle rule
+// MangleRule represents a Linux mangle rule
 type MangleRule struct {
 	IPVersion         int      `json:"ip_version,omitempty"`
 	ID                string   `json:"id"`
@@ -165,7 +165,7 @@ type MangleRule struct {
 	NewPriority       string   `json:"new_priority,omitempty"`
 	Route             string   `json:"route,omitempty"`
 	SniffTarget       string   `json:"sniff_target,omitempty"`
-	Passthrough       bool     `json:"passthrough"` // default true in MikroTik
+	Passthrough       bool     `json:"passthrough"` // continue evaluating rules after this action
 	SrcAddress        string   `json:"src_address,omitempty"`
 	DstAddress        string   `json:"dst_address,omitempty"`
 	Protocol          string   `json:"protocol,omitempty"`
@@ -185,7 +185,7 @@ type MangleRule struct {
 	Packets           uint64   `json:"packets"`
 	Bytes             uint64   `json:"bytes"`
 
-	// Extended WebFig / WinBox v4 properties
+	// Extended Linux firewall match properties
 	AnyPort            string   `json:"any_port,omitempty"`
 	InInterfaceList    string   `json:"in_interface_list,omitempty"`
 	OutInterfaceList   string   `json:"out_interface_list,omitempty"`
@@ -228,6 +228,7 @@ type MangleRule struct {
 	Limit           string `json:"limit,omitempty"`
 	DstLimit        string `json:"dst_limit,omitempty"`
 	Time            string `json:"time,omitempty"`
+	Days            string `json:"days,omitempty"`
 	PSD             string `json:"psd,omitempty"`
 
 	// Action tab extended
@@ -238,8 +239,10 @@ type MangleRule struct {
 	AddressListTimeout string `json:"address_list_timeout,omitempty"`
 }
 
-// AddressListEntry represents a MikroTik /ip firewall address-list entry
+// AddressListEntry represents a static firewall address-list entry
 type AddressListEntry struct {
+	Dynamic  bool   `json:"dynamic,omitempty"`
+	Timeout  int    `json:"timeout,omitempty"`
 	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"
@@ -260,6 +263,11 @@ type GeoCountryInfo struct {
 
 // ActiveConnection represents an active flow from /proc/net/nf_conntrack / conntrack table
 type ActiveConnection struct {
+	IPVersion       int    `json:"ip_version"`
+	Zone            int    `json:"zone"`
+	ICMPID          *int   `json:"icmp_id,omitempty"`
+	ICMPType        *int   `json:"icmp_type,omitempty"`
+	ICMPCode        *int   `json:"icmp_code,omitempty"`
 	ID              string `json:"id"`
 	Protocol        string `json:"protocol"`
 	SrcAddress      string `json:"src_address"`
@@ -280,11 +288,14 @@ type ActiveConnection struct {
 
 // 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"`
-	GeoCountriesCount int `json:"geo_countries_count"`
-	GeoSubnetsCount   int `json:"geo_subnets_count"`
-	ActiveConnections int `json:"active_connections"`
+	ConnectionsError  string `json:"connections_error,omitempty"`
+	Applied           bool   `json:"applied"`
+	LastError         string `json:"last_error,omitempty"`
+	FilterRulesCount  int    `json:"filter_rules_count"`
+	NATRulesCount     int    `json:"nat_rules_count"`
+	MangleRulesCount  int    `json:"mangle_rules_count"`
+	AddressListsCount int    `json:"address_lists_count"`
+	GeoCountriesCount int    `json:"geo_countries_count"`
+	GeoSubnetsCount   int    `json:"geo_subnets_count"`
+	ActiveConnections int    `json:"active_connections"`
 }

+ 65 - 0
internal/firewall/ui_contract_test.go

@@ -0,0 +1,65 @@
+package firewall
+
+import (
+	"golang.org/x/net/html"
+	"os"
+	"strings"
+	"testing"
+)
+
+func TestUIOnlyOffersImplementedFirewallOptions(t *testing.T) {
+	data, err := os.ReadFile("../web/static/index.html")
+	if err != nil {
+		t.Fatal(err)
+	}
+	doc, err := html.Parse(strings.NewReader(string(data)))
+	if err != nil {
+		t.Fatal(err)
+	}
+	caps := Capabilities()
+	actions := caps["actions"].(map[string][]string)
+	fields := caps["fields"].(map[string][]string)
+	keyMap := map[string]string{"src": "src_address", "dst": "dst_address", "srclist": "src_address_list", "dstlist": "dst_address_list", "srcgeo": "src_geoip", "dstgeo": "dst_geoip", "proto": "protocol", "sport": "src_port", "dport": "dst_port", "anyport": "any_port", "iniface": "in_interface", "outiface": "out_interface", "inifacelist": "in_interface_list", "outifacelist": "out_interface_list", "pktmark": "packet_mark", "connmark": "connection_mark", "routingmark": "routing_mark", "conntype": "connection_type", "content": "content", "srcmac": "src_mac_address", "inbridgeport": "in_bridge_port", "outbridgeport": "out_bridge_port", "ipsec": "ipsec_policy", "dscp": "dscp", "tcpmss": "tcp_mss", "tos": "tos", "pktsize": "packet_size", "random": "random", "tcpflags": "tcp_flags", "icmpoptions": "icmp_options", "ttl": "ttl", "time": "time", "limit": "limit", "nth": "nth", "connlimit": "connection_limit", "srcaddrtype": "src_address_type", "dstaddrtype": "dst_address_type", "ipfragment": "ip_fragment", "logprefix": "log_prefix"}
+	contains := func(items []string, s string) bool {
+		for _, item := range items {
+			if item == s {
+				return true
+			}
+		}
+		return false
+	}
+	var visit func(*html.Node)
+	visit = func(n *html.Node) {
+		attrs := map[string]string{}
+		for _, a := range n.Attr {
+			attrs[a.Key] = a.Val
+		}
+		id := attrs["id"]
+		for prefix, kind := range map[string]string{"fw": "filter", "nat": "nat", "mangle": "mangle"} {
+			if strings.HasPrefix(id, prefix+"-val-") {
+				key := strings.TrimPrefix(id, prefix+"-val-")
+				field := keyMap[key]
+				if field == "" || !contains(fields[kind], field) {
+					t.Errorf("UI control %s has no supported API/compiler field", id)
+				}
+			}
+		}
+		for _, kind := range []string{"filter", "nat", "mangle"} {
+			if n.Type == html.ElementNode && n.Data == "select" && id == kind+"-modal-action" {
+				for child := n.FirstChild; child != nil; child = child.NextSibling {
+					if child.Type == html.ElementNode && child.Data == "option" {
+						for _, a := range child.Attr {
+							if a.Key == "value" && !contains(actions[kind], a.Val) {
+								t.Errorf("unsupported %s UI action %s", kind, a.Val)
+							}
+						}
+					}
+				}
+			}
+		}
+		for c := n.FirstChild; c != nil; c = c.NextSibling {
+			visit(c)
+		}
+	}
+	visit(doc)
+}

+ 357 - 0
internal/firewall/validation.go

@@ -0,0 +1,357 @@
+package firewall
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"reflect"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+)
+
+var safeIdentifier = regexp.MustCompile(`^[A-Za-z0-9_.:-]+$`)
+var safeChain = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,15}$`)
+var commonFields = strings.Fields(`ip_version id order chain action protocol src_address dst_address src_port dst_port any_port in_interface out_interface in_interface_list out_interface_list packet_mark connection_mark routing_mark connection_type connection_state connection_nat_state src_address_list dst_address_list src_geoip dst_geoip comment disabled packets bytes content src_mac_address in_bridge_port out_bridge_port ipsec_policy dscp tcp_mss tos packet_size random tcp_flags icmp_options ttl time days limit nth connection_limit src_address_type dst_address_type ip_fragment log log_prefix jump_target address_list address_list_timeout`)
+
+// Capabilities defines the options actually implemented by this Linux backend.
+func Capabilities() map[string]any {
+	actions := map[string][]string{
+		"filter": strings.Fields("accept drop reject return jump log passthrough add-src-to-address-list add-dst-to-address-list"),
+		"nat":    strings.Fields("accept return jump log passthrough masquerade src-nat dst-nat redirect netmap add-src-to-address-list add-dst-to-address-list"),
+		"mangle": strings.Fields("accept drop return jump log passthrough mark-connection mark-routing mark-packet change-mss change-dscp change-ttl set-priority add-src-to-address-list add-dst-to-address-list"),
+	}
+	fields := map[string][]string{}
+	for _, kind := range []string{"filter", "nat", "mangle"} {
+		var model any
+		switch kind {
+		case "filter":
+			model = FilterRule{}
+		case "nat":
+			model = NATRule{}
+		case "mangle":
+			model = MangleRule{}
+		}
+		present := map[string]bool{}
+		typ := reflect.TypeOf(model)
+		for i := 0; i < typ.NumField(); i++ {
+			present[strings.Split(typ.Field(i).Tag.Get("json"), ",")[0]] = true
+		}
+		for _, field := range commonFields {
+			if present[field] {
+				fields[kind] = append(fields[kind], field)
+			}
+		}
+	}
+	fields["filter"] = append(fields["filter"], "reject_with")
+	fields["nat"] = append(fields["nat"], "to_addresses", "to_ports")
+	fields["mangle"] = append(fields["mangle"], strings.Fields("new_connection_mark new_routing_mark new_packet_mark new_mss new_dscp new_ttl new_priority passthrough")...)
+	return map[string]any{"actions": actions, "fields": fields, "time_zone": "UTC", "geoip_ipv6": false}
+}
+func values(rule any) map[string]any {
+	b, _ := json.Marshal(rule)
+	var v map[string]any
+	_ = json.Unmarshal(b, &v)
+	return v
+}
+func text(v map[string]any, k string) string { s, _ := v[k].(string); return s }
+func populated(v any) bool {
+	switch x := v.(type) {
+	case nil:
+		return false
+	case string:
+		return x != ""
+	case bool:
+		return x
+	case float64:
+		return x != 0
+	case []any:
+		return len(x) > 0
+	}
+	return false
+}
+func plain(s string) (string, string) {
+	if strings.HasPrefix(s, "!") {
+		return "! ", strings.TrimSpace(s[1:])
+	}
+	return "", strings.TrimSpace(s)
+}
+func quote(s string) string { return strconv.Quote(s) }
+func intRange(s string, min, max int) error {
+	n, e := strconv.Atoi(s)
+	if e != nil || n < min || n > max {
+		return fmt.Errorf("expected a number from %d to %d", min, max)
+	}
+	return nil
+}
+func portValue(s string) string { return strings.ReplaceAll(s, "-", ":") }
+func validatePorts(s string) error {
+	_, s = plain(s)
+	for _, p := range strings.Split(portValue(s), ",") {
+		bounds := strings.Split(p, ":")
+		if len(bounds) > 2 {
+			return fmt.Errorf("invalid port range")
+		}
+		for _, n := range bounds {
+			if err := intRange(n, 0, 65535); err != nil {
+				return err
+			}
+		}
+		if len(bounds) == 2 {
+			a, _ := strconv.Atoi(bounds[0])
+			b, _ := strconv.Atoi(bounds[1])
+			if a > b {
+				return fmt.Errorf("reversed port range")
+			}
+		}
+	}
+	if len(strings.Split(s, ",")) > 15 {
+		return fmt.Errorf("at most 15 ports per rule")
+	}
+	return nil
+}
+func timeoutSeconds(s string) (int, error) {
+	if s == "" || s == "0" || s == "none-dynamic" || s == "none-static" {
+		return 0, nil
+	}
+	if strings.HasSuffix(s, "d") {
+		n, e := strconv.ParseFloat(strings.TrimSuffix(s, "d"), 64)
+		if e != nil || n <= 0 || n > 24855 {
+			return 0, fmt.Errorf("invalid address list timeout")
+		}
+		return int(n * 86400), nil
+	}
+	d, e := time.ParseDuration(s)
+	if e != nil || d < time.Second || d/time.Second > 2147483 {
+		return 0, fmt.Errorf("timeout must be 0 or a duration such as 30s, 5m, 1h, 1d")
+	}
+	return int(d / time.Second), nil
+}
+func validateRule(kind string, rule any) error {
+	v := values(rule)
+	if disabled, _ := v["disabled"].(bool); disabled {
+		return nil
+	}
+	if family, _ := v["ip_version"].(float64); family != 0 && family != 4 && family != 6 {
+		return fmt.Errorf("IP family must be 4 or 6")
+	}
+	cap := Capabilities()
+	allowed := map[string]bool{}
+	for _, k := range cap["fields"].(map[string][]string)[kind] {
+		allowed[k] = true
+	}
+	for key, value := range v {
+		if s, ok := value.(string); ok && strings.ContainsAny(s, "\n\r\x00") {
+			return fmt.Errorf("%s: control characters are not allowed", key)
+		}
+	}
+	for _, key := range []string{"id", "jump_target"} {
+		s := text(v, key)
+		if s != "" && !safeIdentifier.MatchString(s) {
+			return fmt.Errorf("invalid %s", key)
+		}
+	}
+	chain := text(v, "chain")
+	if chain == "" || !safeChain.MatchString(chain) {
+		return fmt.Errorf("a valid chain is required (1–16 characters)")
+	}
+	if disabled, _ := v["disabled"].(bool); disabled {
+		return nil
+	}
+	for key, value := range v {
+		if !allowed[key] && populated(value) {
+			return fmt.Errorf("%s is not supported by the Linux firewall; clear this legacy setting", key)
+		}
+	}
+	action := text(v, "action")
+	valid := false
+	for _, a := range cap["actions"].(map[string][]string)[kind] {
+		if action == a {
+			valid = true
+		}
+	}
+	if !valid {
+		return fmt.Errorf("action %q is not supported in %s", action, kind)
+	}
+	for _, key := range []string{"src_address", "dst_address"} {
+		_, s := plain(text(v, key))
+		if s != "" {
+			if net.ParseIP(s) == nil {
+				if _, _, err := net.ParseCIDR(s); err != nil {
+					return fmt.Errorf("invalid %s", key)
+				}
+			}
+		}
+	}
+	for _, key := range []string{"src_port", "dst_port", "any_port"} {
+		if s := text(v, key); s != "" {
+			if err := validatePorts(s); err != nil {
+				return fmt.Errorf("%s: %w", key, err)
+			}
+		}
+	}
+	for _, key := range []string{"in_interface", "out_interface", "in_interface_list", "out_interface_list", "in_bridge_port", "out_bridge_port"} {
+		_, s := plain(text(v, key))
+		if s != "" && !safeIdentifier.MatchString(s) {
+			return fmt.Errorf("invalid %s", key)
+		}
+	}
+	for _, pair := range [][2]string{{"in_interface", "in_interface_list"}, {"out_interface", "out_interface_list"}} {
+		if text(v, pair[0]) != "" && text(v, pair[1]) != "" {
+			return fmt.Errorf("choose %s or %s, not both", pair[0], pair[1])
+		}
+	}
+	for _, key := range []string{"in_interface_list", "out_interface_list"} {
+		_, s := plain(text(v, key))
+		if strings.HasPrefix(text(v, key), "!") {
+			return fmt.Errorf("negated interface lists are not supported; use an explicit negated interface")
+		}
+		if s != "" && len(resolveInterfaceList(s)) == 0 {
+			return fmt.Errorf("interface list %q has no members", s)
+		}
+	}
+	for _, key := range []string{"src_address_list", "dst_address_list", "address_list"} {
+		_, s := plain(text(v, key))
+		if s != "" {
+			if err := validateListName(s); err != nil {
+				return err
+			}
+		}
+	}
+	if text(v, "packet_mark") != "" && text(v, "routing_mark") != "" && text(v, "packet_mark") != text(v, "routing_mark") {
+		return fmt.Errorf("packet mark and routing mark use the same Linux mark; choose one criterion")
+	}
+	if action == "jump" && !safeChain.MatchString(text(v, "jump_target")) {
+		return fmt.Errorf("jump target must be a valid custom chain")
+	}
+	if strings.HasPrefix(action, "add-") && text(v, "address_list") == "" {
+		return fmt.Errorf("address list is required")
+	}
+	if _, err := timeoutSeconds(text(v, "address_list_timeout")); err != nil {
+		return err
+	}
+	if kind == "nat" {
+		if action == "netmap" && text(v, "to_ports") != "" {
+			return fmt.Errorf("netmap translates addresses only; use src-nat/dst-nat to translate ports")
+		}
+		if (action == "src-nat" || action == "dst-nat" || action == "netmap") && text(v, "to_addresses") == "" {
+			return fmt.Errorf("to-addresses is required for %s", action)
+		}
+		if s := text(v, "to_addresses"); s != "" {
+			if action == "netmap" {
+				if _, _, err := net.ParseCIDR(s); err != nil {
+					return fmt.Errorf("netmap requires a destination CIDR")
+				}
+			} else {
+				for _, ip := range strings.Split(s, "-") {
+					if net.ParseIP(ip) == nil {
+						return fmt.Errorf("invalid to-addresses")
+					}
+				}
+			}
+		}
+		if s := text(v, "to_ports"); s != "" {
+			if err := validatePorts(s); err != nil {
+				return fmt.Errorf("to-ports: %w", err)
+			}
+			_, p := plain(text(v, "protocol"))
+			if p != "tcp" && p != "udp" {
+				return fmt.Errorf("to-ports requires TCP or UDP")
+			}
+		}
+	}
+	if kind == "mangle" {
+		required := map[string]string{"mark-connection": "new_connection_mark", "mark-routing": "new_routing_mark", "mark-packet": "new_packet_mark", "change-mss": "new_mss", "change-ttl": "new_ttl", "set-priority": "new_priority"}
+		if key := required[action]; key != "" && text(v, key) == "" {
+			return fmt.Errorf("%s is required", key)
+		}
+		if action == "change-dscp" {
+			n, _ := v["new_dscp"].(float64)
+			if n < 0 || n > 63 {
+				return fmt.Errorf("DSCP must be 0–63")
+			}
+		}
+		if action == "change-mss" {
+			_, p := plain(text(v, "protocol"))
+			if p != "" && p != "all" && p != "tcp" {
+				return fmt.Errorf("change-mss requires TCP")
+			}
+			s := text(v, "new_mss")
+			if s != "clamp-to-pmtu" {
+				if err := intRange(s, 1, 65535); err != nil {
+					return fmt.Errorf("new-mss: %w", err)
+				}
+			}
+		}
+		if action == "set-priority" {
+			if chain != "postrouting" {
+				return fmt.Errorf("set-priority requires the postrouting chain")
+			}
+			if err := intRange(text(v, "new_priority"), 0, 65535); err != nil {
+				return err
+			}
+		}
+	}
+	for _, key := range []string{"connection_state", "connection_nat_state"} {
+		if items, ok := v[key].([]any); ok {
+			for _, item := range items {
+				state, _ := item.(string)
+				allowed := map[string]bool{"new": true, "established": true, "related": true, "invalid": true, "untracked": true}
+				if key == "connection_nat_state" {
+					allowed = map[string]bool{"srcnat": true, "dstnat": true, "snat": true, "dnat": true}
+				}
+				if !allowed[strings.ToLower(state)] {
+					return fmt.Errorf("invalid %s", key)
+				}
+			}
+		}
+	}
+	if action == "netmap" && v["ip_version"] == float64(6) {
+		return fmt.Errorf("netmap is IPv4-only")
+	}
+	for _, key := range []string{"src_geoip", "dst_geoip"} {
+		if s := text(v, key); s != "" {
+			_, s = plain(s)
+			s = strings.TrimPrefix(s, "geo:")
+			s = strings.TrimPrefix(s, "geo_")
+			s = strings.ToUpper(s)
+			if !regexp.MustCompile(`^[A-Z]{2}$`).MatchString(s) && s != "EU" && s != "BALKANS" && s != "BLOCKLIST" && s != "HIGHRISK" {
+				return fmt.Errorf("invalid GeoIP country or preset")
+			}
+			if v["ip_version"] == float64(6) {
+				return fmt.Errorf("GeoIP is IPv4-only")
+			}
+		}
+	}
+	if action == "reject" {
+		if s := text(v, "reject_with"); s != "" {
+			allowed := strings.Fields("icmp-net-unreachable icmp-network-unreachable icmp-host-unreachable icmp-proto-unreachable icmp-port-unreachable icmp-net-prohibited icmp-host-prohibited icmp-admin-prohibited tcp-reset")
+			if v["ip_version"] == float64(6) {
+				allowed = strings.Fields("icmp6-no-route icmp6-adm-prohibited icmp6-addr-unreachable icmp6-port-unreachable tcp-reset")
+			}
+			valid := false
+			for _, option := range allowed {
+				valid = valid || s == option
+			}
+			if !valid {
+				return fmt.Errorf("reject type is not supported for this IP family")
+			}
+		}
+	}
+
+	return validateAdvanced(v)
+}
+func validateListName(s string) error {
+	if strings.TrimSpace(s) != s || len(s) > 128 || s == "" || strings.HasPrefix(s, "geo:") || strings.HasPrefix(s, "geo_") || strings.ContainsAny(s, "\n\r\x00") {
+		return fmt.Errorf("invalid or reserved address list name %q", s)
+	}
+	return nil
+}
+
+func ValidateRule(kind string, rule any) error {
+	if kind != "filter" && kind != "nat" && kind != "mangle" {
+		return fmt.Errorf("unknown firewall rule kind")
+	}
+	return validateRule(kind, rule)
+}

+ 157 - 92
internal/web/static/app.js

@@ -3788,12 +3788,18 @@ async function loadFirewall(populateDropdowns = true) {
     ]);
 
     if (summaryRes.ok) state.fwSummary = await summaryRes.json();
+    const applyError = document.getElementById('fw-apply-error');
+    if (applyError) {
+      applyError.textContent = state.fwSummary?.last_error ? 'Firewall configuration was not applied: ' + state.fwSummary.last_error : '';
+      applyError.classList.toggle('hidden', !state.fwSummary?.last_error);
+    }
     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();
     if (geoStatusRes.ok) state.geoStatus = await geoStatusRes.json();
     if (geoCountriesRes.ok) state.geoCountries = await geoCountriesRes.json();
+    if (applyError) { const messages=[state.fwSummary?.last_error, state.fwSummary?.connections_error, state.geoStatus?.last_error && 'GeoIP: '+state.geoStatus.last_error].filter(Boolean); applyError.textContent=messages.join(' · '); applyError.classList.toggle('hidden', !messages.length); }
 
     // Badges
     const bFilter = document.getElementById('fw-badge-filter');
@@ -3810,7 +3816,7 @@ async function loadFirewall(populateDropdowns = true) {
     const sigFilter = (state.filterRules || []).map(r => JSON.stringify({...r, packets: 0, bytes: 0})).join(';');
     const sigNat = (state.natRules || []).map(r => JSON.stringify({...r, packets: 0, bytes: 0})).join(';');
     const sigMangle = (state.mangleRules || []).map(r => JSON.stringify({...r, packets: 0, bytes: 0})).join(';');
-    const sigAddr = (state.addressLists || []).map(a => `${a.id}:${a.name}:${a.address}:${a.disabled}`).join(';');
+    const sigAddr = (state.addressLists || []).map(a => JSON.stringify(a)).join(';');
 
     const tbodyFilter = document.getElementById('filter-table-body');
     if (tbodyFilter) {
@@ -3887,7 +3893,8 @@ function renderFilterRules() {
   }
   empty?.classList.add('hidden');
 
-  rules.forEach((r, idx) => {
+  rules.forEach((raw, idx) => {
+    const r = safeFirewallRow(raw);
     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(' ');
@@ -3967,7 +3974,8 @@ function populateAddressListSelect(selectId, currentVal = '') {
   sel.innerHTML = '<option value="">(None)</option>';
 
   const cleanVal = (currentVal || '').replace(/^!/, '');
-  const customNames = Array.from(new Set((state.addressLists || []).map(a => a.name))).sort();
+  const ruleLists = [...(state.filterRules||[]), ...(state.natRules||[]), ...(state.mangleRules||[])].flatMap(r => [r.src_address_list,r.dst_address_list,r.address_list]).filter(Boolean).map(n => n.replace(/^!/, '')).filter(n => !n.startsWith('geo:') && !n.startsWith('geo_'));
+  const customNames = Array.from(new Set([...(state.addressLists || []).map(a => a.name), ...ruleLists])).sort();
   customNames.forEach(name => {
     const count = (state.addressLists || []).filter(a => a.name === name).length;
     const opt = document.createElement('option');
@@ -3977,7 +3985,7 @@ function populateAddressListSelect(selectId, currentVal = '') {
     sel.appendChild(opt);
   });
 
-  if (cleanVal && !sel.querySelector(`option[value="${cleanVal}"]`)) {
+  if (cleanVal && !Array.from(sel.options).some(option => option.value === cleanVal)) {
     const opt = document.createElement('option');
     opt.value = cleanVal;
     opt.innerText = cleanVal;
@@ -4108,6 +4116,7 @@ function toggleFwField(fieldKey, expand) {
 }
 
 function setFwFieldVal(fieldKey, value, hasInvert = false) {
+  if (fieldKey === 'ipsec' && typeof value === 'string') value = value.replace(':', ',');
   if (value !== undefined && value !== null && value !== '' && value !== 'all') {
     toggleFwField(fieldKey, true);
     let strVal = String(value);
@@ -4116,9 +4125,10 @@ function setFwFieldVal(fieldKey, value, hasInvert = false) {
       if (inv) strVal = strVal.slice(1);
       const invEl = document.getElementById(`fw-inv-${fieldKey}`);
       if (invEl) invEl.checked = inv;
+      else if (inv) strVal = '!'+strVal;
     }
     const valEl = document.getElementById(`fw-val-${fieldKey}`);
-    if (valEl) valEl.value = strVal;
+    if (valEl) { if (valEl.tagName === 'SELECT' && !Array.from(valEl.options).some(o => o.value === strVal)) { const option=document.createElement('option'); option.value=strVal; option.textContent=strVal+' (saved)'; valEl.appendChild(option); } valEl.value = strVal; }
   } else {
     toggleFwField(fieldKey, false);
   }
@@ -4152,7 +4162,9 @@ function toggleMaximizeFilterModal() {
 }
 
 function openAddFilterModal() {
+  const tos = document.getElementById('fw-val-tos'); if (tos) tos.value = '';
   switchFwRuleTab('general');
+  setFirewallDays('fw', '');
   const badge = document.getElementById('fw-modal-badge');
   if (badge) badge.innerText = 'New...';
   document.getElementById('filter-modal-id').value = '';
@@ -4182,13 +4194,15 @@ function openAddFilterModal() {
   ];
   allFieldKeys.forEach(k => toggleFwField(k, false));
 
-  resetCurrentRuleCounters();
+  resetRuleCounterDisplay();
   toggleFilterActionFields();
 
+  refreshFirewallRejectTypes();
   document.getElementById('filter-modal').classList.remove('hidden');
 }
 
 function openEditFilterModal(id) {
+  id = decodeURIComponent(id);
   const r = (state.filterRules || []).find(rule => rule.id === id);
   if (!r) return;
 
@@ -4201,7 +4215,7 @@ function openEditFilterModal(id) {
   document.getElementById('filter-modal-enabled').checked = !r.disabled;
   document.getElementById('filter-modal-comment').value = r.comment || '';
   document.getElementById('filter-modal-chain').value = r.chain || 'forward';
-  document.getElementById('filter-modal-action').value = r.action || 'accept';
+  setFirewallActionSelection('filter', r.action || 'accept');
   document.getElementById('filter-modal-log').checked = !!r.log;
 
   populateInterfaceSelect('fw-val-iniface', (r.in_interface || '').replace(/^!/, ''));
@@ -4272,10 +4286,7 @@ function openEditFilterModal(id) {
   setFwFieldVal('priority', r.priority, false);
   setFwFieldVal('dscp', r.dscp, false);
   setFwFieldVal('tcpmss', r.tcp_mss, false);
-  if (r.tos) {
-    const tosEl = document.getElementById('fw-val-tos');
-    if (tosEl) tosEl.value = r.tos;
-  }
+  { const tosEl = document.getElementById('fw-val-tos'); if (tosEl) tosEl.value = r.tos || ''; }
   setFwFieldVal('pktsize', r.packet_size, false);
   setFwFieldVal('random', r.random, false);
   setFwFieldVal('tcpflags', r.tcp_flags, false);
@@ -4289,7 +4300,7 @@ function openEditFilterModal(id) {
     toggleFwField('days', true);
     ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'].forEach(d => {
       const chk = document.getElementById(`fw-day-${d}`);
-      if (chk) chk.checked = r.days.includes(d);
+      if (chk) chk.checked = r.days.toLowerCase().includes(d);
     });
   } else {
     toggleFwField('days', false);
@@ -4331,6 +4342,7 @@ function openEditFilterModal(id) {
   renderFwSparkline('fw-stat-pktrate-svg', state.fwStatPktHistory, 'p/s', 'fw-stat-legend-pktrate');
 
   toggleFilterActionFields();
+  refreshFirewallRejectTypes();
   document.getElementById('filter-modal').classList.remove('hidden');
 }
 
@@ -4379,7 +4391,7 @@ function copyFilterRule() {
   showToast('Rule Cloned', 'Ready to save as a new rule copy', 'info');
 }
 
-function resetCurrentRuleCounters() {
+function resetRuleCounterDisplay() {
   state.fwStatRateHistory = new Array(30).fill(0);
   state.fwStatPktHistory = new Array(30).fill(0);
   state.fwLastBytes = 0;
@@ -4402,7 +4414,7 @@ async function resetAllFwCounters() {
   try {
     const res = await fetch(`${API_BASE}/firewall/reset-counters`, { method: 'POST' });
     if (res.ok) {
-      resetCurrentRuleCounters();
+      resetRuleCounterDisplay();
       showToast('All Counters Reset', 'All firewall counters zeroed', 'success');
       loadFirewall(false);
     }
@@ -4413,6 +4425,8 @@ async function resetAllFwCounters() {
 
 async function applyFilterRule(closeAfter = false) {
   const id = document.getElementById('filter-modal-id').value;
+  if (!reviewLegacyFirewallSettings('filter', id)) return;
+  let selectedDays; try { selectedDays = getFirewallDays('fw'); } catch(error) { showToast('Error', error.message, 'error'); return; }
   const chain = document.getElementById('filter-modal-chain').value;
   const action = document.getElementById('filter-modal-action').value;
   const comment = document.getElementById('filter-modal-comment').value.trim();
@@ -4483,20 +4497,11 @@ async function applyFilterRule(closeAfter = false) {
     disabled: !enabled,
 
     // Advanced tab
-    layer7_protocol: getFwFieldVal('layer7', false),
     content: getFwFieldVal('content', false),
-    connection_bytes: getFwFieldVal('connbytes', false),
-    connection_rate: getFwFieldVal('connrate', false),
-    pcc: getFwFieldVal('pcc', false),
     src_mac_address: getFwFieldVal('srcmac', true),
     out_bridge_port: getFwFieldVal('outbridgeport', false),
     in_bridge_port: getFwFieldVal('inbridgeport', false),
-    in_bridge_port_list: getFwFieldVal('inbridgeportlist', false),
-    out_bridge_port_list: getFwFieldVal('outbridgeportlist', false),
     ipsec_policy: getFwFieldVal('ipsec', false),
-    tls_host: getFwFieldVal('tlshost', false),
-    ingress_priority: getFwFieldVal('ingresspriority', false),
-    priority: getFwFieldVal('priority', false),
     dscp: getFwFieldVal('dscp', false),
     tcp_mss: getFwFieldVal('tcpmss', false),
     tos: document.getElementById('fw-val-tos')?.value || '',
@@ -4504,14 +4509,13 @@ async function applyFilterRule(closeAfter = false) {
     random: getFwFieldVal('random', false),
     tcp_flags: getFwFieldVal('tcpflags', false),
     icmp_options: getFwFieldVal('icmpoptions', false),
-    ipv4_options: getFwFieldVal('ipv4options', false),
     ttl: getFwFieldVal('ttl', false),
 
+    days: selectedDays,
+
     // Extra tab
     time: getFwFieldVal('time', false),
     limit: getFwFieldVal('limit', false),
-    dst_limit: getFwFieldVal('dstlimit', false),
-    psd: getFwFieldVal('psd', false),
 
     // Action tab
     reject_with: action === 'reject' ? (document.getElementById('filter-modal-rejectwith')?.value || '') : '',
@@ -4590,8 +4594,8 @@ async function pollRuleStats() {
     const curBytes = r.bytes || 0;
     const curPackets = r.packets || 0;
 
-    const deltaBytes = Math.max(0, curBytes - (state.fwLastBytes || curBytes));
-    const deltaPkts = Math.max(0, curPackets - (state.fwLastPackets || curPackets));
+    const deltaBytes = Math.max(0, curBytes - (state.fwLastBytes ?? curBytes));
+    const deltaPkts = Math.max(0, curPackets - (state.fwLastPackets ?? curPackets));
 
     state.fwLastBytes = curBytes;
     state.fwLastPackets = curPackets;
@@ -4679,6 +4683,7 @@ function renderFwSparkline(svgId, dataArray, unit, legendId) {
 }
 
 async function moveFilterRule(id, dir) {
+  id = decodeURIComponent(id);
   try {
     const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}/move`, {
       method: 'POST',
@@ -4692,6 +4697,7 @@ async function moveFilterRule(id, dir) {
 }
 
 async function toggleFilterRuleState(id, disabled) {
+  id = decodeURIComponent(id);
   try {
     const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}/state`, {
       method: 'POST',
@@ -4705,6 +4711,7 @@ async function toggleFilterRuleState(id, disabled) {
 }
 
 async function deleteFilterRule(id) {
+  id = decodeURIComponent(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' });
@@ -4734,7 +4741,8 @@ function renderNATRules() {
   }
   empty?.classList.add('hidden');
 
-  rules.forEach((r, idx) => {
+  rules.forEach((raw, idx) => {
+    const r = safeFirewallRow(raw);
     const tr = document.createElement('tr');
     tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`;
     
@@ -4840,6 +4848,7 @@ function toggleNATField(fieldKey, expand) {
 }
 
 function setNATFieldVal(fieldKey, value, hasInvert = false) {
+  if (fieldKey === 'ipsec' && typeof value === 'string') value = value.replace(':', ',');
   if (value !== undefined && value !== null && value !== '' && value !== 'all') {
     toggleNATField(fieldKey, true);
     let strVal = String(value);
@@ -4848,9 +4857,10 @@ function setNATFieldVal(fieldKey, value, hasInvert = false) {
       if (inv) strVal = strVal.slice(1);
       const invEl = document.getElementById(`nat-inv-${fieldKey}`);
       if (invEl) invEl.checked = inv;
+      else if (inv) strVal = '!'+strVal;
     }
     const valEl = document.getElementById(`nat-val-${fieldKey}`);
-    if (valEl) valEl.value = strVal;
+    if (valEl) { if (valEl.tagName === 'SELECT' && !Array.from(valEl.options).some(o => o.value === strVal)) { const option=document.createElement('option'); option.value=strVal; option.textContent=strVal+' (saved)'; valEl.appendChild(option); } valEl.value = strVal; }
   } else {
     toggleNATField(fieldKey, false);
   }
@@ -4909,7 +4919,7 @@ function toggleNATActionFields() {
   if (jumpBox) jumpBox.classList.add('hidden');
   if (addrBox) addrBox.classList.add('hidden');
 
-  if (['src-nat', 'dst-nat', 'netmap', 'same', 'socksify', 'endpoint-independent-nat'].includes(act)) {
+  if (['src-nat', 'dst-nat', 'netmap'].includes(act)) {
     if (rowAddr) rowAddr.classList.remove('hidden');
     if (rowPort) rowPort.classList.remove('hidden');
   } else if (act === 'redirect' || act === 'masquerade') {
@@ -4933,7 +4943,9 @@ function toggleNATLogPrefix() {
 }
 
 function openAddNATModal() {
+  const tos = document.getElementById('nat-val-tos'); if (tos) tos.value = '';
   switchNATRuleTab('general');
+  setFirewallDays('nat', '');
   const badge = document.getElementById('nat-modal-badge');
   if (badge) badge.innerText = 'New...';
   document.getElementById('nat-modal-id').value = '';
@@ -4965,16 +4977,18 @@ function openAddNATModal() {
   ];
   allFieldKeys.forEach(k => toggleNATField(k, false));
 
-  resetCurrentNATRuleCounters();
+  resetNATCounterDisplay();
   toggleNATActionFields();
 
   document.getElementById('nat-modal').classList.remove('hidden');
 }
 
 function openEditNATModal(id) {
+  id = decodeURIComponent(id);
   const r = (state.natRules || []).find(rule => rule.id === id);
   if (!r) return;
 
+  setFirewallDays('nat', r.days || '');
   switchNATRuleTab('general');
   const badge = document.getElementById('nat-modal-badge');
   if (badge) badge.innerText = `#${r.order != null ? r.order : r.id}`;
@@ -4984,7 +4998,7 @@ function openEditNATModal(id) {
   document.getElementById('nat-modal-enabled').checked = !r.disabled;
   document.getElementById('nat-modal-comment').value = r.comment || '';
   document.getElementById('nat-modal-chain').value = r.chain || 'srcnat';
-  document.getElementById('nat-modal-action').value = r.action || 'masquerade';
+  setFirewallActionSelection('nat', r.action || 'accept');
   document.getElementById('nat-modal-toaddr').value = r.to_addresses || '';
   document.getElementById('nat-modal-toport').value = r.to_ports || '';
   document.getElementById('nat-modal-log').checked = !!r.log;
@@ -5035,10 +5049,7 @@ function openEditNATModal(id) {
   setNATFieldVal('priority', r.priority, false);
   setNATFieldVal('dscp', r.dscp, false);
   setNATFieldVal('tcpmss', r.tcp_mss, false);
-  if (r.tos) {
-    const tosEl = document.getElementById('nat-val-tos');
-    if (tosEl) tosEl.value = r.tos;
-  }
+  { const tosEl = document.getElementById('nat-val-tos'); if (tosEl) tosEl.value = r.tos || ''; }
   setNATFieldVal('pktsize', r.packet_size, false);
   setNATFieldVal('random', r.random, false);
   setNATFieldVal('icmpoptions', r.icmp_options, false);
@@ -5051,7 +5062,7 @@ function openEditNATModal(id) {
     toggleNATField('days', true);
     ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'].forEach(d => {
       const chk = document.getElementById(`nat-day-${d}`);
-      if (chk) chk.checked = r.days.includes(d);
+      if (chk) chk.checked = r.days.toLowerCase().includes(d);
     });
   } else {
     toggleNATField('days', false);
@@ -5104,7 +5115,7 @@ function copyNATRule() {
   showToast('Rule Cloned', 'Ready to save as a new NAT rule copy', 'info');
 }
 
-function resetCurrentNATRuleCounters() {
+function resetNATCounterDisplay() {
   state.natStatRateHistory = new Array(30).fill(0);
   state.natStatPktHistory = new Array(30).fill(0);
   state.natLastBytes = 0;
@@ -5125,6 +5136,8 @@ function resetCurrentNATRuleCounters() {
 
 async function applyNATRule(closeAfter = false) {
   const id = document.getElementById('nat-modal-id').value;
+  if (!reviewLegacyFirewallSettings('nat', id)) return;
+  let selectedDays; try { selectedDays = getFirewallDays('nat'); } catch(error) { showToast('Error', error.message, 'error'); return; }
   const chain = document.getElementById('nat-modal-chain').value;
   const action = document.getElementById('nat-modal-action').value;
   const comment = document.getElementById('nat-modal-comment').value.trim();
@@ -5149,8 +5162,8 @@ async function applyNATRule(closeAfter = false) {
   const routingmark = getNATFieldVal('routingmark', true);
   const conntype = getNATFieldVal('conntype', false);
 
-  const usesAddr = ['src-nat', 'dst-nat', 'netmap', 'same', 'socksify', 'endpoint-independent-nat'].includes(action);
-  const usesPort = ['src-nat', 'dst-nat', 'netmap', 'same', 'socksify', 'endpoint-independent-nat', 'redirect', 'masquerade'].includes(action);
+  const usesAddr = ['src-nat', 'dst-nat', 'netmap'].includes(action);
+  const usesPort = ['src-nat', 'dst-nat', 'redirect', 'masquerade'].includes(action);
 
   const payload = {
     ip_version: Number(document.getElementById('nat-modal-family').value),
@@ -5181,33 +5194,24 @@ async function applyNATRule(closeAfter = false) {
     disabled: !enabled,
 
     // Advanced tab
-    layer7_protocol: getNATFieldVal('layer7', false),
     content: getNATFieldVal('content', false),
-    connection_bytes: getNATFieldVal('connbytes', false),
-    connection_rate: getNATFieldVal('connrate', false),
-    pcc: getNATFieldVal('pcc', false),
     src_mac_address: getNATFieldVal('srcmac', true),
     out_bridge_port: getNATFieldVal('outbridgeport', false),
     in_bridge_port: getNATFieldVal('inbridgeport', false),
-    in_bridge_port_list: getNATFieldVal('inbridgeportlist', false),
-    out_bridge_port_list: getNATFieldVal('outbridgeportlist', false),
     ipsec_policy: getNATFieldVal('ipsec', false),
-    ingress_priority: getNATFieldVal('ingresspriority', false),
-    priority: getNATFieldVal('priority', false),
     dscp: getNATFieldVal('dscp', false),
     tcp_mss: getNATFieldVal('tcpmss', false),
     tos: document.getElementById('nat-val-tos')?.value || '',
     packet_size: getNATFieldVal('pktsize', false),
     random: getNATFieldVal('random', false),
     icmp_options: getNATFieldVal('icmpoptions', false),
-    ipv4_options: getNATFieldVal('ipv4options', false),
     ttl: getNATFieldVal('ttl', false),
 
+    days: selectedDays,
+
     // Extra tab
     time: getNATFieldVal('time', false),
     limit: getNATFieldVal('limit', false),
-    dst_limit: getNATFieldVal('dstlimit', false),
-    psd: getNATFieldVal('psd', false),
 
     // Action tab
     jump_target: action === 'jump' ? (document.getElementById('nat-modal-jumptarget')?.value || '') : '',
@@ -5285,8 +5289,8 @@ async function pollNATRuleStats() {
     const curBytes = r.bytes || 0;
     const curPackets = r.packets || 0;
 
-    const deltaBytes = Math.max(0, curBytes - (state.natLastBytes || curBytes));
-    const deltaPkts = Math.max(0, curPackets - (state.natLastPackets || curPackets));
+    const deltaBytes = Math.max(0, curBytes - (state.natLastBytes ?? curBytes));
+    const deltaPkts = Math.max(0, curPackets - (state.natLastPackets ?? curPackets));
 
     state.natLastBytes = curBytes;
     state.natLastPackets = curPackets;
@@ -5321,6 +5325,7 @@ async function pollNATRuleStats() {
 }
 
 async function moveNATRule(id, dir) {
+  id = decodeURIComponent(id);
   try {
     const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}/move`, {
       method: 'POST',
@@ -5334,6 +5339,7 @@ async function moveNATRule(id, dir) {
 }
 
 async function toggleNATRuleState(id, disabled) {
+  id = decodeURIComponent(id);
   try {
     const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}/state`, {
       method: 'POST',
@@ -5347,6 +5353,7 @@ async function toggleNATRuleState(id, disabled) {
 }
 
 async function deleteNATRule(id) {
+  id = decodeURIComponent(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' });
@@ -5376,7 +5383,8 @@ function renderMangleRules() {
   }
   empty?.classList.add('hidden');
 
-  rules.forEach((r, idx) => {
+  rules.forEach((raw, idx) => {
+    const r = safeFirewallRow(raw);
     const tr = document.createElement('tr');
     tr.className = `hover:bg-slate-800/40 transition text-xs ${r.disabled ? 'opacity-50' : ''}`;
     let markVal = '<span class="text-slate-500">-</span>';
@@ -5384,7 +5392,7 @@ function renderMangleRules() {
     else if (r.new_routing_mark) markVal = `<span class="text-amber-300 font-mono font-bold">${r.new_routing_mark}</span>`;
     else if (r.new_packet_mark) markVal = `<span class="text-emerald-300 font-mono font-bold">${r.new_packet_mark}</span>`;
     else if (r.new_mss) markVal = `<span class="text-sky-300 font-mono font-bold">mss:${r.new_mss}</span>`;
-    else if (r.new_dscp) markVal = `<span class="text-purple-300 font-mono font-bold">dscp:${r.new_dscp}</span>`;
+    else if (r.action === 'change-dscp') markVal = `<span class="text-purple-300 font-mono font-bold">dscp:${r.new_dscp}</span>`;
     else if (r.new_ttl) markVal = `<span class="text-blue-300 font-mono font-bold">ttl:${r.new_ttl}</span>`;
     else if (r.new_priority) markVal = `<span class="text-indigo-300 font-mono font-bold">prio:${r.new_priority}</span>`;
     else if (r.route) markVal = `<span class="text-orange-300 font-mono font-bold">route:${r.route}</span>`;
@@ -5483,6 +5491,7 @@ function toggleMangleField(fieldKey, expand) {
 }
 
 function setMangleFieldVal(fieldKey, value, hasInvert = false) {
+  if (fieldKey === 'ipsec' && typeof value === 'string') value = value.replace(':', ',');
   if (value !== undefined && value !== null && value !== '' && value !== 'all') {
     toggleMangleField(fieldKey, true);
     let strVal = String(value);
@@ -5491,9 +5500,10 @@ function setMangleFieldVal(fieldKey, value, hasInvert = false) {
       if (inv) strVal = strVal.slice(1);
       const invEl = document.getElementById(`mangle-inv-${fieldKey}`);
       if (invEl) invEl.checked = inv;
+      else if (inv) strVal = '!'+strVal;
     }
     const valEl = document.getElementById(`mangle-val-${fieldKey}`);
-    if (valEl) valEl.value = strVal;
+    if (valEl) { if (valEl.tagName === 'SELECT' && !Array.from(valEl.options).some(o => o.value === strVal)) { const option=document.createElement('option'); option.value=strVal; option.textContent=strVal+' (saved)'; valEl.appendChild(option); } valEl.value = strVal; }
   } else {
     toggleMangleField(fieldKey, false);
   }
@@ -5600,6 +5610,7 @@ const allMangleFieldKeys = [
 ];
 
 function openAddMangleModal() {
+  const tos = document.getElementById('mangle-val-tos'); if (tos) tos.value = '';
   switchMangleRuleTab('general');
   const badge = document.getElementById('mangle-modal-badge');
   if (badge) badge.innerText = 'New...';
@@ -5634,13 +5645,14 @@ function openAddMangleModal() {
 
   allMangleFieldKeys.forEach(k => toggleMangleField(k, false));
 
-  resetCurrentMangleRuleCounters();
+  resetMangleCounterDisplay();
   toggleMangleActionFields();
 
   document.getElementById('mangle-modal').classList.remove('hidden');
 }
 
 function openEditMangleModal(id) {
+  id = decodeURIComponent(id);
   const r = (state.mangleRules || []).find(rule => rule.id === id);
   if (!r) return;
 
@@ -5653,7 +5665,7 @@ function openEditMangleModal(id) {
   document.getElementById('mangle-modal-enabled').checked = !r.disabled;
   document.getElementById('mangle-modal-comment').value = r.comment || '';
   document.getElementById('mangle-modal-chain').value = r.chain || 'prerouting';
-  document.getElementById('mangle-modal-action').value = r.action || 'mark-connection';
+  setFirewallActionSelection('mangle', r.action || 'accept');
   
   if (document.getElementById('mangle-modal-newconnmark')) document.getElementById('mangle-modal-newconnmark').value = r.new_connection_mark || '';
   if (document.getElementById('mangle-modal-newroutingmark')) document.getElementById('mangle-modal-newroutingmark').value = r.new_routing_mark || '';
@@ -5741,10 +5753,7 @@ function openEditMangleModal(id) {
   setMangleFieldVal('priority', r.priority, false);
   setMangleFieldVal('dscp', r.dscp, false);
   setMangleFieldVal('tcpmss', r.tcp_mss, false);
-  if (r.tos) {
-    const tosEl = document.getElementById('mangle-val-tos');
-    if (tosEl) tosEl.value = r.tos;
-  }
+  { const tosEl = document.getElementById('mangle-val-tos'); if (tosEl) tosEl.value = r.tos || ''; }
   setMangleFieldVal('pktsize', r.packet_size, false);
   setMangleFieldVal('random', r.random, false);
   setMangleFieldVal('tcpflags', r.tcp_flags, false);
@@ -5808,7 +5817,7 @@ function copyMangleRule() {
   showToast('Rule Cloned', 'Ready to save as a new Mangle rule copy', 'info');
 }
 
-function resetCurrentMangleRuleCounters() {
+function resetMangleCounterDisplay() {
   state.mangleStatRateHistory = new Array(30).fill(0);
   state.mangleStatPktHistory = new Array(30).fill(0);
   state.mangleLastBytes = 0;
@@ -5829,6 +5838,7 @@ function resetCurrentMangleRuleCounters() {
 
 async function applyMangleRule(closeAfter = false) {
   const id = document.getElementById('mangle-modal-id').value;
+  if (!reviewLegacyFirewallSettings('mangle', id)) return;
   const chain = document.getElementById('mangle-modal-chain').value;
   const action = document.getElementById('mangle-modal-action').value;
   const comment = document.getElementById('mangle-modal-comment').value.trim();
@@ -5898,20 +5908,11 @@ async function applyMangleRule(closeAfter = false) {
     disabled: !enabled,
 
     // Advanced tab
-    layer7_protocol: getMangleFieldVal('layer7', false),
     content: getMangleFieldVal('content', false),
-    connection_bytes: getMangleFieldVal('connbytes', false),
-    connection_rate: getMangleFieldVal('connrate', false),
-    pcc: getMangleFieldVal('pcc', false),
     src_mac_address: getMangleFieldVal('srcmac', true),
     out_bridge_port: getMangleFieldVal('outbridgeport', false),
     in_bridge_port: getMangleFieldVal('inbridgeport', false),
-    in_bridge_port_list: getMangleFieldVal('inbridgeportlist', false),
-    out_bridge_port_list: getMangleFieldVal('outbridgeportlist', false),
     ipsec_policy: getMangleFieldVal('ipsec', false),
-    tls_host: getMangleFieldVal('tlshost', false),
-    ingress_priority: getMangleFieldVal('ingresspriority', false),
-    priority: getMangleFieldVal('priority', false),
     dscp: getMangleFieldVal('dscp', false),
     tcp_mss: getMangleFieldVal('tcpmss', false),
     tos: document.getElementById('mangle-val-tos')?.value || '',
@@ -5919,7 +5920,6 @@ async function applyMangleRule(closeAfter = false) {
     random: getMangleFieldVal('random', false),
     tcp_flags: getMangleFieldVal('tcpflags', false),
     icmp_options: getMangleFieldVal('icmpoptions', false),
-    ipv4_options: getMangleFieldVal('ipv4options', false),
     ttl: getMangleFieldVal('ttl', false),
 
     // Extra tab
@@ -5927,12 +5927,9 @@ async function applyMangleRule(closeAfter = false) {
     connection_limit: getMangleFieldVal('connlimit', false),
     src_address_type: getMangleFieldVal('srcaddrtype', false),
     dst_address_type: getMangleFieldVal('dstaddrtype', false),
-    hotspot: getMangleFieldVal('hotspot', false),
     ip_fragment: getMangleFieldVal('ipfragment', false),
     limit: getMangleFieldVal('limit', false),
-    dst_limit: getMangleFieldVal('dstlimit', false),
     time: getMangleFieldVal('time', false),
-    psd: getMangleFieldVal('psd', false),
 
     // Action tab
     new_connection_mark: action === 'mark-connection' ? (document.getElementById('mangle-modal-newconnmark')?.value.trim() || '') : '',
@@ -5942,8 +5939,6 @@ async function applyMangleRule(closeAfter = false) {
     new_dscp: action === 'change-dscp' ? parseInt(document.getElementById('mangle-modal-newdscp')?.value || '0', 10) : 0,
     new_ttl: action === 'change-ttl' ? (document.getElementById('mangle-modal-newttl')?.value.trim() || '') : '',
     new_priority: action === 'set-priority' ? (document.getElementById('mangle-modal-newpriority')?.value.trim() || '') : '',
-    route: action === 'route' ? (document.getElementById('mangle-modal-route')?.value.trim() || '') : '',
-    sniff_target: (action === 'sniff-pc' || action === 'sniff-tzsp') ? (document.getElementById('mangle-modal-sniff')?.value.trim() || '') : '',
     passthrough: document.getElementById('mangle-modal-passthrough')?.checked ?? true,
     jump_target: action === 'jump' ? (document.getElementById('mangle-modal-jumptarget')?.value || '') : '',
     address_list: action.includes('address') ? (document.getElementById('mangle-modal-action-addrlist')?.value || '') : '',
@@ -6020,8 +6015,8 @@ async function pollMangleRuleStats() {
     const curBytes = r.bytes || 0;
     const curPackets = r.packets || 0;
 
-    const deltaBytes = Math.max(0, curBytes - (state.mangleLastBytes || curBytes));
-    const deltaPkts = Math.max(0, curPackets - (state.mangleLastPackets || curPackets));
+    const deltaBytes = Math.max(0, curBytes - (state.mangleLastBytes ?? curBytes));
+    const deltaPkts = Math.max(0, curPackets - (state.mangleLastPackets ?? curPackets));
 
     state.mangleLastBytes = curBytes;
     state.mangleLastPackets = curPackets;
@@ -6056,6 +6051,7 @@ async function pollMangleRuleStats() {
 }
 
 async function moveMangleRule(id, dir) {
+  id = decodeURIComponent(id);
   try {
     const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}/move`, {
       method: 'POST',
@@ -6069,6 +6065,7 @@ async function moveMangleRule(id, dir) {
 }
 
 async function toggleMangleRuleState(id, disabled) {
+  id = decodeURIComponent(id);
   try {
     const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}/state`, {
       method: 'POST',
@@ -6082,6 +6079,7 @@ async function toggleMangleRuleState(id, disabled) {
 }
 
 async function deleteMangleRule(id) {
+  id = decodeURIComponent(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' });
@@ -6103,7 +6101,7 @@ function renderAddressLists() {
   tbody.innerHTML = '';
 
   const entries = (state.addressLists || []).filter(e => !filterList || e.name === filterList);
-  document.getElementById('addrlist-count-badge').innerText = `${entries.length} Entries`;
+  document.getElementById('addrlist-count-badge').innerText = `${entries.length} Entries${entries.filter(e => e.dynamic).length >= 2000 ? ' (dynamic results capped at 2000)' : ''}`;
 
   // Update filter select
   const sel = document.getElementById('addrlist-filter-name');
@@ -6126,19 +6124,21 @@ function renderAddressLists() {
   }
   empty?.classList.add('hidden');
 
-  entries.forEach((e, idx) => {
+  entries.forEach((raw, idx) => {
+    const e = safeFirewallRow(raw);
     const tr = document.createElement('tr');
     tr.className = `hover:bg-slate-800/40 transition text-xs cursor-pointer ${e.disabled ? 'opacity-50' : ''}`;
-    tr.setAttribute('ondblclick', `openEditAddrListModal('${e.id}')`);
+    if (!e.dynamic) tr.setAttribute('ondblclick', `openEditAddrListModal('${e.id}')`);
     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" title="Disabled">X</span>' : '<span class="text-emerald-400 font-bold">✓</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 truncate max-w-[150px]">${e.comment || '-'}</td>
+      <td class="py-2.5 px-3 text-slate-400 italic truncate max-w-[150px]">${e.dynamic ? 'Dynamic · '+(e.timeout ? e.timeout+'s remaining' : 'no expiry') : e.comment || '-'}</td>
       <td class="py-2.5 px-3 text-right space-x-2 font-sans whitespace-nowrap">
-        <button onclick="toggleAddrListState('${e.id}', ${!e.disabled})" class="text-xs ${e.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline">${e.disabled ? 'Enable' : 'Disable'}</button>
+${e.dynamic ? '' : `        <button onclick="toggleAddrListState('${e.id}', ${!e.disabled})" class="text-xs ${e.disabled ? 'text-emerald-400' : 'text-slate-400'} hover:underline">${e.disabled ? 'Enable' : 'Disable'}</button>
         <button onclick="openEditAddrListModal('${e.id}')" class="text-xs text-cyan-400 hover:text-cyan-300 font-medium">Edit</button>
+`}
         <button onclick="deleteAddrList('${e.id}')" class="text-xs text-slate-500 hover:text-rose-400">Delete</button>
       </td>
     `;
@@ -6160,6 +6160,7 @@ function openAddAddrListModal() {
 }
 
 function openEditAddrListModal(id) {
+  id = decodeURIComponent(id);
   const e = (state.addressLists || []).find(item => item.id === id);
   if (!e) return;
 
@@ -6206,6 +6207,7 @@ async function submitSaveAddrList(e) {
 }
 
 async function toggleAddrListState(id, disabled) {
+  id = decodeURIComponent(id);
   try {
     const res = await fetch(`${API_BASE}/firewall/address-lists/${encodeURIComponent(id)}/state`, {
       method: 'POST',
@@ -6221,6 +6223,7 @@ async function toggleAddrListState(id, disabled) {
 }
 
 async function deleteAddrList(id) {
+  id = decodeURIComponent(id);
   if (!confirm('Are you sure you want to delete this address list item?')) return;
   try {
     const res = await fetch(`${API_BASE}/firewall/address-lists/${encodeURIComponent(id)}`, { method: 'DELETE' });
@@ -6590,12 +6593,14 @@ async function loadConnections() {
   try {
     const res = await fetch(`${API_BASE}/firewall/connections`);
     const data = await res.json();
+    if (!res.ok) throw new Error(data.message || 'Connection tracking unavailable');
     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);
+    const badge=document.getElementById('conns-count-badge'); if(badge) badge.textContent='Unavailable: '+err.message;
   }
 }
 
@@ -6639,24 +6644,24 @@ function renderConnections() {
       <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>
+${['TCP','UDP','SCTP','DCCP','ICMP','ICMPV6'].includes(c.protocol) ? `        <button onclick="killConnection('${c.protocol}', '${c.src_address}', ${c.src_port}, '${c.dst_address}', ${c.dst_port}, ${c.ip_version || 4}, ${c.zone || 0}, ${c.icmp_id ?? 'null'}, ${c.icmp_type ?? 'null'}, ${c.icmp_code ?? 'null'})" class="text-xs text-rose-400 hover:text-rose-300">Kill</button>` : ''}
       </td>
     `;
     tbody.appendChild(tr);
   });
 }
 
-async function killConnection(protocol, src, sport, dst, dport) {
+async function killConnection(protocol, src, sport, dst, dport, ip_version, zone, icmp_id, icmp_type, icmp_code) {
   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 })
+      body: JSON.stringify({ protocol, src, sport, dst, dport, ip_version, zone, icmp_id, icmp_type, icmp_code })
     });
     if (res.ok) {
       showToast('Terminated', `Killed flow ${src}:${sport} -> ${dst}:${dport}`, 'info');
       loadConnections();
-    }
+    } else { const error = await res.json(); showToast('Error', error.message || 'Unable to terminate this flow', 'error'); }
   } catch (err) {
     showToast('Error', err.message, 'error');
   }
@@ -7383,8 +7388,8 @@ function showToast(title, message, type = 'info') {
   toast.className = `pointer-events-auto border rounded-xl p-4 shadow-2xl backdrop-blur-md flex items-start space-x-3 w-80 transform transition-all duration-300 translate-y-2 opacity-0 ${borderColors[type] || borderColors.info}`;
   toast.innerHTML = `
     <div class="flex-1">
-      <div class="font-bold text-xs text-white">${title}</div>
-      <div class="text-[11px] text-slate-300 mt-0.5 leading-relaxed font-sans">${message}</div>
+      <div class="font-bold text-xs text-white">${escapeHtml(title)}</div>
+      <div class="text-[11px] text-slate-300 mt-0.5 leading-relaxed font-sans">${escapeHtml(message)}</div>
     </div>
   `;
   container.appendChild(toast);
@@ -17293,3 +17298,63 @@ async function startUpdate(action) {
     await loadUpdateStatus(false);
   } catch (err) { showToast('Update Failed', err.message, 'error'); }
 }
+
+async function resetCurrentRuleCounters() { return resetFirewallRuleCounter('filter', resetRuleCounterDisplay); }
+
+async function resetCurrentNATRuleCounters() { return resetFirewallRuleCounter('nat', resetNATCounterDisplay); }
+
+async function resetCurrentMangleRuleCounters() { return resetFirewallRuleCounter('mangle', resetMangleCounterDisplay); }
+
+function safeFirewallRow(row) {
+  const result = {...row};
+  for (const [key,value] of Object.entries(result)) if (typeof value === 'string') result[key] = escapeHtml(value);
+  result.id = encodeURIComponent(String(row.id || '')).replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29');
+  return result;
+}
+function setFirewallDays(prefix, days) {
+  const selected = days ? days.toLowerCase().split(',') : ['sun','mon','tue','wed','thu','fri','sat'];
+  for (const d of ['sun','mon','tue','wed','thu','fri','sat']) { const el = document.getElementById(prefix+'-day-'+d); if (el) el.checked = selected.includes(d); }
+}
+function getFirewallDays(prefix) {
+  if (document.getElementById(prefix+'-field-days')?.classList.contains('hidden')) return '';
+  const selected = ['sun','mon','tue','wed','thu','fri','sat'].filter(d => document.getElementById(prefix+'-day-'+d)?.checked);
+  if (!selected.length) throw new Error('Select at least one weekday, or remove the Days condition.');
+  return selected.join(',');
+}
+async function resetFirewallRuleCounter(kind, resetDisplay) {
+  const id = document.getElementById(kind+'-modal-id')?.value;
+  if (!id) { resetDisplay(); return; }
+  try {
+    const response = await fetch(`${API_BASE}/firewall/${kind}/${encodeURIComponent(id)}/reset-counters`, {method:'POST'});
+    if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Counter reset failed'); }
+    resetDisplay(); await loadFirewall(false);
+  } catch(error) { showToast('Error', error.message, 'error'); }
+}
+
+function reviewLegacyFirewallSettings(kind, id) {
+  if (!id) return true;
+  const rows = kind === 'filter' ? state.filterRules : kind === 'nat' ? state.natRules : state.mangleRules;
+  const rule = (rows || []).find(r => r.id === id);
+  if (!rule) return true;
+  const unsupported = ['layer7_protocol','connection_bytes','connection_rate','pcc','in_bridge_port_list','out_bridge_port_list','tls_host','ingress_priority','priority','ipv4_options','dst_limit','psd','hotspot','route','sniff_target'];
+  const present = unsupported.filter(key => rule[key]);
+  if (!present.length) return true;
+  return confirm('This saved rule contains unsupported settings: '+present.join(', ')+'. Saving removes them. Review the remaining match conditions before continuing.');
+}
+
+function refreshFirewallRejectTypes() {
+  const select=document.getElementById('filter-modal-rejectwith'); if(!select) return;
+  const old=select.value;
+  const ipv6=document.getElementById('filter-modal-family')?.value==='6';
+  const types=ipv6 ? ['icmp6-port-unreachable','icmp6-no-route','icmp6-adm-prohibited','icmp6-addr-unreachable','tcp-reset'] : ['icmp-port-unreachable','icmp-net-unreachable','icmp-host-unreachable','icmp-proto-unreachable','icmp-net-prohibited','icmp-host-prohibited','icmp-admin-prohibited','tcp-reset'];
+  select.replaceChildren(...types.map(value=>{const option=document.createElement('option');option.value=value;option.textContent=value;return option}));
+  if(types.includes(old))select.value=old;
+}
+
+function setFirewallActionSelection(kind, value) {
+ const select=document.getElementById(kind+'-modal-action'); if(!select)return;
+ if(!Array.from(select.options).some(option=>option.value===value)) {
+  const option=document.createElement('option');option.value=value;option.textContent='Unsupported saved action: '+value;option.disabled=true;select.appendChild(option);
+ }
+ select.value=value;
+}

+ 108 - 464
internal/web/static/index.html

@@ -2066,6 +2066,7 @@
 
         <!-- ================= SECTION: FIREWALL ================= -->
         <section id="section-firewall" class="hidden space-y-6">
+<div id="fw-apply-error" class="hidden rounded border border-rose-700 bg-rose-950/40 p-3 text-sm text-rose-200"></div>
           <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">
@@ -5801,11 +5802,11 @@
 
       <!-- Window Body (Split Layout: Left Tab Content + Right Action Sidebar) -->
       <div class="flex-1 flex min-h-0 overflow-hidden">
-        
+
         <!-- Form Content Container (Scrollable) -->
         <form id="fw-rule-form" onsubmit="event.preventDefault(); applyFilterRule(true);" class="flex-1 overflow-y-auto px-4 py-3 min-w-0 space-y-1">
           <input type="hidden" id="filter-modal-id">
-          <label class="block text-xs text-slate-300">IP family<select id="filter-modal-family" class="bg-slate-800 rounded p-2 ml-2"><option value="4">IPv4</option><option value="6">IPv6</option></select></label>
+          <label class="block text-xs text-slate-300">IP family<select id="filter-modal-family" onchange="refreshFirewallRejectTypes()" class="bg-slate-800 rounded p-2 ml-2"><option value="4">IPv4</option><option value="6">IPv6</option></select></label>
 
           <!-- TAB PANE: GENERAL -->
           <div id="fw-pane-general" class="fw-rule-pane space-y-1">
@@ -5829,11 +5830,11 @@
             <div class="flex items-center text-xs py-1">
               <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Chain</label>
               <div class="flex-1">
-                <select id="filter-modal-chain" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-sky-400 font-mono focus:outline-none focus:border-sky-500 w-full cursor-pointer">
+                <input id="filter-modal-chain" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-sky-400 font-mono focus:outline-none focus:border-sky-500 w-full cursor-pointer" list="filter-chain-options" maxlength="16" required><datalist id="filter-chain-options">
                   <option value="forward" selected>forward</option>
                   <option value="input">input</option>
                   <option value="output">output</option>
-                </select>
+                </datalist>
               </div>
             </div>
 
@@ -5948,7 +5949,7 @@
                   <select id="fw-val-proto" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
                     <option value="tcp">6 (tcp)</option>
                     <option value="udp">17 (udp)</option>
-                    <option value="icmp">1 (icmp)</option>
+                    <option value="icmp">ICMP (family-specific)</option><option value="ipv6-icmp">ICMPv6</option>
                     <option value="gre">47 (gre)</option>
                     <option value="esp">50 (esp)</option>
                     <option value="ipip">4 (ipip)</option>
@@ -6048,14 +6049,11 @@
               <div class="flex-1 flex items-center gap-1.5 min-w-0">
                 <button type="button" onclick="toggleFwField('inifacelist', true)" id="fw-plus-inifacelist" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="fw-field-inifacelist" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <label title="Invert match (!)" class="flex items-center gap-1 cursor-pointer pr-1 select-none">
-                    <input type="checkbox" id="fw-inv-inifacelist" class="rounded bg-[#101721] border-[#2b3e56] text-amber-500 focus:ring-0 w-3.5 h-3.5">
-                    <span class="font-mono font-bold text-amber-400 text-xs">!</span>
-                  </label>
+
                   <select id="fw-val-inifacelist" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
-                    <option value="all">all</option>
-                    <option value="none">none</option>
-                    <option value="dynamic">dynamic</option>
+
+
+
                     <option value="LAN">LAN</option>
                     <option value="WAN">WAN</option>
                   </select>
@@ -6070,14 +6068,11 @@
               <div class="flex-1 flex items-center gap-1.5 min-w-0">
                 <button type="button" onclick="toggleFwField('outifacelist', true)" id="fw-plus-outifacelist" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="fw-field-outifacelist" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <label title="Invert match (!)" class="flex items-center gap-1 cursor-pointer pr-1 select-none">
-                    <input type="checkbox" id="fw-inv-outifacelist" class="rounded bg-[#101721] border-[#2b3e56] text-amber-500 focus:ring-0 w-3.5 h-3.5">
-                    <span class="font-mono font-bold text-amber-400 text-xs">!</span>
-                  </label>
+
                   <select id="fw-val-outifacelist" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
-                    <option value="all">all</option>
-                    <option value="none">none</option>
-                    <option value="dynamic">dynamic</option>
+
+
+
                     <option value="LAN">LAN</option>
                     <option value="WAN">WAN</option>
                   </select>
@@ -6190,16 +6185,7 @@
           <!-- TAB PANE: ADVANCED (Screenshot 2) -->
           <div id="fw-pane-advanced" class="fw-rule-pane hidden space-y-1">
             <!-- Layer7 Protocol -->
-            <div class="flex items-center text-xs py-1" id="fw-row-layer7">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Layer7 Protocol</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleFwField('layer7', true)" id="fw-plus-layer7" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="fw-field-layer7" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="fw-val-layer7" placeholder="e.g. youtube_regex" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleFwField('layer7', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Content -->
             <div class="flex items-center text-xs py-1" id="fw-row-content">
@@ -6214,40 +6200,13 @@
             </div>
 
             <!-- Connection Bytes -->
-            <div class="flex items-center text-xs py-1" id="fw-row-connbytes">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Connection Bytes</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleFwField('connbytes', true)" id="fw-plus-connbytes" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="fw-field-connbytes" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="fw-val-connbytes" placeholder="e.g. 0-500000" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleFwField('connbytes', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Connection Rate -->
-            <div class="flex items-center text-xs py-1" id="fw-row-connrate">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Connection Rate</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleFwField('connrate', true)" id="fw-plus-connrate" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="fw-field-connrate" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="fw-val-connrate" placeholder="e.g. 0-100k" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleFwField('connrate', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Per Connection Classifier -->
-            <div class="flex items-center text-xs py-1" id="fw-row-pcc">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Per Connection Classifier</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleFwField('pcc', true)" id="fw-plus-pcc" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="fw-field-pcc" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="fw-val-pcc" placeholder="both-addresses:2/0" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleFwField('pcc', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Src. MAC Address -->
             <div class="flex items-center text-xs py-1" id="fw-row-srcmac">
@@ -6294,28 +6253,10 @@
             <hr class="border-[#213042] my-2">
 
             <!-- In. Bridge Port List -->
-            <div class="flex items-center text-xs py-1" id="fw-row-inbridgeportlist">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">In. Bridge Port List</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleFwField('inbridgeportlist', true)" id="fw-plus-inbridgeportlist" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="fw-field-inbridgeportlist" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="fw-val-inbridgeportlist" placeholder="e.g. LAN" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleFwField('inbridgeportlist', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Out. Bridge Port List -->
-            <div class="flex items-center text-xs py-1" id="fw-row-outbridgeportlist">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Out. Bridge Port List</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleFwField('outbridgeportlist', true)" id="fw-plus-outbridgeportlist" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="fw-field-outbridgeportlist" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="fw-val-outbridgeportlist" placeholder="e.g. WAN" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleFwField('outbridgeportlist', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <hr class="border-[#213042] my-2">
 
@@ -6326,10 +6267,10 @@
                 <button type="button" onclick="toggleFwField('ipsec', true)" id="fw-plus-ipsec" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="fw-field-ipsec" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
                   <select id="fw-val-ipsec" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
-                    <option value="in:ipsec">in:ipsec</option>
-                    <option value="out:ipsec">out:ipsec</option>
-                    <option value="in:none">in:none</option>
-                    <option value="out:none">out:none</option>
+                    <option value="in,ipsec">in:ipsec</option>
+                    <option value="out,ipsec">out:ipsec</option>
+                    <option value="in,none">in:none</option>
+                    <option value="out,none">out:none</option>
                   </select>
                   <button type="button" onclick="toggleFwField('ipsec', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
                 </div>
@@ -6337,42 +6278,15 @@
             </div>
 
             <!-- TLS Host -->
-            <div class="flex items-center text-xs py-1" id="fw-row-tlshost">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">TLS Host</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleFwField('tlshost', true)" id="fw-plus-tlshost" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="fw-field-tlshost" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="fw-val-tlshost" placeholder="e.g. *.netflix.com" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleFwField('tlshost', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <hr class="border-[#213042] my-2">
 
             <!-- Ingress Priority -->
-            <div class="flex items-center text-xs py-1" id="fw-row-ingresspriority">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Ingress Priority</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleFwField('ingresspriority', true)" id="fw-plus-ingresspriority" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="fw-field-ingresspriority" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="fw-val-ingresspriority" placeholder="0-7" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleFwField('ingresspriority', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Priority -->
-            <div class="flex items-center text-xs py-1" id="fw-row-priority">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Priority</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleFwField('priority', true)" id="fw-plus-priority" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="fw-field-priority" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="fw-val-priority" placeholder="0-7" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleFwField('priority', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- DSCP -->
             <div class="flex items-center text-xs py-1" id="fw-row-dscp">
@@ -6462,16 +6376,7 @@
             </div>
 
             <!-- IPv4 Options -->
-            <div class="flex items-center text-xs py-1" id="fw-row-ipv4options">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">IPv4 Options</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleFwField('ipv4options', true)" id="fw-plus-ipv4options" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="fw-field-ipv4options" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="fw-val-ipv4options" placeholder="any, loose-source-routing" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleFwField('ipv4options', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- TTL -->
             <div class="flex items-center text-xs py-1" id="fw-row-ttl">
@@ -6494,7 +6399,7 @@
               <div class="flex-1 flex items-center gap-1.5 min-w-0">
                 <button type="button" onclick="toggleFwField('time', true)" id="fw-plus-time" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="fw-field-time" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="fw-val-time" placeholder="00:00:00-23:59:59" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
+                  <input type="text" id="fw-val-time" placeholder="00:00:00-23:59:59 (UTC)" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
                   <button type="button" onclick="toggleFwField('time', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
                 </div>
               </div>
@@ -6524,35 +6429,17 @@
               <div class="flex-1 flex items-center gap-1.5 min-w-0">
                 <button type="button" onclick="toggleFwField('limit', true)" id="fw-plus-limit" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="fw-field-limit" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="fw-val-limit" placeholder="e.g. 100,5:packet" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
+                  <input type="text" id="fw-val-limit" placeholder="100/second,5" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
                   <button type="button" onclick="toggleFwField('limit', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
                 </div>
               </div>
             </div>
 
             <!-- Dst. Limit -->
-            <div class="flex items-center text-xs py-1" id="fw-row-dstlimit">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Dst. Limit</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleFwField('dstlimit', true)" id="fw-plus-dstlimit" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="fw-field-dstlimit" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="fw-val-dstlimit" placeholder="e.g. 100/5s,5" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleFwField('dstlimit', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- PSD -->
-            <div class="flex items-center text-xs py-1" id="fw-row-psd">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">PSD</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleFwField('psd', true)" id="fw-plus-psd" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="fw-field-psd" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="fw-val-psd" placeholder="Weight: 21, 3s, 3, 1" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleFwField('psd', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
           </div>
 
           <!-- TAB PANE: ACTION (Screenshot 3) -->
@@ -6566,13 +6453,13 @@
                   <option value="add-dst-to-address-list">add-dst-to-address-list</option>
                   <option value="add-src-to-address-list">add-src-to-address-list</option>
                   <option value="drop">drop</option>
-                  <option value="fasttrack-connection">fasttrack-connection</option>
+
                   <option value="jump">jump</option>
                   <option value="log">log</option>
                   <option value="passthrough">passthrough</option>
                   <option value="reject">reject</option>
                   <option value="return">return</option>
-                  <option value="tarpit">tarpit</option>
+
                 </select>
               </div>
             </div>
@@ -6745,7 +6632,7 @@
   <!-- ================= MODAL: FIREWALL NAT RULE ================= -->
   <div id="nat-modal" class="fixed inset-0 bg-black/60 backdrop-blur-[2px] z-50 hidden flex items-center justify-center p-2 sm:p-4">
     <div id="nat-modal-window" class="bg-[#131b26] border border-[#243347] rounded-md shadow-2xl w-full max-w-[720px] max-h-[92vh] flex flex-col overflow-hidden text-xs select-none">
-      
+
       <!-- Window Title Bar -->
       <div class="bg-[#162335] border-b border-[#223145] px-3.5 py-1.5 flex items-center justify-between shrink-0">
         <div class="flex items-center gap-2">
@@ -6780,7 +6667,7 @@
 
       <!-- Window Body (Split Layout: Left Tab Content + Right Action Sidebar) -->
       <div class="flex-1 flex min-h-0 overflow-hidden">
-        
+
         <!-- Form Content Container (Scrollable) -->
         <form id="nat-rule-form" onsubmit="event.preventDefault(); applyNATRule(true);" class="flex-1 overflow-y-auto px-4 py-3 min-w-0 space-y-1">
           <input type="hidden" id="nat-modal-id">
@@ -6808,10 +6695,10 @@
             <div class="flex items-center text-xs py-1">
               <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Chain</label>
               <div class="flex-1">
-                <select id="nat-modal-chain" onchange="handleNATChainChange()" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-sky-400 font-mono focus:outline-none focus:border-sky-500 w-full cursor-pointer">
+                <input id="nat-modal-chain" onchange="handleNATChainChange()" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-sky-400 font-mono focus:outline-none focus:border-sky-500 w-full cursor-pointer" list="nat-chain-options" maxlength="16" required><datalist id="nat-chain-options">
                   <option value="srcnat" selected>srcnat</option>
                   <option value="dstnat">dstnat</option>
-                </select>
+                </datalist>
               </div>
             </div>
 
@@ -6926,7 +6813,7 @@
                   <select id="nat-val-proto" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
                     <option value="tcp">6 (tcp)</option>
                     <option value="udp">17 (udp)</option>
-                    <option value="icmp">1 (icmp)</option>
+                    <option value="icmp">ICMP (family-specific)</option><option value="ipv6-icmp">ICMPv6</option>
                     <option value="gre">47 (gre)</option>
                     <option value="esp">50 (esp)</option>
                     <option value="all">all</option>
@@ -7024,14 +6911,11 @@
               <div class="flex-1 flex items-center gap-1.5 min-w-0">
                 <button type="button" onclick="toggleNATField('inifacelist', true)" id="nat-plus-inifacelist" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="nat-field-inifacelist" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <label title="Invert match (!)" class="flex items-center gap-1 cursor-pointer pr-1 select-none">
-                    <input type="checkbox" id="nat-inv-inifacelist" class="rounded bg-[#101721] border-[#2b3e56] text-amber-500 focus:ring-0 w-3.5 h-3.5">
-                    <span class="font-mono font-bold text-amber-400 text-xs">!</span>
-                  </label>
+
                   <select id="nat-val-inifacelist" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
-                    <option value="all">all</option>
-                    <option value="none">none</option>
-                    <option value="dynamic">dynamic</option>
+
+
+
                     <option value="LAN">LAN</option>
                     <option value="WAN">WAN</option>
                   </select>
@@ -7046,14 +6930,11 @@
               <div class="flex-1 flex items-center gap-1.5 min-w-0">
                 <button type="button" onclick="toggleNATField('outifacelist', true)" id="nat-plus-outifacelist" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="nat-field-outifacelist" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <label title="Invert match (!)" class="flex items-center gap-1 cursor-pointer pr-1 select-none">
-                    <input type="checkbox" id="nat-inv-outifacelist" class="rounded bg-[#101721] border-[#2b3e56] text-amber-500 focus:ring-0 w-3.5 h-3.5">
-                    <span class="font-mono font-bold text-amber-400 text-xs">!</span>
-                  </label>
+
                   <select id="nat-val-outifacelist" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
-                    <option value="all">all</option>
-                    <option value="none">none</option>
-                    <option value="dynamic">dynamic</option>
+
+
+
                     <option value="LAN">LAN</option>
                     <option value="WAN">WAN</option>
                   </select>
@@ -7137,16 +7018,7 @@
           <!-- TAB PANE: ADVANCED (Screenshot 2) -->
           <div id="nat-pane-advanced" class="nat-rule-pane hidden space-y-1">
             <!-- Layer7 Protocol -->
-            <div class="flex items-center text-xs py-1" id="nat-row-layer7">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Layer7 Protocol</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleNATField('layer7', true)" id="nat-plus-layer7" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="nat-field-layer7" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="nat-val-layer7" placeholder="e.g. youtube_regex" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleNATField('layer7', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Content -->
             <div class="flex items-center text-xs py-1" id="nat-row-content">
@@ -7161,40 +7033,13 @@
             </div>
 
             <!-- Connection Bytes -->
-            <div class="flex items-center text-xs py-1" id="nat-row-connbytes">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Connection Bytes</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleNATField('connbytes', true)" id="nat-plus-connbytes" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="nat-field-connbytes" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="nat-val-connbytes" placeholder="e.g. 0-500000" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleNATField('connbytes', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Connection Rate -->
-            <div class="flex items-center text-xs py-1" id="nat-row-connrate">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Connection Rate</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleNATField('connrate', true)" id="nat-plus-connrate" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="nat-field-connrate" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="nat-val-connrate" placeholder="e.g. 0-100k" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleNATField('connrate', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Per Connection Classifier -->
-            <div class="flex items-center text-xs py-1" id="nat-row-pcc">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Per Connection Classifier</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleNATField('pcc', true)" id="nat-plus-pcc" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="nat-field-pcc" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="nat-val-pcc" placeholder="both-addresses:2/0" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleNATField('pcc', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Src. MAC Address -->
             <div class="flex items-center text-xs py-1" id="nat-row-srcmac">
@@ -7241,28 +7086,10 @@
             <hr class="border-[#213042] my-2">
 
             <!-- In. Bridge Port List -->
-            <div class="flex items-center text-xs py-1" id="nat-row-inbridgeportlist">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">In. Bridge Port List</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleNATField('inbridgeportlist', true)" id="nat-plus-inbridgeportlist" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="nat-field-inbridgeportlist" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="nat-val-inbridgeportlist" placeholder="e.g. LAN" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleNATField('inbridgeportlist', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Out. Bridge Port List -->
-            <div class="flex items-center text-xs py-1" id="nat-row-outbridgeportlist">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Out. Bridge Port List</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleNATField('outbridgeportlist', true)" id="nat-plus-outbridgeportlist" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="nat-field-outbridgeportlist" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="nat-val-outbridgeportlist" placeholder="e.g. WAN" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleNATField('outbridgeportlist', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <hr class="border-[#213042] my-2">
 
@@ -7273,10 +7100,10 @@
                 <button type="button" onclick="toggleNATField('ipsec', true)" id="nat-plus-ipsec" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="nat-field-ipsec" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
                   <select id="nat-val-ipsec" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
-                    <option value="in:ipsec">in:ipsec</option>
-                    <option value="out:ipsec">out:ipsec</option>
-                    <option value="in:none">in:none</option>
-                    <option value="out:none">out:none</option>
+                    <option value="in,ipsec">in:ipsec</option>
+                    <option value="out,ipsec">out:ipsec</option>
+                    <option value="in,none">in:none</option>
+                    <option value="out,none">out:none</option>
                   </select>
                   <button type="button" onclick="toggleNATField('ipsec', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
                 </div>
@@ -7286,28 +7113,10 @@
             <hr class="border-[#213042] my-2">
 
             <!-- Ingress Priority -->
-            <div class="flex items-center text-xs py-1" id="nat-row-ingresspriority">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Ingress Priority</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleNATField('ingresspriority', true)" id="nat-plus-ingresspriority" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="nat-field-ingresspriority" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="nat-val-ingresspriority" placeholder="0-7" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleNATField('ingresspriority', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Priority -->
-            <div class="flex items-center text-xs py-1" id="nat-row-priority">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Priority</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleNATField('priority', true)" id="nat-plus-priority" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="nat-field-priority" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="nat-val-priority" placeholder="0-7" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleNATField('priority', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- DSCP -->
             <div class="flex items-center text-xs py-1" id="nat-row-dscp">
@@ -7385,16 +7194,7 @@
             </div>
 
             <!-- IPv4 Options -->
-            <div class="flex items-center text-xs py-1" id="nat-row-ipv4options">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">IPv4 Options</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleNATField('ipv4options', true)" id="nat-plus-ipv4options" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="nat-field-ipv4options" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="nat-val-ipv4options" placeholder="any, loose-source-routing" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleNATField('ipv4options', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- TTL -->
             <div class="flex items-center text-xs py-1" id="nat-row-ttl">
@@ -7417,7 +7217,7 @@
               <div class="flex-1 flex items-center gap-1.5 min-w-0">
                 <button type="button" onclick="toggleNATField('time', true)" id="nat-plus-time" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="nat-field-time" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="nat-val-time" placeholder="00:00:00-23:59:59" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
+                  <input type="text" id="nat-val-time" placeholder="00:00:00-23:59:59 (UTC)" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
                   <button type="button" onclick="toggleNATField('time', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
                 </div>
               </div>
@@ -7447,35 +7247,17 @@
               <div class="flex-1 flex items-center gap-1.5 min-w-0">
                 <button type="button" onclick="toggleNATField('limit', true)" id="nat-plus-limit" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="nat-field-limit" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="nat-val-limit" placeholder="e.g. 100,5:packet" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
+                  <input type="text" id="nat-val-limit" placeholder="100/second,5" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
                   <button type="button" onclick="toggleNATField('limit', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
                 </div>
               </div>
             </div>
 
             <!-- Dst. Limit -->
-            <div class="flex items-center text-xs py-1" id="nat-row-dstlimit">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Dst. Limit</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleNATField('dstlimit', true)" id="nat-plus-dstlimit" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="nat-field-dstlimit" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="nat-val-dstlimit" placeholder="e.g. 100/5s,5" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleNATField('dstlimit', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- PSD -->
-            <div class="flex items-center text-xs py-1" id="nat-row-psd">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">PSD</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleNATField('psd', true)" id="nat-plus-psd" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="nat-field-psd" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="nat-val-psd" placeholder="Weight: 21, 3s, 3, 1" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleNATField('psd', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
           </div>
 
           <!-- TAB PANE: ACTION (Screenshot 3) -->
@@ -7489,7 +7271,7 @@
                   <option value="add-dst-to-address-list">add dst to address list</option>
                   <option value="add-src-to-address-list">add src to address list</option>
                   <option value="dst-nat">dst-nat</option>
-                  <option value="endpoint-independent-nat">endpoint-independent-nat</option>
+
                   <option value="jump">jump</option>
                   <option value="log">log</option>
                   <option value="masquerade" selected>masquerade</option>
@@ -7497,8 +7279,8 @@
                   <option value="passthrough">passthrough</option>
                   <option value="redirect">redirect</option>
                   <option value="return">return</option>
-                  <option value="same">same</option>
-                  <option value="socksify">socksify</option>
+
+
                   <option value="src-nat">src-nat</option>
                 </select>
               </div>
@@ -7674,7 +7456,7 @@
   <!-- ================= MODAL: FIREWALL MANGLE RULE ================= -->
   <div id="mangle-modal" class="fixed inset-0 bg-black/60 backdrop-blur-[2px] z-50 hidden flex items-center justify-center p-2 sm:p-4">
     <div id="mangle-modal-window" class="bg-[#131b26] border border-[#243347] rounded-md shadow-2xl w-full max-w-[720px] max-h-[92vh] flex flex-col overflow-hidden text-xs select-none">
-      
+
       <!-- Window Title Bar -->
       <div class="bg-[#162335] border-b border-[#223145] px-3.5 py-1.5 flex items-center justify-between shrink-0">
         <div class="flex items-center gap-2">
@@ -7709,7 +7491,7 @@
 
       <!-- Window Body (Split Layout: Left Tab Content + Right Action Sidebar) -->
       <div class="flex-1 flex min-h-0 overflow-hidden">
-        
+
         <!-- Form Content Container (Scrollable) -->
         <form id="mangle-rule-form" onsubmit="event.preventDefault(); applyMangleRule(true);" class="flex-1 overflow-y-auto px-4 py-3 min-w-0 space-y-1">
           <input type="hidden" id="mangle-modal-id">
@@ -7737,13 +7519,13 @@
             <div class="flex items-center text-xs py-1">
               <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Chain</label>
               <div class="flex-1">
-                <select id="mangle-modal-chain" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-sky-400 font-mono focus:outline-none focus:border-sky-500 w-full cursor-pointer">
+                <input id="mangle-modal-chain" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-sky-400 font-mono focus:outline-none focus:border-sky-500 w-full cursor-pointer" list="mangle-chain-options" maxlength="16" required><datalist id="mangle-chain-options">
                   <option value="prerouting" selected>prerouting</option>
                   <option value="postrouting">postrouting</option>
                   <option value="forward">forward</option>
                   <option value="input">input</option>
                   <option value="output">output</option>
-                </select>
+                </datalist>
               </div>
             </div>
 
@@ -7858,7 +7640,7 @@
                   <select id="mangle-val-proto" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
                     <option value="tcp">6 (tcp)</option>
                     <option value="udp">17 (udp)</option>
-                    <option value="icmp">1 (icmp)</option>
+                    <option value="icmp">ICMP (family-specific)</option><option value="ipv6-icmp">ICMPv6</option>
                     <option value="gre">47 (gre)</option>
                     <option value="esp">50 (esp)</option>
                     <option value="all">all</option>
@@ -7956,14 +7738,11 @@
               <div class="flex-1 flex items-center gap-1.5 min-w-0">
                 <button type="button" onclick="toggleMangleField('inifacelist', true)" id="mangle-plus-inifacelist" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="mangle-field-inifacelist" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <label title="Invert match (!)" class="flex items-center gap-1 cursor-pointer pr-1 select-none">
-                    <input type="checkbox" id="mangle-inv-inifacelist" class="rounded bg-[#101721] border-[#2b3e56] text-amber-500 focus:ring-0 w-3.5 h-3.5">
-                    <span class="font-mono font-bold text-amber-400 text-xs">!</span>
-                  </label>
+
                   <select id="mangle-val-inifacelist" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
-                    <option value="all">all</option>
-                    <option value="none">none</option>
-                    <option value="dynamic">dynamic</option>
+
+
+
                     <option value="LAN">LAN</option>
                     <option value="WAN">WAN</option>
                   </select>
@@ -7978,14 +7757,11 @@
               <div class="flex-1 flex items-center gap-1.5 min-w-0">
                 <button type="button" onclick="toggleMangleField('outifacelist', true)" id="mangle-plus-outifacelist" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="mangle-field-outifacelist" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <label title="Invert match (!)" class="flex items-center gap-1 cursor-pointer pr-1 select-none">
-                    <input type="checkbox" id="mangle-inv-outifacelist" class="rounded bg-[#101721] border-[#2b3e56] text-amber-500 focus:ring-0 w-3.5 h-3.5">
-                    <span class="font-mono font-bold text-amber-400 text-xs">!</span>
-                  </label>
+
                   <select id="mangle-val-outifacelist" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
-                    <option value="all">all</option>
-                    <option value="none">none</option>
-                    <option value="dynamic">dynamic</option>
+
+
+
                     <option value="LAN">LAN</option>
                     <option value="WAN">WAN</option>
                   </select>
@@ -8098,16 +7874,7 @@
           <!-- TAB PANE: ADVANCED (Screenshot 2) -->
           <div id="mangle-pane-advanced" class="mangle-rule-pane hidden space-y-1">
             <!-- Layer7 Protocol -->
-            <div class="flex items-center text-xs py-1" id="mangle-row-layer7">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Layer7 Protocol</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleMangleField('layer7', true)" id="mangle-plus-layer7" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="mangle-field-layer7" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="mangle-val-layer7" placeholder="e.g. youtube_regex" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleMangleField('layer7', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Content -->
             <div class="flex items-center text-xs py-1" id="mangle-row-content">
@@ -8122,40 +7889,13 @@
             </div>
 
             <!-- Connection Bytes -->
-            <div class="flex items-center text-xs py-1" id="mangle-row-connbytes">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Connection Bytes</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleMangleField('connbytes', true)" id="mangle-plus-connbytes" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="mangle-field-connbytes" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="mangle-val-connbytes" placeholder="e.g. 0-500000" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleMangleField('connbytes', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Connection Rate -->
-            <div class="flex items-center text-xs py-1" id="mangle-row-connrate">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Connection Rate</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleMangleField('connrate', true)" id="mangle-plus-connrate" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="mangle-field-connrate" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="mangle-val-connrate" placeholder="e.g. 0-100k" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleMangleField('connrate', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Per Connection Classifier -->
-            <div class="flex items-center text-xs py-1" id="mangle-row-pcc">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Per Connection Classifier</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleMangleField('pcc', true)" id="mangle-plus-pcc" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="mangle-field-pcc" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="mangle-val-pcc" placeholder="both-addresses:2/0" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleMangleField('pcc', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Src. MAC Address -->
             <div class="flex items-center text-xs py-1" id="mangle-row-srcmac">
@@ -8202,28 +7942,10 @@
             <hr class="border-[#213042] my-2">
 
             <!-- In. Bridge Port List -->
-            <div class="flex items-center text-xs py-1" id="mangle-row-inbridgeportlist">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">In. Bridge Port List</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleMangleField('inbridgeportlist', true)" id="mangle-plus-inbridgeportlist" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="mangle-field-inbridgeportlist" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="mangle-val-inbridgeportlist" placeholder="LAN_Ports" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleMangleField('inbridgeportlist', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Out. Bridge Port List -->
-            <div class="flex items-center text-xs py-1" id="mangle-row-outbridgeportlist">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Out. Bridge Port List</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleMangleField('outbridgeportlist', true)" id="mangle-plus-outbridgeportlist" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="mangle-field-outbridgeportlist" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="mangle-val-outbridgeportlist" placeholder="WAN_Ports" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleMangleField('outbridgeportlist', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <hr class="border-[#213042] my-2">
 
@@ -8234,11 +7956,11 @@
                 <button type="button" onclick="toggleMangleField('ipsec', true)" id="mangle-plus-ipsec" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="mangle-field-ipsec" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
                   <select id="mangle-val-ipsec" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
-                    <option value="none">none</option>
-                    <option value="in:ipsec">in:ipsec</option>
-                    <option value="in:none">in:none</option>
-                    <option value="out:ipsec">out:ipsec</option>
-                    <option value="out:none">out:none</option>
+                    <option value="">none</option>
+                    <option value="in,ipsec">in:ipsec</option>
+                    <option value="in,none">in:none</option>
+                    <option value="out,ipsec">out:ipsec</option>
+                    <option value="out,none">out:none</option>
                   </select>
                   <button type="button" onclick="toggleMangleField('ipsec', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
                 </div>
@@ -8246,42 +7968,15 @@
             </div>
 
             <!-- TLS Host -->
-            <div class="flex items-center text-xs py-1" id="mangle-row-tlshost">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">TLS Host</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleMangleField('tlshost', true)" id="mangle-plus-tlshost" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="mangle-field-tlshost" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="mangle-val-tlshost" placeholder="*.example.com" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleMangleField('tlshost', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <hr class="border-[#213042] my-2">
 
             <!-- Ingress Priority -->
-            <div class="flex items-center text-xs py-1" id="mangle-row-ingresspriority">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Ingress Priority</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleMangleField('ingresspriority', true)" id="mangle-plus-ingresspriority" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="mangle-field-ingresspriority" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="mangle-val-ingresspriority" placeholder="0-7" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleMangleField('ingresspriority', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Priority -->
-            <div class="flex items-center text-xs py-1" id="mangle-row-priority">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Priority</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleMangleField('priority', true)" id="mangle-plus-priority" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="mangle-field-priority" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="mangle-val-priority" placeholder="0-7" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleMangleField('priority', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- DSCP -->
             <div class="flex items-center text-xs py-1" id="mangle-row-dscp">
@@ -8371,16 +8066,7 @@
             </div>
 
             <!-- IPv4 Options -->
-            <div class="flex items-center text-xs py-1" id="mangle-row-ipv4options">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">IPv4 Options</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleMangleField('ipv4options', true)" id="mangle-plus-ipv4options" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="mangle-field-ipv4options" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="mangle-val-ipv4options" placeholder="any, loose-source-routing" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleMangleField('ipv4options', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- TTL -->
             <div class="flex items-center text-xs py-1" id="mangle-row-ttl">
@@ -8456,21 +8142,7 @@
             </div>
 
             <!-- Hotspot -->
-            <div class="flex items-center text-xs py-1" id="mangle-row-hotspot">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Hotspot</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleMangleField('hotspot', true)" id="mangle-plus-hotspot" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="mangle-field-hotspot" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <select id="mangle-val-hotspot" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
-                    <option value="from-client">from-client</option>
-                    <option value="to-client">to-client</option>
-                    <option value="auth">auth</option>
-                    <option value="http">http</option>
-                  </select>
-                  <button type="button" onclick="toggleMangleField('hotspot', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- IP Fragment -->
             <div class="flex items-center text-xs py-1" id="mangle-row-ipfragment">
@@ -8493,23 +8165,14 @@
               <div class="flex-1 flex items-center gap-1.5 min-w-0">
                 <button type="button" onclick="toggleMangleField('limit', true)" id="mangle-plus-limit" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="mangle-field-limit" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="mangle-val-limit" placeholder="e.g. 100,5:packet" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
+                  <input type="text" id="mangle-val-limit" placeholder="100/second,5" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
                   <button type="button" onclick="toggleMangleField('limit', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
                 </div>
               </div>
             </div>
 
             <!-- Dst. Limit -->
-            <div class="flex items-center text-xs py-1" id="mangle-row-dstlimit">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Dst. Limit</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleMangleField('dstlimit', true)" id="mangle-plus-dstlimit" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="mangle-field-dstlimit" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="mangle-val-dstlimit" placeholder="e.g. 100/5s,5" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleMangleField('dstlimit', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
 
             <!-- Time -->
             <div class="flex items-center text-xs py-1" id="mangle-row-time">
@@ -8517,23 +8180,14 @@
               <div class="flex-1 flex items-center gap-1.5 min-w-0">
                 <button type="button" onclick="toggleMangleField('time', true)" id="mangle-plus-time" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
                 <div id="mangle-field-time" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="mangle-val-time" placeholder="00:00:00-23:59:59" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
+                  <input type="text" id="mangle-val-time" placeholder="00:00:00-23:59:59 (UTC)" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
                   <button type="button" onclick="toggleMangleField('time', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
                 </div>
               </div>
             </div>
 
             <!-- PSD -->
-            <div class="flex items-center text-xs py-1" id="mangle-row-psd">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">PSD</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleMangleField('psd', true)" id="mangle-plus-psd" class="w-5 h-5 flex items-center justify-center bg-[#172435] hover:bg-[#1e3047] border border-[#2b3e56] rounded text-slate-300 hover:text-sky-300 text-xs font-bold leading-none">+</button>
-                <div id="mangle-field-psd" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="mangle-val-psd" placeholder="Weight: 21, 3s, 3, 1" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white focus:outline-none focus:border-sky-500 w-full font-mono">
-                  <button type="button" onclick="toggleMangleField('psd', false)" class="w-5 h-5 flex items-center justify-center text-slate-500 hover:text-rose-400 text-xs font-bold leading-none shrink-0">-</button>
-                </div>
-              </div>
-            </div>
+
           </div>
 
           <!-- TAB PANE: ACTION (Screenshot 4) -->
@@ -8549,8 +8203,8 @@
                   <option value="change-dscp">change-dscp</option>
                   <option value="change-mss">change-mss</option>
                   <option value="change-ttl">change-ttl</option>
-                  <option value="clear-df">clear-df</option>
-                  <option value="fasttrack-connection">fasttrack-connection</option>
+
+
                   <option value="jump">jump</option>
                   <option value="log">log</option>
                   <option value="mark-connection">mark-connection</option>
@@ -8558,11 +8212,11 @@
                   <option value="mark-routing">mark-routing</option>
                   <option value="passthrough">passthrough</option>
                   <option value="return">return</option>
-                  <option value="route">route</option>
+
                   <option value="set-priority">set-priority</option>
-                  <option value="sniff-pc">sniff-pc</option>
-                  <option value="sniff-tzsp">sniff-tzsp</option>
-                  <option value="strip-ipv4-options">strip-ipv4-options</option>
+
+
+
                 </select>
               </div>
             </div>
@@ -8624,20 +8278,10 @@
             </div>
 
             <!-- Route (when route) -->
-            <div id="mangle-row-route" class="hidden flex items-center text-xs py-1">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Route</label>
-              <div class="flex-1">
-                <input type="text" id="mangle-modal-route" placeholder="e.g. 192.168.1.1 or routing table" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
-              </div>
-            </div>
+
 
             <!-- Sniff Target (when sniff-pc or sniff-tzsp) -->
-            <div id="mangle-row-sniff" class="hidden flex items-center text-xs py-1">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Sniff Target</label>
-              <div class="flex-1">
-                <input type="text" id="mangle-modal-sniff" placeholder="IP / Host / Port" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-white font-mono focus:outline-none focus:border-sky-500 w-full">
-              </div>
-            </div>
+
 
             <!-- Passthrough Checkbox (visible for marks / changes) -->
             <div id="mangle-row-passthrough" class="hidden flex items-center text-xs py-1">

+ 27 - 0
scripts/firewall-smoke.cjs

@@ -0,0 +1,27 @@
+const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');
+const assert=require('node:assert/strict');const fs=require('fs');
+(async()=>{
+ const browser=await chromium.launch({headless:true,args:['--no-sandbox'],env:process.env});const page=await browser.newPage();const writes=[],errors=[],passed=[];
+ page.on('pageerror',e=>errors.push(e.message));
+ await page.route('**/api/firewall/**',async route=>{if(route.request().method()==='GET')return route.continue();writes.push({path:new URL(route.request().url()).pathname,body:route.request().postDataJSON()});await route.fulfill({json:{success:true,id:route.request().postDataJSON()?.id||'filter-preview'}})});
+ try{
+  await page.goto(process.env.DEBTIK_TEST_URL||'http://127.0.0.1:8080/',{waitUntil:'domcontentloaded'});await page.locator('#login-username').fill(process.env.DEBTIK_TEST_USER || 'admin');await page.locator('#login-password').fill(process.env.DEBTIK_TEST_PASSWORD || 'admin');await page.locator('#login-submit-btn').click();await page.waitForFunction(()=>state.currentUser!==null);
+  await page.evaluate(async()=>{navigate('firewall');await loadFirewall(false)});
+  for(const prefix of ['fw','nat','mangle'])for(const key of ['layer7','connbytes','connrate','pcc','tlshost','psd','ipv4options','dstlimit','ingresspriority','priority','hotspot'])assert.equal(await page.locator('#'+prefix+'-field-'+key).count(),0);
+  for(const [type,bad]of [['filter',['tarpit','fasttrack-connection']],['nat',['same','socksify','endpoint-independent-nat']],['mangle',['route','sniff-pc','sniff-tzsp','clear-df','strip-ipv4-options','fasttrack-connection']]])for(const action of bad)assert.equal(await page.locator('#'+type+'-modal-action option[value="'+action+'"]').count(),0);
+  passed.push('Unsupported fields and actions physically removed');
+  for(const [type,open,apply]of [['filter','openEditFilterModal','applyFilterRule'],['nat','openEditNATModal','applyNATRule'],['mangle','openEditMangleModal','applyMangleRule']]){
+   const chain=await page.evaluate(async({type,open,apply})=>{const key=type==='filter'?'filterRules':type==='nat'?'natRules':'mangleRules';state[key]=[{id:type+'-preview',chain:'CUSTOM_TEST',action:'accept',disabled:false,passthrough:true}];window[open](type+'-preview');const value=document.getElementById(type+'-modal-chain').value;await window[apply](true);return value},{type,open,apply});
+   assert.equal(chain,'CUSTOM_TEST');assert.equal(writes.at(-1).body.chain,'CUSTOM_TEST');
+  }passed.push('Custom chain preserved in all three forms');
+  for(const [type,prefix,open,apply]of [['filter','fw','openAddFilterModal','applyFilterRule'],['nat','nat','openAddNATModal','applyNATRule']]){
+   await page.evaluate(async({type,prefix,open,apply})=>{window[open]();window[prefix==='fw'?'toggleFwField':'toggleNATField']('days',true);for(const d of ['sun','mon','tue','wed','thu','fri','sat'])document.getElementById(prefix+'-day-'+d).checked=d==='fri';await window[apply](true)},{type,prefix,open,apply});assert.equal(writes.at(-1).body.days,'fri');
+  }passed.push('Weekdays serialized for Filter and NAT');
+  await page.evaluate(()=>{state.filterRules=[{id:'filter-xss',chain:'input',action:'accept',comment:'<img src="/audit-local-image" onerror="window.__fwAuditXss=true">'}];renderFilterRules()});await page.waitForTimeout(250);assert.equal(await page.evaluate(()=>!!window.__fwAuditXss),false);assert.equal(await page.locator('#filter-table-body img').count(),0);passed.push('Stored firewall comment escaped');
+  await page.evaluate(()=>showToast('Error','<img src=x onerror="window.__toastXss=true">','error')); await page.waitForTimeout(50); assert.equal(await page.evaluate(()=>!!window.__toastXss),false); assert.equal(await page.locator('#toast-container img').count(),0); passed.push('Firewall error messages escaped in notifications');
+  await page.evaluate(()=>{state.addressLists=[{id:'dynamic-test',name:'<img src=x onerror="window.__listXss=true">',address:'127.0.0.1',dynamic:true,timeout:30}];renderAddressLists()});assert.equal(await page.locator('#addrlist-table-body img').count(),0);assert.equal(await page.locator('#addrlist-table-body').getByText('Edit',{exact:true}).count(),0);passed.push('Dynamic entries escaped and read-only except Delete');
+  await page.evaluate(()=>{state.filterRules=[{id:'filter-reset',chain:'input',action:'accept'}];openEditFilterModal('filter-reset')});await page.evaluate(()=>resetCurrentRuleCounters());assert.equal(writes.at(-1).path,'/api/firewall/filter/filter-reset/reset-counters');passed.push('Single-rule reset calls backend');
+  await page.evaluate(()=>{state.filterRules=[{id:'filter-old',chain:'forward',action:'accept',tos:'min-delay'}];openEditFilterModal('filter-old');openAddFilterModal()});assert.equal(await page.locator('#fw-val-tos').inputValue(),'');passed.push('New rule does not inherit old TOS');
+  await page.evaluate(()=>handleLogoutClick());assert.deepEqual(errors,[]);console.log(JSON.stringify({passed,errors,mockedWrites:writes.length},null,2));
+ }finally{await browser.close()}
+})().catch(e=>{console.error(e);process.exitCode=1});

+ 2 - 2
scripts/install-dependencies.sh

@@ -8,7 +8,7 @@ src=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
 mode=${1:-install}
 [[ $# -le 1 && ( $mode == install || $mode == --check ) ]] || { echo 'Usage: install-dependencies.sh [--check]' >&2; exit 2; }
 packages=(
-  bash coreutils grep gawk sed findutils util-linux systemd procps
+  bash coreutils grep gawk sed findutils util-linux systemd procps kmod
   ca-certificates curl git openssh-client tar python3 openssl tzdata
   iproute2 bridge-utils iptables ipset conntrack wireguard-tools
   keepalived bird2 dnsmasq isc-dhcp-client
@@ -32,7 +32,7 @@ check_dependencies() {
     if ! installed "$package"; then echo "Missing package: $package" >&2; missing=1; fi
   done
   if ! installed ifupdown && ! installed ifupdown2; then echo 'Missing ifupdown or ifupdown2' >&2; missing=1; fi
-  for binary in ssh ip bridge brctl iptables ip6tables iptables-save ip6tables-save iptables-restore ip6tables-restore ipset conntrack wg wg-quick keepalived bird birdc dnsmasq dhclient pppd pon poff pppoe-server pptp pptpd xl2tpd swanctl ping tracepath traceroute dig mtr mstpd mstpctl systemctl systemd-run hostnamectl timedatectl pkill flock python3; do
+  for binary in ssh ip bridge brctl iptables ip6tables iptables-save ip6tables-save iptables-restore ip6tables-restore ipset conntrack wg wg-quick keepalived bird birdc dnsmasq dhclient pppd pon poff pppoe-server pptp pptpd xl2tpd swanctl ping tracepath traceroute dig mtr mstpd mstpctl systemctl systemd-run hostnamectl timedatectl pkill flock python3 modprobe; do
     if ! command -v "$binary" >/dev/null; then echo "Missing command: $binary" >&2; missing=1; fi
   done
   go_cmd=$(command -v go || :)