Fix legacy dynamic route NAT missing v6 duplicate

The legacy DNS resolver path creates NAT pairs with destination
0.0.0.0/0 (a prefix, not a DomainSet). The v6 NAT duplication only
triggered for DomainSets, so legacy dynamic routes never got a v6
NAT rule.

Extract NeedsV6NATDuplicate and ToV6NatPair helpers that detect both
DomainSets and the v4 default wildcard 0.0.0.0/0. Both nftables and
iptables managers now use these for Add/RemoveNatRule, ensuring v6
NAT duplication works for both modern and legacy DNS resolver paths.
This commit is contained in:
Viktor Liu
2026-04-10 12:59:35 +02:00
parent 8ddbcf6c5b
commit 567f36b07e
3 changed files with 40 additions and 15 deletions

View File

@@ -272,10 +272,11 @@ func (m *Manager) AddNatRule(pair firewall.RouterPair) error {
return err
}
// Dynamic routes need NAT in both tables
if m.hasIPv6() && pair.Destination.IsSet() {
v6Pair := pair
v6Pair.Source = firewall.Network{Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 0)}
// Dynamic routes need NAT in both tables since resolved IPs can be
// either v4 or v6. This covers both DomainSet (modern) and the legacy
// wildcard 0.0.0.0/0 destination where the client resolves DNS.
if m.hasIPv6() && firewall.NeedsV6NATDuplicate(pair) {
v6Pair := firewall.ToV6NatPair(pair)
if err := m.router6.AddNatRule(v6Pair); err != nil {
return fmt.Errorf("add v6 NAT rule: %w", err)
}
@@ -299,9 +300,8 @@ func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error {
return err
}
if m.hasIPv6() && pair.Destination.IsSet() {
v6Pair := pair
v6Pair.Source = firewall.Network{Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 0)}
if m.hasIPv6() && firewall.NeedsV6NATDuplicate(pair) {
v6Pair := firewall.ToV6NatPair(pair)
if err := m.router6.RemoveNatRule(v6Pair); err != nil {
return fmt.Errorf("remove v6 NAT rule: %w", err)
}

View File

@@ -1,6 +1,8 @@
package manager
import (
"net/netip"
"github.com/netbirdio/netbird/route"
)
@@ -22,3 +24,27 @@ func GetInversePair(pair RouterPair) RouterPair {
Inverse: true,
}
}
// NeedsV6NATDuplicate reports whether a v4 NAT pair should be duplicated to
// the v6 table. This is true for DomainSets (resolved IPs can be either
// family) and for the v4 default wildcard 0.0.0.0/0 used by the legacy DNS
// resolver path for dynamic routes.
func NeedsV6NATDuplicate(pair RouterPair) bool {
if pair.Destination.IsSet() {
return true
}
return pair.Destination.IsPrefix() &&
pair.Destination.Prefix.Bits() == 0 &&
pair.Destination.Prefix.Addr().Is4()
}
// ToV6NatPair creates a v6 counterpart of a v4 NAT pair with `::/0` source
// and, for prefix destinations, `::/0` destination.
func ToV6NatPair(pair RouterPair) RouterPair {
v6 := pair
v6.Source = Network{Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 0)}
if v6.Destination.IsPrefix() {
v6.Destination = Network{Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 0)}
}
return v6
}

View File

@@ -327,11 +327,11 @@ func (m *Manager) AddNatRule(pair firewall.RouterPair) error {
return err
}
// Dynamic routes (DomainSet) need NAT in both tables since resolved IPs
// can be either v4 or v6.
if m.hasIPv6() && pair.Destination.IsSet() {
v6Pair := pair
v6Pair.Source = firewall.Network{Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 0)}
// Dynamic routes need NAT in both tables since resolved IPs can be
// either v4 or v6. This covers both DomainSet (modern) and the legacy
// wildcard 0.0.0.0/0 destination where the client resolves DNS.
if m.hasIPv6() && firewall.NeedsV6NATDuplicate(pair) {
v6Pair := firewall.ToV6NatPair(pair)
if err := m.router6.AddNatRule(v6Pair); err != nil {
return fmt.Errorf("add v6 NAT rule: %w", err)
}
@@ -355,9 +355,8 @@ func (m *Manager) RemoveNatRule(pair firewall.RouterPair) error {
return err
}
if m.hasIPv6() && pair.Destination.IsSet() {
v6Pair := pair
v6Pair.Source = firewall.Network{Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 0)}
if m.hasIPv6() && firewall.NeedsV6NATDuplicate(pair) {
v6Pair := firewall.ToV6NatPair(pair)
if err := m.router6.RemoveNatRule(v6Pair); err != nil {
return fmt.Errorf("remove v6 NAT rule: %w", err)
}