Explorar el Código

Ensure destination NAT uses dstnat chain

Proxmox Router hace 3 días
padre
commit
ce63a0b386

+ 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.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
 ## 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.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.
 `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.
 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
 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.
 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 {
 func (m *FirewallManager) SaveNATRule(r NATRule) error {
 	m.mu.Lock()
 	m.mu.Lock()
 	defer m.mu.Unlock()
 	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 == "" {
 	if r.ID == "" {
 		r.ID = fmt.Sprintf("nat-%d", time.Now().UnixNano())
 		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() {
 function toggleNATLogPrefix() {
   const isLog = document.getElementById('nat-modal-log')?.checked;
   const isLog = document.getElementById('nat-modal-log')?.checked;
   toggleNATField('logprefix', !!isLog);
   toggleNATField('logprefix', !!isLog);
@@ -5166,6 +5173,7 @@ 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 (!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 (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)) {
   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';
     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">
             <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">Action</label>
               <div class="flex-1">
               <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="accept">accept</option>
                   <option value="add-dst-to-address-list">add dst to address list</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="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.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());
+  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()});
   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-action').inputValue(),'dst-nat');
   assert.equal(await page.locator('#nat-modal-toaddr').isVisible(),true);
   assert.equal(await page.locator('#nat-modal-toaddr').isVisible(),true);