فهرست منبع

Expose and validate firewall chain placement

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

+ 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.3**. 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.4**. 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.3 https://gogs.eparmak.local/eparmak/DebTik.git
+  clone --depth 1 --branch v1.3.4 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.3` 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.4` 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.3
+1.3.4

+ 1 - 1
docs/firewall-native.md

@@ -99,4 +99,4 @@ 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.
+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.

+ 8 - 2
internal/firewall/manager.go

@@ -349,6 +349,9 @@ func (m *FirewallManager) ListFilterRules() []FilterRule {
 func (m *FirewallManager) SaveFilterRule(r FilterRule) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
+	if err := validateChainPlacement("filter", r); err != nil {
+		return err
+	}
 
 	if r.ID == "" {
 		r.ID = fmt.Sprintf("filter-%d", time.Now().UnixNano())
@@ -452,8 +455,8 @@ 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 err := validateChainPlacement("nat", r); err != nil {
+		return err
 	}
 
 	if r.ID == "" {
@@ -558,6 +561,9 @@ func (m *FirewallManager) ListMangleRules() []MangleRule {
 func (m *FirewallManager) SaveMangleRule(r MangleRule) error {
 	m.mu.Lock()
 	defer m.mu.Unlock()
+	if err := validateChainPlacement("mangle", r); err != nil {
+		return err
+	}
 
 	if r.ID == "" {
 		r.ID = fmt.Sprintf("mangle-%d", time.Now().UnixNano())

+ 33 - 8
internal/firewall/nat_chain_test.go

@@ -5,15 +5,40 @@ import (
 	"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)
+func TestChainPlacement(t *testing.T) {
+	cases := []struct {
+		kind  string
+		rule  any
+		valid bool
+	}{
+		{"nat", NATRule{Chain: "dstnat", Action: "dst-nat"}, true},
+		{"nat", NATRule{Chain: "prerouting", Action: "dst-nat"}, true},
+		{"nat", NATRule{Chain: "output", Action: "dst-nat"}, true},
+		{"nat", NATRule{Chain: "custom", Action: "dst-nat"}, true},
+		{"nat", NATRule{Chain: "srcnat", Action: "dst-nat"}, false},
+		{"nat", NATRule{Chain: "forward", Action: "accept"}, false},
+		{"nat", NATRule{Chain: "srcnat", Action: "masquerade", OutInterface: "eth0"}, true},
+		{"nat", NATRule{Chain: "input", Action: "masquerade"}, false},
+		{"nat", NATRule{Chain: "prerouting", Action: "accept", OutInterface: "eth0"}, false},
+		{"filter", FilterRule{Chain: "prerouting", Action: "accept"}, false},
+		{"filter", FilterRule{Chain: "input", Action: "accept", OutInterface: "eth0"}, false},
+		{"mangle", MangleRule{Chain: "output", Action: "accept", InInterface: "eth0"}, false},
+		{"mangle", MangleRule{Chain: "forward", Action: "accept", InInterface: "eth0", OutInterface: "eth1"}, true},
+	}
+	for _, c := range cases {
+		err := validateChainPlacement(c.kind, c.rule)
+		if (err == nil) != c.valid {
+			t.Errorf("%s %#v: %v", c.kind, c.rule, err)
 		}
-		if len(m.natRules) != 0 {
-			t.Fatal("rejected rule was saved")
+	}
+}
+
+func TestNATChainHooks(t *testing.T) {
+	rules := []NATRule{{ID: "incoming", Chain: "dstnat", Action: "dst-nat", ToAddresses: "192.0.2.1"}, {ID: "local", Chain: "output", Action: "dst-nat", ToAddresses: "192.0.2.2"}, {ID: "outgoing", Chain: "srcnat", Action: "masquerade"}}
+	payload := ownedPayload(BuildIPTablesRestore(nil, rules, nil), "")
+	for _, want := range []string{"-A DT_nat_PREROUTING ", "-A DT_nat_OUTPUT ", "-A DT_nat_POSTROUTING ", "-I PREROUTING 1 -m comment --comment debtik:hook -j DT_nat_PREROUTING", "-I OUTPUT 1 -m comment --comment debtik:hook -j DT_nat_OUTPUT", "-I POSTROUTING 1 -m comment --comment debtik:hook -j DT_nat_POSTROUTING"} {
+		if !strings.Contains(payload, want) {
+			t.Errorf("missing %s", want)
 		}
 	}
 }

+ 41 - 0
internal/firewall/nat_forward_test.go

@@ -105,3 +105,44 @@ print('UDP and TCP forwarded successfully',flush=True)`
 		t.Fatal("client timed out")
 	}
 }
+
+func TestLocalOutputDNATInNamespace(t *testing.T) {
+	if os.Getenv("DEBTIK_NAT_INTEGRATION") != "1" {
+		t.Skip("isolated network namespace required")
+	}
+	if out, err := exec.Command("ip", "link", "set", "lo", "up").CombinedOutput(); err != nil {
+		t.Fatalf("loopback: %s: %v", out, err)
+	}
+	server, err := net.ListenPacket("udp4", "127.0.0.2:1053")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer server.Close()
+	go func() {
+		b := make([]byte, 16)
+		n, a, e := server.ReadFrom(b)
+		if e == nil {
+			server.WriteTo(b[:n], a)
+		}
+	}()
+	rule := NATRule{ID: "nat-local", Chain: "output", Action: "dst-nat", Protocol: "udp", DstAddress: "127.0.0.1", DstPort: "53", ToAddresses: "127.0.0.2", ToPorts: "1053"}
+	if err := ValidateRule("nat", rule); err != nil {
+		t.Fatal(err)
+	}
+	if err := ApplyDualStack(nil, []NATRule{rule}, nil); err != nil {
+		t.Fatal(err)
+	}
+	client, err := net.DialTimeout("udp4", "127.0.0.1:53", 2*time.Second)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer client.Close()
+	client.SetDeadline(time.Now().Add(2 * time.Second))
+	if _, err := client.Write([]byte("dns")); err != nil {
+		t.Fatal(err)
+	}
+	b := make([]byte, 3)
+	if n, err := client.Read(b); err != nil || string(b[:n]) != "dns" {
+		t.Fatalf("local DNAT response %q: %v", b[:n], err)
+	}
+}

+ 51 - 1
internal/firewall/validation.go

@@ -360,5 +360,55 @@ func ValidateRule(kind string, rule any) error {
 	if kind != "filter" && kind != "nat" && kind != "mangle" {
 		return fmt.Errorf("unknown firewall rule kind")
 	}
-	return validateRule(kind, rule)
+	if err := validateRule(kind, rule); err != nil {
+		return err
+	}
+	return validateChainPlacement(kind, 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 kind != "nat" && (chain == "srcnat" || chain == "dstnat") {
+		return fmt.Errorf("chain %s is available in the nat table only", chain)
+	}
+	if kind == "nat" {
+		switch chain {
+		case "dstnat":
+			chain = "prerouting"
+		case "srcnat":
+			chain = "postrouting"
+		}
+	}
+	reserved := map[string]bool{"input": true, "output": true, "forward": true, "prerouting": true, "postrouting": true}
+	if !reserved[chain] {
+		return nil
+	} // User chains are reached through an explicit jump.
+	if kind == "filter" && (chain == "prerouting" || chain == "postrouting") || kind == "nat" && chain == "forward" {
+		return fmt.Errorf("chain %s is not available in the %s table", chain, kind)
+	}
+	if (chain == "output" || chain == "postrouting") && (text(v, "in_interface") != "" || text(v, "in_interface_list") != "" || text(v, "in_bridge_port") != "") {
+		return fmt.Errorf("chain %s cannot match an incoming interface", chain)
+	}
+	if (chain == "input" || chain == "prerouting") && (text(v, "out_interface") != "" || text(v, "out_interface_list") != "" || text(v, "out_bridge_port") != "") {
+		return fmt.Errorf("chain %s cannot match an outgoing interface", chain)
+	}
+	if kind == "nat" {
+		switch text(v, "action") {
+		case "dst-nat", "redirect":
+			if chain != "prerouting" && chain != "output" {
+				return fmt.Errorf("%s requires prerouting/dstnat or output", text(v, "action"))
+			}
+		case "masquerade":
+			if chain != "postrouting" {
+				return fmt.Errorf("masquerade requires postrouting/srcnat")
+			}
+		case "src-nat":
+			if chain != "postrouting" && chain != "input" {
+				return fmt.Errorf("src-nat requires postrouting/srcnat or input")
+			}
+		}
+	}
+	return nil
 }

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

@@ -4941,7 +4941,7 @@ 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';
+  if (action === 'dst-nat' && chain && ['srcnat', 'postrouting', 'input'].includes(chain.value.toLowerCase())) chain.value = 'dstnat';
   toggleNATActionFields();
 }
 
@@ -5173,7 +5173,6 @@ 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';

+ 9 - 2
internal/web/static/index.html

@@ -5835,6 +5835,7 @@
                   <option value="input">input</option>
                   <option value="output">output</option>
                 </datalist>
+                <p class="mt-1 text-slate-400">input: to this machine; forward: through it; output: from this machine. Custom chains require a jump.</p>
               </div>
             </div>
 
@@ -6695,9 +6696,14 @@
               <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="srcnat" selected>srcnat</option>
-                  <option value="dstnat">dstnat</option>
+                  <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>
                 </datalist>
+                <p class="mt-1 text-slate-400">dstnat = prerouting; srcnat = postrouting. Custom chains require a jump from an active chain.</p>
               </div>
             </div>
 
@@ -7522,6 +7528,7 @@
                   <option value="input">input</option>
                   <option value="output">output</option>
                 </datalist>
+                <p class="mt-1 text-slate-400">Choose where packets are processed. Custom chains require a jump from an active chain.</p>
               </div>
             </div>
 

+ 5 - 0
scripts/firewall-smoke.cjs

@@ -26,6 +26,11 @@ const assert=require('node:assert/strict');const fs=require('fs');
   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='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()});
   assert.equal(await page.locator('#nat-modal-action').inputValue(),'dst-nat');
   assert.equal(await page.locator('#nat-modal-toaddr').isVisible(),true);