Переглянути джерело

Use native iptables firewall chains and targets

Proxmox Router 2 днів тому
батько
коміт
4db13b6532

+ 3 - 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.3.5**. 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.6**. 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.3.5 https://gogs.eparmak.local/eparmak/DebTik.git
+  clone --depth 1 --branch v1.3.6 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.3.5` contains the complete dependency installer and native Linux firewall corrections.
+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.6` 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.
 

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.3.5
+1.3.6

+ 17 - 1
docs/firewall-native.md

@@ -1,4 +1,4 @@
-# Native Linux firewall — DebTik 1.3.0
+# Native Linux firewall — DebTik
 
 The firewall forms describe the implemented iptables/ip6tables backend.
 Unsupported RouterOS template options have been removed from the forms. The API
@@ -100,3 +100,19 @@ NAT port matches require explicit TCP or UDP; configure both for DNS. Existing
 legacy `all` port matches are displayed as TCP, preserving their prior kernel behavior.
 
 NAT exposes the Linux chains `prerouting`, `input`, `output` and `postrouting`. The MikroTik aliases `dstnat` and `srcnat` map to `prerouting` and `postrouting`, respectively, including existing rules. DNAT can be used in `dstnat`/`prerouting` for incoming traffic or `output` for traffic created on this machine. Custom chains need a jump from a reachable chain; they are not evaluated automatically. New rules are checked for incompatible chain, action and interface combinations. Existing saved rules are not migrated or reinterpreted.
+
+From 1.3.6 the UI uses iptables table, chain and target names. Filter offers
+INPUT/FORWARD/OUTPUT, NAT offers PREROUTING/INPUT/OUTPUT/POSTROUTING, and
+mangle offers all five. Existing `srcnat`/`dstnat` records are displayed and
+filtered as POSTROUTING/PREROUTING without rewriting the saved configuration;
+editing a rule saves its native chain name. The API accepts native DNAT/SNAT,
+MARK/CONNMARK, TCPMSS, DSCP, TTL/HL and CLASSIFY target names as well as the
+older internal names. Address-list SET operations retain explicit source or
+destination selection. Named routing marks still use DebTik's fwmark mapping.
+
+Rule moves skip rules belonging to other kernel chains; each chain's processing
+order is independent. New and edited port matches must use an explicit,
+non-inverted TCP or UDP protocol: configure separate rules for both. Existing
+saved rules keep their prior interpretation until edited. Invalid jumps to
+built-in chains are rejected. A custom chain receives packets only when reached
+through an explicit jump. Address lists, GeoIP and conntrack remain available.

+ 77 - 13
internal/firewall/manager.go

@@ -74,7 +74,7 @@ func (m *FirewallManager) initDefaultRules() error {
 		m.natRules = append(m.natRules, NATRule{
 			ID:               fmt.Sprintf("nat-%d", time.Now().UnixNano()),
 			Order:            1,
-			Chain:            "srcnat",
+			Chain:            "postrouting",
 			Action:           "masquerade",
 			OutInterfaceList: "WAN",
 			Comment:          "defconf: masquerade outgoing traffic to WAN",
@@ -349,6 +349,10 @@ func (m *FirewallManager) ListFilterRules() []FilterRule {
 func (m *FirewallManager) SaveFilterRule(r FilterRule) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
+	r = normalizeNativeRule("filter", r).(FilterRule)
+	if err := validateNewPortProtocol(r); err != nil {
+		return err
+	}
 	if err := validateChainPlacement("filter", r); err != nil {
 		return err
 	}
@@ -407,10 +411,16 @@ func (m *FirewallManager) MoveFilterRule(id string, dir string) error {
 		return fmt.Errorf("rule not found")
 	}
 
-	if dir == "up" && idx > 0 {
-		m.filterRules[idx], m.filterRules[idx-1] = m.filterRules[idx-1], m.filterRules[idx]
-	} else if dir == "down" && idx < len(m.filterRules)-1 {
-		m.filterRules[idx], m.filterRules[idx+1] = m.filterRules[idx+1], m.filterRules[idx]
+	chains := make([]string, len(m.filterRules))
+	for i, rule := range m.filterRules {
+		chains[i] = rule.Chain
+	}
+	other, err := neighboringChainRule(chains, idx, dir, false)
+	if err != nil {
+		return err
+	}
+	if other >= 0 {
+		m.filterRules[idx], m.filterRules[other] = m.filterRules[other], m.filterRules[idx]
 	}
 
 	for i := range m.filterRules {
@@ -455,6 +465,10 @@ func (m *FirewallManager) ListNATRules() []NATRule {
 func (m *FirewallManager) SaveNATRule(r NATRule) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
+	r = normalizeNativeRule("nat", r).(NATRule)
+	if err := validateNewPortProtocol(r); err != nil {
+		return err
+	}
 	if err := validateChainPlacement("nat", r); err != nil {
 		return err
 	}
@@ -513,10 +527,16 @@ func (m *FirewallManager) MoveNATRule(id string, dir string) error {
 		return fmt.Errorf("rule not found")
 	}
 
-	if dir == "up" && idx > 0 {
-		m.natRules[idx], m.natRules[idx-1] = m.natRules[idx-1], m.natRules[idx]
-	} else if dir == "down" && idx < len(m.natRules)-1 {
-		m.natRules[idx], m.natRules[idx+1] = m.natRules[idx+1], m.natRules[idx]
+	chains := make([]string, len(m.natRules))
+	for i, rule := range m.natRules {
+		chains[i] = rule.Chain
+	}
+	other, err := neighboringChainRule(chains, idx, dir, true)
+	if err != nil {
+		return err
+	}
+	if other >= 0 {
+		m.natRules[idx], m.natRules[other] = m.natRules[other], m.natRules[idx]
 	}
 
 	for i := range m.natRules {
@@ -561,6 +581,10 @@ func (m *FirewallManager) ListMangleRules() []MangleRule {
 func (m *FirewallManager) SaveMangleRule(r MangleRule) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
+	r = normalizeNativeRule("mangle", r).(MangleRule)
+	if err := validateNewPortProtocol(r); err != nil {
+		return err
+	}
 	if err := validateChainPlacement("mangle", r); err != nil {
 		return err
 	}
@@ -619,10 +643,16 @@ func (m *FirewallManager) MoveMangleRule(id string, dir string) error {
 		return fmt.Errorf("rule not found")
 	}
 
-	if dir == "up" && idx > 0 {
-		m.mangleRules[idx], m.mangleRules[idx-1] = m.mangleRules[idx-1], m.mangleRules[idx]
-	} else if dir == "down" && idx < len(m.mangleRules)-1 {
-		m.mangleRules[idx], m.mangleRules[idx+1] = m.mangleRules[idx+1], m.mangleRules[idx]
+	chains := make([]string, len(m.mangleRules))
+	for i, rule := range m.mangleRules {
+		chains[i] = rule.Chain
+	}
+	other, err := neighboringChainRule(chains, idx, dir, false)
+	if err != nil {
+		return err
+	}
+	if other >= 0 {
+		m.mangleRules[idx], m.mangleRules[other] = m.mangleRules[other], m.mangleRules[idx]
 	}
 
 	for i := range m.mangleRules {
@@ -820,3 +850,37 @@ func (m *FirewallManager) RenameInterface(oldName, newName string) {
 		}
 	}
 }
+
+// Rules in different kernel chains never run in the same sequence. Move to the
+// adjacent rule in the same chain even when the stored list interleaves chains.
+func neighboringChainRule(chains []string, index int, direction string, nat bool) (int, error) {
+	step := 0
+	switch direction {
+	case "up":
+		step = -1
+	case "down":
+		step = 1
+	default:
+		return -1, fmt.Errorf("direction must be up or down")
+	}
+	if index < 0 || index >= len(chains) {
+		return -1, fmt.Errorf("rule not found")
+	}
+	canonical := func(s string) string {
+		s = strings.ToLower(s)
+		if nat && s == "dstnat" {
+			return "prerouting"
+		}
+		if nat && s == "srcnat" {
+			return "postrouting"
+		}
+		return s
+	}
+	target := canonical(chains[index])
+	for i := index + step; i >= 0 && i < len(chains); i += step {
+		if canonical(chains[i]) == target {
+			return i, nil
+		}
+	}
+	return -1, nil
+}

+ 45 - 0
internal/firewall/nat_chain_test.go

@@ -42,3 +42,48 @@ func TestNATChainHooks(t *testing.T) {
 		}
 	}
 }
+
+func TestNativeTargetsAndPortValidation(t *testing.T) {
+	for _, action := range []string{"DNAT", "dnat", "dst-nat"} {
+		r := NATRule{Chain: "prerouting", Action: action, Protocol: "udp", DstPort: "53", ToAddresses: "192.0.2.53"}
+		if err := ValidateRule("nat", r); err != nil {
+			t.Fatalf("%s: %v", action, err)
+		}
+		normal := normalizeNativeRule("nat", r).(NATRule)
+		if !strings.Contains(BuildIPTablesRestore(nil, []NATRule{normal}, nil), "-j DNAT --to-destination 192.0.2.53") {
+			t.Fatalf("%s did not produce DNAT", action)
+		}
+	}
+	for target, want := range map[string]string{"MARK": "mark-packet", "CONNMARK": "mark-connection", "TCPMSS": "change-mss", "DSCP": "change-dscp", "TTL": "change-ttl", "HL": "change-ttl", "CLASSIFY": "set-priority"} {
+		got := normalizeNativeRule("mangle", MangleRule{Action: target}).(MangleRule).Action
+		if got != want {
+			t.Errorf("target %s normalized to %s, want %s", target, got, want)
+		}
+	}
+	for _, rule := range []any{FilterRule{Protocol: "all", DstPort: "53"}, MangleRule{Protocol: "icmp", SrcPort: "53"}, NATRule{Protocol: "!udp", DstPort: "53"}} {
+		if validateNewPortProtocol(rule) == nil {
+			t.Fatalf("accepted unsafe port combination: %#v", rule)
+		}
+	}
+	if validateNewPortProtocol(FilterRule{Protocol: "udp", DstPort: "53"}) != nil {
+		t.Fatal("valid UDP port was rejected")
+	}
+}
+
+func TestNeighboringChainRule(t *testing.T) {
+	chains := []string{"srcnat", "input", "postrouting", "dstnat", "prerouting"}
+	for _, tt := range []struct {
+		index int
+		dir   string
+		nat   bool
+		want  int
+	}{{0, "down", true, 2}, {2, "up", true, 0}, {3, "down", true, 4}, {1, "up", true, -1}, {0, "down", false, -1}} {
+		got, err := neighboringChainRule(chains, tt.index, tt.dir, tt.nat)
+		if err != nil || got != tt.want {
+			t.Errorf("%+v: got %d %v", tt, got, err)
+		}
+	}
+	if _, err := neighboringChainRule(chains, 0, "sideways", true); err == nil {
+		t.Fatal("invalid direction accepted")
+	}
+}

+ 30 - 0
internal/firewall/owned_test.go

@@ -65,6 +65,36 @@ func TestOwnedFirewallInNamespace(t *testing.T) {
 	}
 }
 
+func TestMoveNATRulesWithinChainInNamespace(t *testing.T) {
+	if os.Getenv("DEBTIK_FIREWALL_PERSIST_TEST") != "1" {
+		t.Skip("isolated mount and network namespaces required")
+	}
+	dir := t.TempDir()
+	if out, err := exec.Command("mount", "--bind", dir, "/etc/proxmox-router/firewall").CombinedOutput(); err != nil {
+		t.Fatalf("bind: %s: %v", out, err)
+	}
+	m := &FirewallManager{natRules: []NATRule{
+		{ID: "nat-first", Chain: "srcnat", Action: "accept"},
+		{ID: "nat-other", Chain: "output", Action: "accept"},
+		{ID: "nat-last", Chain: "postrouting", Action: "accept"},
+	}}
+	m.remember()
+	if err := m.Apply(); err != nil {
+		t.Fatal(err)
+	}
+	if err := m.MoveNATRule("nat-first", "down"); err != nil {
+		t.Fatal(err)
+	}
+	out, err := exec.Command("iptables", "-t", "nat", "-S", "DT_nat_POSTROUTING").CombinedOutput()
+	if err != nil {
+		t.Fatalf("iptables: %s: %v", out, err)
+	}
+	last, first := strings.Index(string(out), "id:nat-last"), strings.Index(string(out), "id:nat-first")
+	if last < 0 || first < 0 || last >= first {
+		t.Fatalf("wrong chain order: %s", out)
+	}
+}
+
 // Run only under both an isolated mount namespace and network namespace.
 func TestFirewallFailedSaveRollsBack(t *testing.T) {
 	if os.Getenv("DEBTIK_FIREWALL_PERSIST_TEST") != "1" {

+ 64 - 1
internal/firewall/validation.go

@@ -292,7 +292,7 @@ func validateRule(kind string, rule any) error {
 			}
 		}
 		if action == "set-priority" {
-			if chain != "postrouting" {
+			if !strings.EqualFold(chain, "postrouting") {
 				return fmt.Errorf("set-priority requires the postrouting chain")
 			}
 			if err := intRange(text(v, "new_priority"), 0, 65535); err != nil {
@@ -360,16 +360,79 @@ func ValidateRule(kind string, rule any) error {
 	if kind != "filter" && kind != "nat" && kind != "mangle" {
 		return fmt.Errorf("unknown firewall rule kind")
 	}
+	rule = normalizeNativeRule(kind, rule)
 	if err := validateRule(kind, rule); err != nil {
 		return err
 	}
+	if err := validateNewPortProtocol(rule); err != nil {
+		return err
+	}
 	return validateChainPlacement(kind, rule)
 }
 
+func validateNewPortProtocol(rule any) error {
+	v := values(rule)
+	if text(v, "src_port") == "" && text(v, "dst_port") == "" && text(v, "any_port") == "" && text(v, "to_ports") == "" {
+		return nil
+	}
+	inv, protocol := plain(strings.ToLower(text(v, "protocol")))
+	if inv != "" || (protocol != "tcp" && protocol != "udp") {
+		return fmt.Errorf("port matches require explicit TCP or UDP without protocol inversion; use separate rules for TCP and UDP")
+	}
+	return nil
+}
+
+// Accept native target names at the API boundary while preserving the storage
+// format used by existing installations and older clients.
+func normalizeNativeRule(kind string, rule any) any {
+	if r, ok := rule.(FilterRule); ok && kind == "filter" {
+		r.Action = strings.ToLower(r.Action)
+		return r
+	}
+	if r, ok := rule.(MangleRule); ok && kind == "mangle" {
+		r.Action = strings.ToLower(r.Action)
+		switch r.Action {
+		case "mark":
+			r.Action = "mark-packet"
+		case "connmark":
+			r.Action = "mark-connection"
+		case "tcpmss":
+			r.Action = "change-mss"
+		case "dscp":
+			r.Action = "change-dscp"
+		case "ttl", "hl":
+			r.Action = "change-ttl"
+		case "classify":
+			r.Action = "set-priority"
+		}
+		return r
+	}
+	if kind == "nat" {
+		if r, ok := rule.(NATRule); ok {
+			switch strings.ToLower(r.Action) {
+			case "dnat":
+				r.Action = "dst-nat"
+			case "snat":
+				r.Action = "src-nat"
+			default:
+				r.Action = strings.ToLower(r.Action)
+			}
+			return r
+		}
+	}
+	return rule
+}
+
 // Validate new and edited rules without changing the interpretation of saved rules.
 func validateChainPlacement(kind string, rule any) error {
 	v := values(rule)
 	chain := strings.ToLower(strings.TrimSpace(text(v, "chain")))
+	if text(v, "action") == "jump" {
+		target := strings.ToLower(strings.TrimSpace(text(v, "jump_target")))
+		if target == chain || target == "input" || target == "output" || target == "forward" || target == "prerouting" || target == "postrouting" || target == "srcnat" || target == "dstnat" {
+			return fmt.Errorf("jump requires a different user-defined chain")
+		}
+	}
 	if kind != "nat" && (chain == "srcnat" || chain == "dstnat") {
 		return fmt.Errorf("chain %s is available in the nat table only", chain)
 	}

+ 49 - 32
internal/web/static/app.js

@@ -3705,10 +3705,22 @@ async function saveInterfaceMetadata() {
   }
 }
 
-// ======================== MIKROTIK FIREWALL (/ip firewall) ========================
+// ======================== IPTABLES FIREWALL ========================
 state.fwSubTab = 'filter';
 state.routesSubTab = 'routes';
 
+function nativeNATChain(chain) {
+  const value = (chain || '').toLowerCase();
+  if (value === 'dstnat') return 'prerouting';
+  if (value === 'srcnat') return 'postrouting';
+  return ['prerouting', 'input', 'output', 'postrouting'].includes(value) ? value : (chain || '');
+}
+
+function nativeFirewallTarget(action) {
+  const name = (action || '').toLowerCase();
+  return ({'src-nat':'SNAT', 'dst-nat':'DNAT', 'add-src-to-address-list':'SET (add source)', 'add-dst-to-address-list':'SET (add destination)', 'mark-connection':'CONNMARK', 'mark-packet':'MARK', 'mark-routing':'MARK (routing)', 'change-dscp':'DSCP', 'change-mss':'TCPMSS', 'change-ttl':'TTL / HL', 'set-priority':'CLASSIFY', 'passthrough':'COUNT / continue', 'jump':'JUMP'})[name] || name.toUpperCase();
+}
+
 function renderRuleFlag(disabled, hasComment) {
   let flags = '';
   if (disabled) flags += '<span class="text-rose-400 font-bold" title="Disabled">X</span>';
@@ -3725,7 +3737,7 @@ function renderActionBadge(action) {
   else if (act.startsWith('mark-')) color = 'bg-purple-500/20 text-purple-300 border border-purple-500/30';
   else if (act === 'log') color = 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30';
 
-  return `<span class="px-2 py-0.5 rounded text-[10px] font-mono font-semibold ${color}">${action}</span>`;
+  return `<span class="px-2 py-0.5 rounded text-[10px] font-mono font-semibold ${color}">${escapeHtml(nativeFirewallTarget(action))}</span>`;
 }
 
 function switchFWSubTab(tab) {
@@ -3884,7 +3896,7 @@ function renderFilterRules() {
   if (!tbody) return;
   tbody.innerHTML = '';
 
-  const rules = (state.filterRules || []).filter(r => !chainFilter || r.chain === chainFilter);
+  const rules = (state.filterRules || []).filter(r => !chainFilter || (r.chain || '').toLowerCase() === chainFilter);
   document.getElementById('filter-count-badge').innerText = `${rules.length} Rules`;
 
   if (rules.length === 0) {
@@ -3914,7 +3926,7 @@ function renderFilterRules() {
     tr.innerHTML = `
       <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${r.order || idx + 1}</td>
       <td class="py-2.5 px-3 text-center">${renderRuleFlag(r.disabled, !!r.comment)}</td>
-      <td class="py-2.5 px-3 font-bold text-white">${r.chain}</td>
+      <td class="py-2.5 px-3 font-bold text-white">${(r.chain || '').toUpperCase()}</td>
       <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.src_address, r.src_address_list, r.src_geoip)}</td>
       <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.dst_address, r.dst_address_list, r.dst_geoip)}</td>
       <td class="py-2.5 px-3 text-cyan-400 uppercase">${r.protocol || 'all'} <span class="text-[9px] text-slate-500">IPv${r.ip_version || 4}</span></td>
@@ -4442,6 +4454,10 @@ async function applyFilterRule(closeAfter = false) {
   const sport = getFwFieldVal('sport', true);
   const dport = getFwFieldVal('dport', true);
   const anyport = getFwFieldVal('anyport', true);
+  if ((sport || dport || anyport) && !['tcp', 'udp'].includes(proto)) {
+    switchFwRuleTab('general'); toggleFwField('proto', true);
+    showToast('Select protocol', 'Port matches require TCP or UDP without protocol inversion.', 'error'); document.getElementById('fw-val-proto').focus(); return;
+  }
   const iniface = getFwFieldVal('iniface', true);
   const outiface = getFwFieldVal('outiface', true);
   const inifacelist = getFwFieldVal('inifacelist', true);
@@ -4690,7 +4706,8 @@ async function moveFilterRule(id, dir) {
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({ direction: dir })
     });
-    if (res.ok) loadFirewall(false);
+    if (!res.ok) throw new Error(await responseError(res));
+    await loadFirewall(false);
   } catch (err) {
     showToast('Error', err.message, 'error');
   }
@@ -4704,7 +4721,8 @@ async function toggleFilterRuleState(id, disabled) {
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({ disabled })
     });
-    if (res.ok) loadFirewall(false);
+    if (!res.ok) throw new Error(await responseError(res));
+    await loadFirewall(false);
   } catch (err) {
     showToast('Error', err.message, 'error');
   }
@@ -4715,7 +4733,8 @@ async function deleteFilterRule(id) {
   if (!confirm('Are you sure you want to delete this filter rule?')) return;
   try {
     const res = await fetch(`${API_BASE}/firewall/filter/${encodeURIComponent(id)}`, { method: 'DELETE' });
-    if (res.ok) {
+    if (!res.ok) throw new Error(await responseError(res));
+    {
       showToast('Deleted', 'Filter rule removed', 'success');
       loadFirewall(false);
     }
@@ -4732,7 +4751,7 @@ function renderNATRules() {
   if (!tbody) return;
   tbody.innerHTML = '';
 
-  const rules = (state.natRules || []).filter(r => !chainFilter || r.chain === chainFilter);
+  const rules = (state.natRules || []).filter(r => !chainFilter || nativeNATChain(r.chain) === chainFilter);
   document.getElementById('nat-count-badge').innerText = `${rules.length} Rules`;
 
   if (rules.length === 0) {
@@ -4761,12 +4780,12 @@ function renderNATRules() {
       const parts = [r.to_addresses, r.to_ports].filter(Boolean);
       toTarget = parts.length ? `<span class="text-amber-300 font-mono font-bold">${parts.join(':')}</span>` : '-';
     }
-    const ifaces = [r.out_interface ? 'out:' + r.out_interface : '', r.in_interface ? 'in:' + r.in_interface : ''].filter(Boolean).join(' ');
+    const ifaces = [r.out_interface ? 'out:' + r.out_interface : r.out_interface_list ? 'out-list:' + r.out_interface_list : '', r.in_interface ? 'in:' + r.in_interface : r.in_interface_list ? 'in-list:' + r.in_interface_list : ''].filter(Boolean).join(' ');
 
     tr.innerHTML = `
       <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${r.order || idx + 1}</td>
       <td class="py-2.5 px-3 text-center">${renderRuleFlag(r.disabled, !!r.comment)}</td>
-      <td class="py-2.5 px-3 font-bold text-amber-400">${r.chain}</td>
+      <td class="py-2.5 px-3 font-bold text-amber-400">${escapeHtml(nativeNATChain(raw.chain).toUpperCase())}</td>
       <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.src_address, r.src_address_list, r.src_geoip)}</td>
       <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.dst_address, r.dst_address_list, r.dst_geoip)}</td>
       <td class="py-2.5 px-3 text-cyan-400 uppercase">${r.protocol || 'all'}${r.dst_port ? ':' + r.dst_port : ''} <span class="text-[9px] text-slate-500">IPv${r.ip_version || 4}</span></td>
@@ -4895,15 +4914,6 @@ function toggleMaximizeNATModal() {
 }
 
 function handleNATChainChange() {
-  const ch = document.getElementById('nat-modal-chain')?.value || 'srcnat';
-  const actSel = document.getElementById('nat-modal-action');
-  if (actSel) {
-    if (ch === 'srcnat' && (actSel.value === 'dst-nat' || actSel.value === 'redirect')) {
-      actSel.value = 'masquerade';
-    } else if (ch === 'dstnat' && (actSel.value === 'masquerade' || actSel.value === 'src-nat')) {
-      actSel.value = 'dst-nat';
-    }
-  }
   toggleNATActionFields();
 }
 
@@ -4939,9 +4949,6 @@ function toggleNATActionFields() {
 }
 
 function handleNATActionChange() {
-  const action = document.getElementById('nat-modal-action')?.value;
-  const chain = document.getElementById('nat-modal-chain');
-  if (action === 'dst-nat' && chain && ['srcnat', 'postrouting', 'input'].includes(chain.value.toLowerCase())) chain.value = 'dstnat';
   toggleNATActionFields();
 }
 
@@ -4960,7 +4967,7 @@ function openAddNATModal() {
   document.getElementById('nat-modal-family').value = '4';
   document.getElementById('nat-modal-enabled').checked = true;
   document.getElementById('nat-modal-comment').value = '';
-  document.getElementById('nat-modal-chain').value = 'srcnat';
+  document.getElementById('nat-modal-chain').value = 'postrouting';
   document.getElementById('nat-modal-action').value = 'masquerade';
   document.getElementById('nat-modal-toaddr').value = '';
   document.getElementById('nat-modal-toport').value = '';
@@ -5005,7 +5012,7 @@ function openEditNATModal(id) {
   document.getElementById('nat-modal-family').value = String(r.ip_version || 4);
   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-chain').value = nativeNATChain(r.chain || 'postrouting');
   setFirewallActionSelection('nat', r.action || 'accept');
   document.getElementById('nat-modal-toaddr').value = r.to_addresses || '';
   document.getElementById('nat-modal-toport').value = r.to_ports || '';
@@ -5347,7 +5354,8 @@ async function moveNATRule(id, dir) {
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({ direction: dir })
     });
-    if (res.ok) loadFirewall(false);
+    if (!res.ok) throw new Error(await responseError(res));
+    await loadFirewall(false);
   } catch (err) {
     showToast('Error', err.message, 'error');
   }
@@ -5361,7 +5369,8 @@ async function toggleNATRuleState(id, disabled) {
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({ disabled })
     });
-    if (res.ok) loadFirewall(false);
+    if (!res.ok) throw new Error(await responseError(res));
+    await loadFirewall(false);
   } catch (err) {
     showToast('Error', err.message, 'error');
   }
@@ -5372,7 +5381,8 @@ async function deleteNATRule(id) {
   if (!confirm('Are you sure you want to delete this NAT rule?')) return;
   try {
     const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}`, { method: 'DELETE' });
-    if (res.ok) {
+    if (!res.ok) throw new Error(await responseError(res));
+    {
       showToast('Deleted', 'NAT rule removed', 'success');
       loadFirewall(false);
     }
@@ -5389,7 +5399,7 @@ function renderMangleRules() {
   if (!tbody) return;
   tbody.innerHTML = '';
 
-  const rules = (state.mangleRules || []).filter(r => !chainFilter || r.chain === chainFilter);
+  const rules = (state.mangleRules || []).filter(r => !chainFilter || (r.chain || '').toLowerCase() === chainFilter);
   document.getElementById('mangle-count-badge').innerText = `${rules.length} Rules`;
 
   if (rules.length === 0) {
@@ -5419,7 +5429,7 @@ function renderMangleRules() {
     tr.innerHTML = `
       <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${r.order || idx + 1}</td>
       <td class="py-2.5 px-3 text-center">${renderRuleFlag(r.disabled, !!r.comment)}</td>
-      <td class="py-2.5 px-3 font-bold text-purple-400">${r.chain}</td>
+      <td class="py-2.5 px-3 font-bold text-purple-400">${(r.chain || '').toUpperCase()}</td>
       <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.src_address, r.src_address_list, r.src_geoip)}</td>
       <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.dst_address, r.dst_address_list, r.dst_geoip)}</td>
       <td class="py-2.5 px-3">${renderActionBadge(r.action)}</td>
@@ -5869,6 +5879,10 @@ async function applyMangleRule(closeAfter = false) {
   const sport = getMangleFieldVal('sport', true);
   const dport = getMangleFieldVal('dport', true);
   const anyport = getMangleFieldVal('anyport', true);
+  if ((sport || dport || anyport) && !['tcp', 'udp'].includes(proto)) {
+    switchMangleRuleTab('general'); toggleMangleField('proto', true);
+    showToast('Select protocol', 'Port matches require TCP or UDP without protocol inversion.', 'error'); document.getElementById('mangle-val-proto').focus(); return;
+  }
   const iniface = getMangleFieldVal('iniface', true);
   const outiface = getMangleFieldVal('outiface', true);
   const inifacelist = getMangleFieldVal('inifacelist', true);
@@ -6073,7 +6087,8 @@ async function moveMangleRule(id, dir) {
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({ direction: dir })
     });
-    if (res.ok) loadFirewall(false);
+    if (!res.ok) throw new Error(await responseError(res));
+    await loadFirewall(false);
   } catch (err) {
     showToast('Error', err.message, 'error');
   }
@@ -6087,7 +6102,8 @@ async function toggleMangleRuleState(id, disabled) {
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({ disabled })
     });
-    if (res.ok) loadFirewall(false);
+    if (!res.ok) throw new Error(await responseError(res));
+    await loadFirewall(false);
   } catch (err) {
     showToast('Error', err.message, 'error');
   }
@@ -6098,7 +6114,8 @@ async function deleteMangleRule(id) {
   if (!confirm('Are you sure you want to delete this mangle rule?')) return;
   try {
     const res = await fetch(`${API_BASE}/firewall/mangle/${encodeURIComponent(id)}`, { method: 'DELETE' });
-    if (res.ok) {
+    if (!res.ok) throw new Error(await responseError(res));
+    {
       showToast('Deleted', 'Mangle rule removed', 'success');
       loadFirewall(false);
     }

+ 75 - 102
internal/web/static/index.html

@@ -2071,9 +2071,9 @@
             <div>
               <div class="flex items-center gap-2 mb-1">
                 <span class="px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 tracking-wide uppercase">Packet Inspection</span>
-                <h2 class="text-base font-bold text-white">Firewall & Traffic Controller</h2>
+                <h2 class="text-base font-bold text-white">iptables Firewall</h2>
               </div>
-              <p class="text-xs text-slate-400">Enterprise stateful inspection firewall, network address translation (NAT), packet marking (Mangle), IP address lists, and active connection tracking (conntrack).</p>
+              <p class="text-xs text-slate-400">Manage Linux filter, NAT and mangle tables, address lists (ipset), GeoIP and connection tracking (conntrack). Rules are evaluated in order within each chain.</p>
             </div>
 
             <div class="flex items-center space-x-2.5">
@@ -2089,7 +2089,7 @@
           <!-- Sub Tabs: Filter / NAT / Mangle / Address Lists / Connections -->
           <div class="flex border-b border-slate-800 space-x-1 overflow-x-auto">
             <button id="fw-subtab-btn-filter" onclick="switchFWSubTab('filter')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-emerald-500 text-white transition flex items-center gap-2 whitespace-nowrap">
-              <span>Filter Rules</span>
+              <span>Filter</span>
               <span id="fw-badge-filter" class="px-1.5 py-0.2 rounded text-[10px] bg-slate-800 text-slate-300">0</span>
             </button>
             <button id="fw-subtab-btn-nat" onclick="switchFWSubTab('nat')" class="px-4 py-2.5 text-xs font-bold border-b-2 border-transparent text-slate-400 hover:text-slate-200 transition flex items-center gap-2 whitespace-nowrap">
@@ -2118,7 +2118,7 @@
           <div id="fw-subtab-filter" class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
             <div class="p-4 border-b border-slate-800 flex items-center justify-between flex-wrap gap-3">
               <div class="flex items-center gap-3">
-                <h3 class="text-sm font-bold text-white">Firewall Filter Rules</h3>
+                <h3 class="text-sm font-bold text-white">Filter table</h3>
                 <span id="filter-count-badge" class="text-xs font-mono text-slate-400">0 Rules</span>
               </div>
               <div class="flex items-center gap-2">
@@ -2137,14 +2137,14 @@
                 <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
                   <tr>
                     <th class="py-2.5 px-3 w-10 text-center">#</th>
-                    <th class="py-2.5 px-3 w-12 text-center" title="Flags: X=Disabled, C=Comment">Flag</th>
+                    <th class="py-2.5 px-3 w-12 text-center" title="Status: X=Disabled, C=Comment">Status</th>
                     <th class="py-2.5 px-3">Chain</th>
                     <th class="py-2.5 px-3">Src. Address</th>
                     <th class="py-2.5 px-3">Dst. Address</th>
                     <th class="py-2.5 px-3">Protocol</th>
                     <th class="py-2.5 px-3">Ports</th>
                     <th class="py-2.5 px-3">In / Out</th>
-                    <th class="py-2.5 px-3">Action</th>
+                    <th class="py-2.5 px-3">Target</th>
                     <th class="py-2.5 px-3">Packets / Bytes</th>
                     <th class="py-2.5 px-3">Comment</th>
                     <th class="py-2.5 px-3 text-right font-sans">Order & Actions</th>
@@ -2170,8 +2170,10 @@
                 <label class="text-xs text-slate-400 font-sans">Chain:</label>
                 <select id="nat-filter-chain" onchange="renderNATRules()" class="bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-1 text-xs text-white font-mono">
                   <option value="">All Chains</option>
-                  <option value="srcnat">srcnat (SNAT / Masquerade)</option>
-                  <option value="dstnat">dstnat (Port Forwarding)</option>
+                  <option value="prerouting">PREROUTING</option>
+                  <option value="input">INPUT</option>
+                  <option value="output">OUTPUT</option>
+                  <option value="postrouting">POSTROUTING</option>
                 </select>
               </div>
             </div>
@@ -2181,13 +2183,13 @@
                 <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
                   <tr>
                     <th class="py-2.5 px-3 w-10 text-center">#</th>
-                    <th class="py-2.5 px-3 w-12 text-center">Flag</th>
+                    <th class="py-2.5 px-3 w-12 text-center">Status</th>
                     <th class="py-2.5 px-3">Chain</th>
                     <th class="py-2.5 px-3">Src. Address</th>
                     <th class="py-2.5 px-3">Dst. Address</th>
                     <th class="py-2.5 px-3">Proto/Port</th>
                     <th class="py-2.5 px-3">Out / In</th>
-                    <th class="py-2.5 px-3">Action</th>
+                    <th class="py-2.5 px-3">Target</th>
                     <th class="py-2.5 px-3">To Address:Port</th>
                     <th class="py-2.5 px-3">Packets / Bytes</th>
                     <th class="py-2.5 px-3">Comment</th>
@@ -2207,7 +2209,7 @@
           <div id="fw-subtab-mangle" class="hidden bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
             <div class="p-4 border-b border-slate-800 flex items-center justify-between flex-wrap gap-3">
               <div class="flex items-center gap-3">
-                <h3 class="text-sm font-bold text-white">Mangle Packet & Connection Marking</h3>
+                <h3 class="text-sm font-bold text-white">Mangle table</h3>
                 <span id="mangle-count-badge" class="text-xs font-mono text-slate-400">0 Rules</span>
               </div>
               <div class="flex items-center gap-2">
@@ -2228,11 +2230,11 @@
                 <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
                   <tr>
                     <th class="py-2.5 px-3 w-10 text-center">#</th>
-                    <th class="py-2.5 px-3 w-12 text-center">Flag</th>
+                    <th class="py-2.5 px-3 w-12 text-center">Status</th>
                     <th class="py-2.5 px-3">Chain</th>
                     <th class="py-2.5 px-3">Src. Address</th>
                     <th class="py-2.5 px-3">Dst. Address</th>
-                    <th class="py-2.5 px-3">Action</th>
+                    <th class="py-2.5 px-3">Target</th>
                     <th class="py-2.5 px-3">New Mark / Value</th>
                     <th class="py-2.5 px-3 text-center">Pass</th>
                     <th class="py-2.5 px-3">Packets / Bytes</th>
@@ -2269,7 +2271,7 @@
                 <thead class="text-slate-400 bg-slate-950/60 border-b border-slate-800 uppercase tracking-wider text-[10px] font-sans">
                   <tr>
                     <th class="py-2.5 px-3 w-10 text-center">#</th>
-                    <th class="py-2.5 px-3 w-12 text-center">Flag</th>
+                    <th class="py-2.5 px-3 w-12 text-center">Status</th>
                     <th class="py-2.5 px-3">List Name</th>
                     <th class="py-2.5 px-3">IP Address / CIDR</th>
                     <th class="py-2.5 px-3">Comment</th>
@@ -2316,7 +2318,7 @@
                     <th class="py-2.5 px-3">TCP State</th>
                     <th class="py-2.5 px-3">Mark</th>
                     <th class="py-2.5 px-3">Timeout</th>
-                    <th class="py-2.5 px-3 text-right font-sans">Action</th>
+                    <th class="py-2.5 px-3 text-right font-sans">Target</th>
                   </tr>
                 </thead>
                 <tbody id="conns-table-body" class="divide-y divide-slate-800/60"></tbody>
@@ -5771,16 +5773,7 @@
       <!-- 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">
-          <!-- WinBox v4 Logo -->
-          <div class="flex items-center gap-0.5 text-cyan-400 font-bold font-mono text-[11px] leading-none">
-            <svg class="w-3.5 h-3.5 text-cyan-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
-              <path d="M4 5l3.5 10L11 5" stroke-linecap="round" stroke-linejoin="round"/>
-              <path d="M15 5v5h4m0-5v10" stroke-linecap="round" stroke-linejoin="round"/>
-              <circle cx="12" cy="20" r="1.5" fill="currentColor"/>
-            </svg>
-            <span class="text-cyan-400 font-bold text-xs tracking-tight">v4</span>
-          </div>
-          <span class="text-slate-200 font-medium text-xs">Firewall Rule &gt;</span>
+          <span class="text-slate-200 font-medium text-xs">Filter rule</span>
           <span id="fw-modal-badge" class="bg-[#0078d4] text-white text-[11px] px-2 py-0.5 rounded font-mono font-medium">New...</span>
         </div>
         <div class="flex items-center gap-2">
@@ -5796,7 +5789,7 @@
         <button type="button" onclick="switchFwRuleTab('general')" id="fw-tab-btn-general" class="fw-rule-tab-btn bg-[#182638] text-white border-t-2 border-sky-400 px-3.5 py-1.5 text-xs font-medium transition-colors">General</button>
         <button type="button" onclick="switchFwRuleTab('advanced')" id="fw-tab-btn-advanced" class="fw-rule-tab-btn text-slate-400 hover:text-white hover:bg-[#15202d] border-t-2 border-transparent px-3.5 py-1.5 text-xs font-medium transition-colors">Advanced</button>
         <button type="button" onclick="switchFwRuleTab('extra')" id="fw-tab-btn-extra" class="fw-rule-tab-btn text-slate-400 hover:text-white hover:bg-[#15202d] border-t-2 border-transparent px-3.5 py-1.5 text-xs font-medium transition-colors">Extra</button>
-        <button type="button" onclick="switchFwRuleTab('action')" id="fw-tab-btn-action" class="fw-rule-tab-btn text-slate-400 hover:text-white hover:bg-[#15202d] border-t-2 border-transparent px-3.5 py-1.5 text-xs font-medium transition-colors">Action</button>
+        <button type="button" onclick="switchFwRuleTab('action')" id="fw-tab-btn-action" class="fw-rule-tab-btn text-slate-400 hover:text-white hover:bg-[#15202d] border-t-2 border-transparent px-3.5 py-1.5 text-xs font-medium transition-colors">Target</button>
         <button type="button" onclick="switchFwRuleTab('statistics')" id="fw-tab-btn-statistics" class="fw-rule-tab-btn text-slate-400 hover:text-white hover:bg-[#15202d] border-t-2 border-transparent px-3.5 py-1.5 text-xs font-medium transition-colors">Statistics</button>
       </div>
 
@@ -6175,8 +6168,8 @@
               <div class="flex-1 flex items-center gap-1.5 min-w-0">
                 <button type="button" onclick="toggleFwField('natstate', true)" id="fw-plus-natstate" 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-natstate" class="hidden flex-1 flex items-center gap-3 min-w-0 bg-[#15202e] border border-[#26374d] rounded px-2 py-1">
-                  <label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" id="filter-modal-nat-srcnat" class="rounded bg-[#101721] border-[#2b3e56] text-sky-500"><span class="text-slate-300">srcnat</span></label>
-                  <label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" id="filter-modal-nat-dstnat" class="rounded bg-[#101721] border-[#2b3e56] text-sky-500"><span class="text-slate-300">dstnat</span></label>
+                  <label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" id="filter-modal-nat-srcnat" class="rounded bg-[#101721] border-[#2b3e56] text-sky-500"><span class="text-slate-300">SNAT</span></label>
+                  <label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" id="filter-modal-nat-dstnat" class="rounded bg-[#101721] border-[#2b3e56] text-sky-500"><span class="text-slate-300">DNAT</span></label>
                   <button type="button" onclick="toggleFwField('natstate', 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 ml-auto" title="Remove / Clear">-</button>
                 </div>
               </div>
@@ -6447,19 +6440,19 @@
           <div id="fw-pane-action" class="fw-rule-pane hidden space-y-2">
             <!-- Action -->
             <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">Action</label>
+              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Target</label>
               <div class="flex-1">
                 <select id="filter-modal-action" onchange="toggleFilterActionFields()" 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">
-                  <option value="accept" selected>accept</option>
-                  <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="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="accept" selected>ACCEPT</option>
+                  <option value="add-dst-to-address-list">SET — add destination to address list</option>
+                  <option value="add-src-to-address-list">SET — add source to address list</option>
+                  <option value="drop">DROP</option>
+
+                  <option value="jump">JUMP to user chain</option>
+                  <option value="log">LOG</option>
+                  <option value="passthrough">COUNT / continue</option>
+                  <option value="reject">REJECT</option>
+                  <option value="return">RETURN</option>
 
                 </select>
               </div>
@@ -6637,16 +6630,7 @@
       <!-- 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">
-          <!-- WinBox v4 Logo -->
-          <div class="flex items-center gap-0.5 text-cyan-400 font-bold font-mono text-[11px] leading-none">
-            <svg class="w-3.5 h-3.5 text-cyan-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
-              <path d="M4 5l3.5 10L11 5" stroke-linecap="round" stroke-linejoin="round"/>
-              <path d="M15 5v5h4m0-5v10" stroke-linecap="round" stroke-linejoin="round"/>
-              <circle cx="12" cy="20" r="1.5" fill="currentColor"/>
-            </svg>
-            <span class="text-cyan-400 font-bold text-xs tracking-tight">v4</span>
-          </div>
-          <span class="text-slate-200 font-medium text-xs">NAT Rule &gt;</span>
+          <span class="text-slate-200 font-medium text-xs">NAT rule</span>
           <span id="nat-modal-badge" class="bg-[#0078d4] text-white text-[11px] px-2 py-0.5 rounded font-mono font-medium">New...</span>
         </div>
         <div class="flex items-center gap-2">
@@ -6696,44 +6680,42 @@
               <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">
                 <input id="nat-modal-chain" oninput="handleNATChainChange()" 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="dstnat">dstnat — incoming (prerouting)</option>
-                  <option value="srcnat" selected>srcnat — outgoing (postrouting)</option>
-                  <option value="prerouting">prerouting — incoming before routing</option>
-                  <option value="input">input — delivered to this machine</option>
-                  <option value="output">output — generated by this machine</option>
-                  <option value="postrouting">postrouting — outgoing after routing</option>
+                  <option value="prerouting">PREROUTING — incoming before routing</option>
+                  <option value="input">INPUT — delivered to this machine</option>
+                  <option value="output">OUTPUT — generated by this machine</option>
+                  <option value="postrouting">POSTROUTING — outgoing after routing</option>
                 </datalist>
-                <p class="mt-1 text-slate-400">dstnat = prerouting; srcnat = postrouting. Custom chains require a jump from an active chain.</p>
+                <p class="mt-1 text-slate-400">PREROUTING: incoming DNAT; POSTROUTING: SNAT/MASQUERADE. Custom chains require a jump from an active chain.</p>
               </div>
             </div>
 
             <!-- Action -->
             <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">Action</label>
+              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Target</label>
               <div class="flex-1">
                 <select id="nat-modal-action" onchange="handleNATActionChange()" 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">
-                  <option value="accept">accept</option>
-                  <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="accept">ACCEPT</option>
+                  <option value="add-dst-to-address-list">SET — add destination to address list</option>
+                  <option value="add-src-to-address-list">SET — add source to address list</option>
+                  <option value="dst-nat">DNAT</option>
 
-                  <option value="jump">jump</option>
-                  <option value="log">log</option>
-                  <option value="masquerade" selected>masquerade</option>
-                  <option value="netmap">netmap</option>
-                  <option value="passthrough">passthrough</option>
-                  <option value="redirect">redirect</option>
-                  <option value="return">return</option>
+                  <option value="jump">JUMP to user chain</option>
+                  <option value="log">LOG</option>
+                  <option value="masquerade" selected>MASQUERADE</option>
+                  <option value="netmap">NETMAP</option>
+                  <option value="passthrough">COUNT / continue</option>
+                  <option value="redirect">REDIRECT</option>
+                  <option value="return">RETURN</option>
 
 
-                  <option value="src-nat">src-nat</option>
+                  <option value="src-nat">SNAT</option>
                 </select>
               </div>
             </div>
 
             <!-- To Addresses (Shown for src-nat, dst-nat, netmap, same, socksify, endpoint-independent-nat) -->
             <div id="nat-row-toaddr" 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" for="nat-modal-toaddr">To IP / address</label>
+              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none" for="nat-modal-toaddr">Translate to address</label>
               <div class="flex-1">
                 <input type="text" id="nat-modal-toaddr" placeholder="192.168.10.50 or WAN IP" 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>
@@ -6741,7 +6723,7 @@
 
             <!-- To Ports (Shown for src-nat, dst-nat, redirect, masquerade, netmap, socksify, endpoint-independent-nat) -->
             <div id="nat-row-toport" 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" for="nat-modal-toport">To port (optional)</label>
+              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none" for="nat-modal-toport">Translate to port (optional)</label>
               <div class="flex-1">
                 <input type="text" id="nat-modal-toport" placeholder="80 or 8080 or 1024-65535" 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>
@@ -7462,16 +7444,7 @@
       <!-- 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">
-          <!-- WinBox v4 Logo -->
-          <div class="flex items-center gap-0.5 text-cyan-400 font-bold font-mono text-[11px] leading-none">
-            <svg class="w-3.5 h-3.5 text-cyan-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
-              <path d="M4 5l3.5 10L11 5" stroke-linecap="round" stroke-linejoin="round"/>
-              <path d="M15 5v5h4m0-5v10" stroke-linecap="round" stroke-linejoin="round"/>
-              <circle cx="12" cy="20" r="1.5" fill="currentColor"/>
-            </svg>
-            <span class="text-cyan-400 font-bold text-xs tracking-tight">v4</span>
-          </div>
-          <span class="text-slate-200 font-medium text-xs">Mangle Rule &gt;</span>
+          <span class="text-slate-200 font-medium text-xs">Mangle rule</span>
           <span id="mangle-modal-badge" class="bg-[#0078d4] text-white text-[11px] px-2 py-0.5 rounded font-mono font-medium">New...</span>
         </div>
         <div class="flex items-center gap-2">
@@ -7487,7 +7460,7 @@
         <button type="button" onclick="switchMangleRuleTab('general')" id="mangle-tab-btn-general" class="mangle-rule-tab-btn bg-[#182638] text-white border-t-2 border-sky-400 px-3.5 py-1.5 text-xs font-medium transition-colors">General</button>
         <button type="button" onclick="switchMangleRuleTab('advanced')" id="mangle-tab-btn-advanced" class="mangle-rule-tab-btn text-slate-400 hover:text-white hover:bg-[#15202d] border-t-2 border-transparent px-3.5 py-1.5 text-xs font-medium transition-colors">Advanced</button>
         <button type="button" onclick="switchMangleRuleTab('extra')" id="mangle-tab-btn-extra" class="mangle-rule-tab-btn text-slate-400 hover:text-white hover:bg-[#15202d] border-t-2 border-transparent px-3.5 py-1.5 text-xs font-medium transition-colors">Extra</button>
-        <button type="button" onclick="switchMangleRuleTab('action')" id="mangle-tab-btn-action" class="mangle-rule-tab-btn text-slate-400 hover:text-white hover:bg-[#15202d] border-t-2 border-transparent px-3.5 py-1.5 text-xs font-medium transition-colors">Action</button>
+        <button type="button" onclick="switchMangleRuleTab('action')" id="mangle-tab-btn-action" class="mangle-rule-tab-btn text-slate-400 hover:text-white hover:bg-[#15202d] border-t-2 border-transparent px-3.5 py-1.5 text-xs font-medium transition-colors">Target</button>
         <button type="button" onclick="switchMangleRuleTab('statistics')" id="mangle-tab-btn-statistics" class="mangle-rule-tab-btn text-slate-400 hover:text-white hover:bg-[#15202d] border-t-2 border-transparent px-3.5 py-1.5 text-xs font-medium transition-colors">Statistics</button>
       </div>
 
@@ -7866,8 +7839,8 @@
               <div class="flex-1 flex items-center gap-1.5 min-w-0">
                 <button type="button" onclick="toggleMangleField('natstate', true)" id="mangle-plus-natstate" 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-natstate" class="hidden flex-1 flex items-center gap-3 min-w-0 bg-[#15202e] border border-[#26374d] rounded px-2 py-1">
-                  <label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" id="mangle-modal-nat-srcnat" class="rounded bg-[#101721] border-[#2b3e56] text-sky-500"><span class="text-slate-300">srcnat</span></label>
-                  <label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" id="mangle-modal-nat-dstnat" class="rounded bg-[#101721] border-[#2b3e56] text-sky-500"><span class="text-slate-300">dstnat</span></label>
+                  <label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" id="mangle-modal-nat-srcnat" class="rounded bg-[#101721] border-[#2b3e56] text-sky-500"><span class="text-slate-300">SNAT</span></label>
+                  <label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" id="mangle-modal-nat-dstnat" class="rounded bg-[#101721] border-[#2b3e56] text-sky-500"><span class="text-slate-300">DNAT</span></label>
                   <button type="button" onclick="toggleMangleField('natstate', 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 ml-auto" title="Remove / Clear">-</button>
                 </div>
               </div>
@@ -8197,26 +8170,26 @@
           <div id="mangle-pane-action" class="mangle-rule-pane hidden space-y-2">
             <!-- Action -->
             <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">Action</label>
+              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Target</label>
               <div class="flex-1">
                 <select id="mangle-modal-action" onchange="toggleMangleActionFields()" class="bg-[#15202e] border border-sky-600/80 rounded px-2.5 py-1 text-xs text-sky-400 font-mono focus:outline-none focus:border-sky-500 w-full cursor-pointer">
-                  <option value="accept" selected>accept</option>
-                  <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="change-dscp">change-dscp</option>
-                  <option value="change-mss">change-mss</option>
-                  <option value="change-ttl">change-ttl</option>
-
-
-                  <option value="jump">jump</option>
-                  <option value="log">log</option>
-                  <option value="mark-connection">mark-connection</option>
-                  <option value="mark-packet">mark-packet</option>
-                  <option value="mark-routing">mark-routing</option>
-                  <option value="passthrough">passthrough</option>
-                  <option value="return">return</option>
-
-                  <option value="set-priority">set-priority</option>
+                  <option value="accept" selected>ACCEPT</option>
+                  <option value="add-dst-to-address-list">SET — add destination to address list</option>
+                  <option value="add-src-to-address-list">SET — add source to address list</option>
+                  <option value="change-dscp">DSCP</option>
+                  <option value="change-mss">TCPMSS</option>
+                  <option value="change-ttl">TTL / HL</option>
+
+
+                  <option value="jump">JUMP to user chain</option>
+                  <option value="log">LOG</option>
+                  <option value="mark-connection">CONNMARK — set connection mark</option>
+                  <option value="mark-packet">MARK — set packet mark</option>
+                  <option value="mark-routing">MARK — set routing mark</option>
+                  <option value="passthrough">COUNT / continue</option>
+                  <option value="return">RETURN</option>
+
+                  <option value="set-priority">CLASSIFY</option>
 
 
 

+ 15 - 3
scripts/firewall-smoke.cjs

@@ -24,14 +24,16 @@ const assert=require('node:assert/strict');const fs=require('fs');
   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(()=>openAddNATModal());
   await page.locator('#nat-modal-action').selectOption('dst-nat');
-  assert.equal(await page.locator('#nat-modal-chain').inputValue(),'dstnat');
-  passed.push('Selecting dst-nat selects the dstnat chain');
+  assert.equal(await page.locator('#nat-modal-chain').inputValue(),'postrouting');
+  assert.equal(await page.locator('#nat-modal-action option:checked').innerText(),'DNAT');
+  passed.push('Selecting target leaves chain selection explicit');
   await page.evaluate(()=>{openAddNATModal();document.getElementById('nat-modal-chain').value='output';});
   await page.locator('#nat-modal-action').selectOption('dst-nat');
   assert.equal(await page.locator('#nat-modal-chain').inputValue(),'output');
   for(const chain of ['prerouting','input','output','postrouting']) assert.equal(await page.locator(`#nat-chain-options option[value="${chain}"]`).count(),1);
   passed.push('NAT exposes packet paths and preserves output DNAT');
-  await page.evaluate(()=>{openAddNATModal();document.getElementById('nat-modal-chain').value='dstnat';handleNATChainChange()});
+  await page.evaluate(()=>{openAddNATModal();document.getElementById('nat-modal-chain').value='prerouting';handleNATChainChange()});
+  await page.locator('#nat-modal-action').selectOption('dst-nat');
   assert.equal(await page.locator('#nat-modal-action').inputValue(),'dst-nat');
   assert.equal(await page.locator('#nat-modal-toaddr').isVisible(),true);
   await page.locator('#nat-modal-toaddr').fill('192.0.2.53');
@@ -40,7 +42,17 @@ const assert=require('node:assert/strict');const fs=require('fs');
   assert.equal(await page.locator('#nat-val-proto').inputValue(),'all');
   await page.locator('#nat-val-proto').selectOption('udp');await page.evaluate(()=>applyNATRule(true));
   assert.equal(writes.at(-1).body.protocol,'udp');assert.equal(writes.at(-1).body.dst_port,'53');assert.equal(writes.at(-1).body.to_addresses,'192.0.2.53');
+  assert.equal(writes.at(-1).body.chain,'prerouting');
   passed.push('DNAT destination IP visible and DNS port requires explicit protocol');
+  await page.evaluate(()=>{state.natRules=[{id:'legacy',chain:'srcnat',action:'masquerade'},{id:'native',chain:'postrouting',action:'masquerade'}];document.getElementById('nat-filter-chain').value='postrouting';renderNATRules()});
+  assert.equal(await page.locator('#nat-table-body tr').count(),2);
+  assert.equal(await page.locator('#nat-table-body').getByText('POSTROUTING',{exact:true}).count(),2);
+  passed.push('Legacy NAT aliases render and filter as native chains');
+  await page.route('**/api/firewall/filter/filter-test/move',route=>route.fulfill({status:422,json:{message:'Move failed in kernel'}}));
+  await page.evaluate(()=>moveFilterRule('filter-test','up'));
+  assert.match(await page.locator('#toast-container').innerText(),/Move failed in kernel/);
+  await page.unroute('**/api/firewall/filter/filter-test/move');
+  passed.push('Rule operation errors are visible');
   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});