Browse Source

Ensure destination NAT uses dstnat chain

Proxmox Router 2 ngày trước cách đây
mục cha
commit
6c8faaced6

+ 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.2**. See [functionality corrections and verification](docs/functionality-fixes.md) for network persistence, firewall isolation/IPv6, service error handling and certificate renewal changes.
+Current release: **1.3.3**. 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.2 https://gogs.eparmak.local/eparmak/DebTik.git
+  clone --depth 1 --branch v1.3.3 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.2` 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.3` 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.2
+1.3.3

+ 2 - 0
docs/firewall-native.md

@@ -98,3 +98,5 @@ 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.
+
+In 1.3.3 selecting the `dst-nat` action sets the `dstnat` chain automatically. New or edited `dst-nat` rules must use that chain; previously saved rules are left intact. The `dstnat` chain maps to Linux NAT PREROUTING.

+ 3 - 0
internal/firewall/manager.go

@@ -452,6 +452,9 @@ func (m *FirewallManager) ListNATRules() []NATRule {
 func (m *FirewallManager) SaveNATRule(r NATRule) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
+	if strings.EqualFold(strings.TrimSpace(r.Action), "dst-nat") && !strings.EqualFold(strings.TrimSpace(r.Chain), "dstnat") {
+		return fmt.Errorf("dst-nat action requires the dstnat chain")
+	}
 
 	if r.ID == "" {
 		r.ID = fmt.Sprintf("nat-%d", time.Now().UnixNano())

+ 19 - 0
internal/firewall/nat_chain_test.go

@@ -0,0 +1,19 @@
+package firewall
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestSaveDNATRequiresDstnatChain(t *testing.T) {
+	m := &FirewallManager{}
+	for _, chain := range []string{"srcnat", "custom", "output"} {
+		err := m.SaveNATRule(NATRule{Chain: chain, Action: "dst-nat"})
+		if err == nil || !strings.Contains(err.Error(), "dstnat chain") {
+			t.Fatalf("chain %s: expected dstnat error, got %v", chain, err)
+		}
+		if len(m.natRules) != 0 {
+			t.Fatal("rejected rule was saved")
+		}
+	}
+}

+ 8 - 0
internal/web/static/app.js

@@ -4938,6 +4938,13 @@ function toggleNATActionFields() {
   }
 }
 
+function handleNATActionChange() {
+  const action = document.getElementById('nat-modal-action')?.value;
+  const chain = document.getElementById('nat-modal-chain');
+  if (action === 'dst-nat' && chain) chain.value = 'dstnat';
+  toggleNATActionFields();
+}
+
 function toggleNATLogPrefix() {
   const isLog = document.getElementById('nat-modal-log')?.checked;
   toggleNATField('logprefix', !!isLog);
@@ -5166,6 +5173,7 @@ 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 (action === 'dst-nat' && chain.trim().toLowerCase() !== 'dstnat') { switchNATRuleTab('general'); showToast('NAT rule', 'The dst-nat action requires the dstnat 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';

+ 1 - 1
internal/web/static/index.html

@@ -6705,7 +6705,7 @@
             <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">
+                <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>

+ 4 - 0
scripts/firewall-smoke.cjs

@@ -22,6 +22,10 @@ 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());
+  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');
   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);