فهرست منبع

Release 1.3.2: make NAT destination and protocol selection clear

Proxmox Router 3 روز پیش
والد
کامیت
712d8e18d4

+ 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.
 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.1**. 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.2**. See [functionality corrections and verification](docs/functionality-fixes.md) for network persistence, firewall isolation/IPv6, service error handling and certificate renewal changes.
 
 
 ## Install
 ## Install
 
 
@@ -21,10 +21,10 @@ The internal Git server uses a private CA. Trust that CA before cloning, or repl
 ```sh
 ```sh
 git -c http.sslVerify=false \
 git -c http.sslVerify=false \
   -c 'http.pinnedpubkey=sha256//u4xyt0njDzn4BAQnS6OTGrXBxVE8vMg5BSNIuryUsMo=' \
   -c 'http.pinnedpubkey=sha256//u4xyt0njDzn4BAQnS6OTGrXBxVE8vMg5BSNIuryUsMo=' \
-  clone --depth 1 --branch v1.3.1 https://gogs.eparmak.local/eparmak/DebTik.git
+  clone --depth 1 --branch v1.3.2 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.1` 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.2` 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.
 `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.1
+1.3.2

+ 4 - 0
docs/firewall-native.md

@@ -94,3 +94,7 @@ Version 1.3.1 also restores existing country sets whose creation limits differ
 from the current defaults, using atomic swap without recreating the existing
 from the current defaults, using atomic swap without recreating the existing
 set. Startup exposes `is_restoring`; migration verification waits for all
 set. Startup exposes `is_restoring`; migration verification waits for all
 country restores and checks `last_error` before declaring success.
 country restores and checks `last_error` before declaring success.
+
+In 1.3.2 NAT action and destination fields are together on the General tab.
+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.

+ 9 - 0
internal/firewall/manager.go

@@ -125,6 +125,15 @@ func (m *FirewallManager) load() error {
 	if m.filterRules == nil {
 	if m.filterRules == nil {
 		m.filterRules = []FilterRule{}
 		m.filterRules = []FilterRule{}
 	}
 	}
+	// Older releases implicitly compiled port matches with protocol=all as TCP.
+	// Keep the existing kernel behavior and expose the actual protocol on edit.
+	for i := range m.natRules {
+		r := &m.natRules[i]
+		if (r.Protocol == "" || r.Protocol == "all") && (r.SrcPort != "" || r.DstPort != "" || r.AnyPort != "") {
+			r.Protocol = "tcp"
+			log.Printf("NAT rule %s: migrated legacy implicit TCP port match", r.ID)
+		}
+	}
 	if m.natRules == nil {
 	if m.natRules == nil {
 		m.natRules = []NATRule{}
 		m.natRules = []NATRule{}
 	}
 	}

+ 107 - 0
internal/firewall/nat_forward_test.go

@@ -0,0 +1,107 @@
+package firewall
+
+import (
+	"bufio"
+	"fmt"
+	"net"
+	"os"
+	"os/exec"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestDNSForwardingInNamespace(t *testing.T) {
+	if os.Getenv("DEBTIK_NAT_INTEGRATION") != "1" {
+		t.Skip("isolated network namespace required")
+	}
+	run := func(args ...string) {
+		t.Helper()
+		out, err := exec.Command("ip", args...).CombinedOutput()
+		if err != nil {
+			t.Fatalf("ip %v: %s: %v", args, out, err)
+		}
+	}
+	run("link", "set", "lo", "up")
+	run("addr", "add", "192.0.2.53/32", "dev", "lo")
+	run("link", "add", "fwserver", "type", "veth", "peer", "name", "fwclient")
+	run("addr", "add", "192.0.2.1/24", "dev", "fwserver")
+	run("link", "set", "fwserver", "up")
+	udp, err := net.ListenPacket("udp4", "192.0.2.53:1053")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer udp.Close()
+	tcp, err := net.Listen("tcp4", "192.0.2.53:1053")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer tcp.Close()
+	go func() {
+		b := make([]byte, 16)
+		n, a, e := udp.ReadFrom(b)
+		if e == nil {
+			udp.WriteTo(b[:n], a)
+		}
+	}()
+	go func() {
+		c, e := tcp.Accept()
+		if e == nil {
+			defer c.Close()
+			b := make([]byte, 16)
+			n, e := c.Read(b)
+			if e == nil {
+				c.Write(b[:n])
+			}
+		}
+	}()
+	rules := []NATRule{{ID: "nat-dns-udp", Chain: "dstnat", Action: "dst-nat", Protocol: "udp", DstPort: "53", ToAddresses: "192.0.2.53", ToPorts: "1053"}, {ID: "nat-dns-tcp", Chain: "dstnat", Action: "dst-nat", Protocol: "tcp", DstPort: "53", ToAddresses: "192.0.2.53", ToPorts: "1053"}}
+	if err := ApplyDualStack(nil, rules, nil); err != nil {
+		t.Fatal(err)
+	}
+	script := `import socket,subprocess,sys
+print('READY',flush=True)
+sys.stdin.readline()
+for args in [['link','set','lo','up'],['addr','add','192.0.2.2/24','dev','fwclient'],['link','set','fwclient','up'],['route','add','default','via','192.0.2.1']]: subprocess.run(['ip']+args,check=True)
+for typ in [socket.SOCK_DGRAM,socket.SOCK_STREAM]:
+ s=socket.socket(socket.AF_INET,typ);s.settimeout(3);s.connect(('198.51.100.53',53));s.sendall(b'dns');assert s.recv(3)==b'dns';s.close()
+print('UDP and TCP forwarded successfully',flush=True)`
+	cmd := exec.Command("unshare", "--net", "python3", "-u", "-c", script)
+	stdin, err := cmd.StdinPipe()
+	if err != nil {
+		t.Fatal(err)
+	}
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		t.Fatal(err)
+	}
+	cmd.Stderr = os.Stderr
+	if err = cmd.Start(); err != nil {
+		t.Fatal(err)
+	}
+	defer func() {
+		if cmd.ProcessState == nil {
+			cmd.Process.Kill()
+			cmd.Wait()
+		}
+	}()
+	reader := bufio.NewReader(stdout)
+	ready, err := reader.ReadString('\n')
+	if err != nil || strings.TrimSpace(ready) != "READY" {
+		t.Fatalf("client not ready: %q %v", ready, err)
+	}
+	run("link", "set", "fwclient", "netns", fmt.Sprint(cmd.Process.Pid))
+	fmt.Fprintln(stdin, "go")
+	stdin.Close()
+	done := make(chan error, 1)
+	go func() { done <- cmd.Wait() }()
+	select {
+	case err := <-done:
+		if err != nil {
+			t.Fatal(err)
+		}
+	case <-time.After(10 * time.Second):
+		cmd.Process.Kill()
+		t.Fatal("client timed out")
+	}
+}

+ 18 - 0
internal/firewall/native_test.go

@@ -190,3 +190,21 @@ func TestFirewallNativeKernel(t *testing.T) {
 	}
 	}
 	t.Log("Native firewall integration complete")
 	t.Log("Native firewall integration complete")
 }
 }
+func TestNATPortProtocolMustBeExplicit(t *testing.T) {
+	r := NATRule{Chain: "dstnat", Action: "dst-nat", Protocol: "all", DstPort: "53", ToAddresses: "192.0.2.53"}
+	if err := validateRule("nat", r); err == nil || !strings.Contains(err.Error(), "TCP or UDP") {
+		t.Fatalf("protocol all accepted: %v", err)
+	}
+	for _, protocol := range []string{"tcp", "udp"} {
+		r.Protocol = protocol
+		if err := validateRule("nat", r); err != nil {
+			t.Fatal(err)
+		}
+		payload := BuildIPTablesRestore(nil, []NATRule{r}, nil)
+		for _, want := range []string{"-p " + protocol, "--dport 53", "-j DNAT --to-destination 192.0.2.53"} {
+			if !strings.Contains(payload, want) {
+				t.Fatalf("missing %s: %s", want, payload)
+			}
+		}
+	}
+}

+ 7 - 0
internal/firewall/validation.go

@@ -232,6 +232,13 @@ func validateRule(kind string, rule any) error {
 		return err
 		return err
 	}
 	}
 	if kind == "nat" {
 	if kind == "nat" {
+		if text(v, "src_port") != "" || text(v, "dst_port") != "" || text(v, "any_port") != "" || text(v, "to_ports") != "" {
+			_, protocol := plain(text(v, "protocol"))
+			if protocol != "tcp" && protocol != "udp" {
+				return fmt.Errorf("NAT ports require an explicit TCP or UDP protocol; create separate rules for both")
+			}
+		}
+
 		if action == "netmap" && text(v, "to_ports") != "" {
 		if action == "netmap" && text(v, "to_ports") != "" {
 			return fmt.Errorf("netmap translates addresses only; use src-nat/dst-nat to translate ports")
 			return fmt.Errorf("netmap translates addresses only; use src-nat/dst-nat to translate ports")
 		}
 		}

+ 9 - 1
internal/web/static/app.js

@@ -4793,6 +4793,7 @@ function renderNATRules() {
 // ==================== FIREWALL NAT MODAL (DEBTIK WEBFIG / WINBOX V4) ====================
 // ==================== FIREWALL NAT MODAL (DEBTIK WEBFIG / WINBOX V4) ====================
 
 
 function switchNATRuleTab(tab) {
 function switchNATRuleTab(tab) {
+  if (!document.getElementById(`nat-pane-${tab}`)) tab = 'general';
   state.activeNATRuleTab = tab;
   state.activeNATRuleTab = tab;
   document.querySelectorAll('.nat-rule-tab-btn').forEach(btn => {
   document.querySelectorAll('.nat-rule-tab-btn').forEach(btn => {
     btn.classList.remove('bg-[#182638]', 'text-white', 'border-sky-400');
     btn.classList.remove('bg-[#182638]', 'text-white', 'border-sky-400');
@@ -4921,7 +4922,7 @@ function toggleNATActionFields() {
 
 
   if (['src-nat', 'dst-nat', 'netmap'].includes(act)) {
   if (['src-nat', 'dst-nat', 'netmap'].includes(act)) {
     if (rowAddr) rowAddr.classList.remove('hidden');
     if (rowAddr) rowAddr.classList.remove('hidden');
-    if (rowPort) rowPort.classList.remove('hidden');
+    if (rowPort && act !== 'netmap') rowPort.classList.remove('hidden');
   } else if (act === 'redirect' || act === 'masquerade') {
   } else if (act === 'redirect' || act === 'masquerade') {
     if (rowPort) rowPort.classList.remove('hidden');
     if (rowPort) rowPort.classList.remove('hidden');
   } else if (act === 'jump') {
   } else if (act === 'jump') {
@@ -5164,6 +5165,13 @@ async function applyNATRule(closeAfter = false) {
 
 
   const usesAddr = ['src-nat', 'dst-nat', 'netmap'].includes(action);
   const usesAddr = ['src-nat', 'dst-nat', 'netmap'].includes(action);
   const usesPort = ['src-nat', 'dst-nat', 'redirect', 'masquerade'].includes(action);
   const usesPort = ['src-nat', 'dst-nat', 'redirect', 'masquerade'].includes(action);
+  if (!chain.trim()) { switchNATRuleTab('general'); showToast('NAT rule', 'Enter a chain.', 'error'); document.getElementById('nat-modal-chain').focus(); return; }
+  if (usesAddr && !document.getElementById('nat-modal-toaddr').value.trim()) { switchNATRuleTab('general'); showToast('NAT rule', 'Enter the destination IP / address for this action.', 'error'); document.getElementById('nat-modal-toaddr').focus(); return; }
+  if ((sport || dport || anyport || (usesPort && document.getElementById('nat-modal-toport').value.trim())) && !['tcp', 'udp'].includes(proto)) {
+    switchNATRuleTab('general'); toggleNATField('proto', true); document.getElementById('nat-val-proto').value = proto || 'all';
+    showToast('Select protocol', 'Port rules require TCP or UDP. For DNS, create one UDP and one TCP rule.', 'error'); document.getElementById('nat-val-proto').focus(); return;
+  }
+
 
 
   const payload = {
   const payload = {
     ip_version: Number(document.getElementById('nat-modal-family').value),
     ip_version: Number(document.getElementById('nat-modal-family').value),

+ 88 - 92
internal/web/static/index.html

@@ -6661,7 +6661,6 @@
         <button type="button" onclick="switchNATRuleTab('general')" id="nat-tab-btn-general" class="nat-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="switchNATRuleTab('general')" id="nat-tab-btn-general" class="nat-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="switchNATRuleTab('advanced')" id="nat-tab-btn-advanced" class="nat-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="switchNATRuleTab('advanced')" id="nat-tab-btn-advanced" class="nat-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="switchNATRuleTab('extra')" id="nat-tab-btn-extra" class="nat-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="switchNATRuleTab('extra')" id="nat-tab-btn-extra" class="nat-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="switchNATRuleTab('action')" id="nat-tab-btn-action" class="nat-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="switchNATRuleTab('statistics')" id="nat-tab-btn-statistics" class="nat-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>
         <button type="button" onclick="switchNATRuleTab('statistics')" id="nat-tab-btn-statistics" class="nat-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>
       </div>
 
 
@@ -6669,7 +6668,7 @@
       <div class="flex-1 flex min-h-0 overflow-hidden">
       <div class="flex-1 flex min-h-0 overflow-hidden">
 
 
         <!-- Form Content Container (Scrollable) -->
         <!-- 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">
+        <form id="nat-rule-form" novalidate 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">
           <input type="hidden" id="nat-modal-id">
           <label class="block text-xs text-slate-300">IP family<select id="nat-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="nat-modal-family" class="bg-slate-800 rounded p-2 ml-2"><option value="4">IPv4</option><option value="6">IPv6</option></select></label>
 
 
@@ -6695,13 +6694,99 @@
             <div class="flex items-center text-xs py-1">
             <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>
               <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">
               <div class="flex-1">
-                <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">
+                <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="srcnat" selected>srcnat</option>
                   <option value="srcnat" selected>srcnat</option>
                   <option value="dstnat">dstnat</option>
                   <option value="dstnat">dstnat</option>
                 </datalist>
                 </datalist>
               </div>
               </div>
             </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>
+              <div class="flex-1">
+                <select id="nat-modal-action" onchange="toggleNATActionFields()" 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="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="src-nat">src-nat</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>
+              <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>
+            </div>
+
+            <!-- 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>
+              <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>
+            </div>
+
+            <!-- Jump Target (Shown when action === jump) -->
+            <div id="nat-action-jump-box" class="hidden flex items-center text-xs py-1">
+              <label class="w-36 sm:w-44 text-right pr-3.5 text-sky-300 shrink-0 select-none">Jump Target</label>
+              <div class="flex-1">
+                <input type="text" id="nat-modal-jumptarget" placeholder="chain name" 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>
+
+            <!-- Address List & Timeout (Shown when action starts with add-) -->
+            <div id="nat-action-addrlist-box" class="hidden space-y-1">
+              <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">Address List</label>
+                <div class="flex-1">
+                  <input type="text" id="nat-modal-action-addrlist" list="fw-addrlist-datalist" placeholder="list name" 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>
+              <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">Timeout</label>
+                <div class="flex-1">
+                  <input type="text" id="nat-modal-action-timeout" placeholder="none or 1d 00:00:00" 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>
+            </div>
+
+            <hr class="border-[#213042] my-2">
+
+            <!-- Log -->
+            <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">Log</label>
+              <div class="flex items-center">
+                <input type="checkbox" id="nat-modal-log" onchange="toggleNATLogPrefix()" class="rounded bg-[#15202e] border-[#26374d] text-sky-500 focus:ring-0 w-4 h-4 cursor-pointer">
+              </div>
+            </div>
+
+            <!-- Log Prefix -->
+            <div class="flex items-center text-xs py-1" id="nat-row-logprefix">
+              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Log Prefix</label>
+              <div class="flex-1 flex items-center gap-1.5 min-w-0">
+                <button type="button" onclick="toggleNATField('logprefix', true)" id="nat-plus-logprefix" 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-logprefix" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
+                  <input type="text" id="nat-val-logprefix" placeholder="e.g. NAT_LOG: " 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('logprefix', 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. Address -->
             <!-- Src. Address -->
             <div class="flex items-center text-xs py-1" id="nat-row-src">
             <div class="flex items-center text-xs py-1" id="nat-row-src">
               <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Src. Address</label>
               <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Src. Address</label>
@@ -7260,95 +7345,6 @@
 
 
           </div>
           </div>
 
 
-          <!-- TAB PANE: ACTION (Screenshot 3) -->
-          <div id="nat-pane-action" class="nat-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>
-              <div class="flex-1">
-                <select id="nat-modal-action" onchange="toggleNATActionFields()" 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="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="src-nat">src-nat</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">To Addresses</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>
-            </div>
-
-            <!-- 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">To Ports</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>
-            </div>
-
-            <!-- Jump Target (Shown when action === jump) -->
-            <div id="nat-action-jump-box" class="hidden flex items-center text-xs py-1">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-sky-300 shrink-0 select-none">Jump Target</label>
-              <div class="flex-1">
-                <input type="text" id="nat-modal-jumptarget" placeholder="chain name" 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>
-
-            <!-- Address List & Timeout (Shown when action starts with add-) -->
-            <div id="nat-action-addrlist-box" class="hidden space-y-1">
-              <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">Address List</label>
-                <div class="flex-1">
-                  <input type="text" id="nat-modal-action-addrlist" list="fw-addrlist-datalist" placeholder="list name" 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>
-              <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">Timeout</label>
-                <div class="flex-1">
-                  <input type="text" id="nat-modal-action-timeout" placeholder="none or 1d 00:00:00" 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>
-            </div>
-
-            <hr class="border-[#213042] my-2">
-
-            <!-- Log -->
-            <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">Log</label>
-              <div class="flex items-center">
-                <input type="checkbox" id="nat-modal-log" onchange="toggleNATLogPrefix()" class="rounded bg-[#15202e] border-[#26374d] text-sky-500 focus:ring-0 w-4 h-4 cursor-pointer">
-              </div>
-            </div>
-
-            <!-- Log Prefix -->
-            <div class="flex items-center text-xs py-1" id="nat-row-logprefix">
-              <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Log Prefix</label>
-              <div class="flex-1 flex items-center gap-1.5 min-w-0">
-                <button type="button" onclick="toggleNATField('logprefix', true)" id="nat-plus-logprefix" 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-logprefix" class="hidden flex-1 flex items-center gap-1.5 min-w-0">
-                  <input type="text" id="nat-val-logprefix" placeholder="e.g. NAT_LOG: " 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('logprefix', 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: STATISTICS (Screenshot 4) -->
           <!-- TAB PANE: STATISTICS (Screenshot 4) -->
           <div id="nat-pane-statistics" class="nat-rule-pane hidden space-y-2 py-1">
           <div id="nat-pane-statistics" class="nat-rule-pane hidden space-y-2 py-1">
             <!-- Bytes -->
             <!-- Bytes -->

+ 10 - 0
scripts/firewall-smoke.cjs

@@ -22,6 +22,16 @@ const assert=require('node:assert/strict');const fs=require('fs');
   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.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-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(()=>{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();document.getElementById('nat-modal-chain').value='dstnat';handleNATChainChange()});
+  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');
+  await page.evaluate(()=>toggleNATField('dport',true)); await page.locator('#nat-val-dport').fill('53');
+  let beforeNAT=writes.length;await page.evaluate(()=>applyNATRule(true));assert.equal(writes.length,beforeNAT);
+  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');
+  passed.push('DNAT destination IP visible and DNS port requires explicit protocol');
   await page.evaluate(()=>handleLogoutClick());assert.deepEqual(errors,[]);console.log(JSON.stringify({passed,errors,mockedWrites:writes.length},null,2));
   await page.evaluate(()=>handleLogoutClick());assert.deepEqual(errors,[]);console.log(JSON.stringify({passed,errors,mockedWrites:writes.length},null,2));
  }finally{await browser.close()}
  }finally{await browser.close()}
 })().catch(e=>{console.error(e);process.exitCode=1});
 })().catch(e=>{console.error(e);process.exitCode=1});