Browse Source

Release 1.3.2: make NAT destination and protocol selection clear

Proxmox Router 2 days ago
parent
commit
d3b5d66ef8

+ 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.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
 
@@ -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.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.
 

+ 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
 set. Startup exposes `is_restoring`; migration verification waits for all
 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 {
 		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 {
 		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")
 }
+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
 	}
 	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") != "" {
 			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) ====================
 
 function switchNATRuleTab(tab) {
+  if (!document.getElementById(`nat-pane-${tab}`)) tab = 'general';
   state.activeNATRuleTab = tab;
   document.querySelectorAll('.nat-rule-tab-btn').forEach(btn => {
     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 (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') {
     if (rowPort) rowPort.classList.remove('hidden');
   } else if (act === 'jump') {
@@ -5164,6 +5165,13 @@ async function applyNATRule(closeAfter = false) {
 
   const usesAddr = ['src-nat', 'dst-nat', 'netmap'].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 = {
     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('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('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>
       </div>
 
@@ -6669,7 +6668,7 @@
       <div class="flex-1 flex min-h-0 overflow-hidden">
 
         <!-- Form Content Container (Scrollable) -->
-        <form id="nat-rule-form" onsubmit="event.preventDefault(); applyNATRule(true);" class="flex-1 overflow-y-auto px-4 py-3 min-w-0 space-y-1">
+        <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">
           <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">
               <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" 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="dstnat">dstnat</option>
                 </datalist>
               </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 -->
             <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>
@@ -7260,95 +7345,6 @@
 
           </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) -->
           <div id="nat-pane-statistics" class="nat-rule-pane hidden space-y-2 py-1">
             <!-- 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.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(()=>{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));
  }finally{await browser.close()}
 })().catch(e=>{console.error(e);process.exitCode=1});