diff --git a/client/firewall/iptables/router_linux.go b/client/firewall/iptables/router_linux.go index 1fe4c149f9d..624b4d24fb7 100644 --- a/client/firewall/iptables/router_linux.go +++ b/client/firewall/iptables/router_linux.go @@ -259,16 +259,25 @@ func (r *router) AddNatRule(pair firewall.RouterPair) error { } } - if !pair.Masquerade { - return nil - } + if pair.Masquerade { + if err := r.addNatRule(pair); err != nil { + return fmt.Errorf("add nat rule: %w", err) + } - if err := r.addNatRule(pair); err != nil { - return fmt.Errorf("add nat rule: %w", err) - } + if err := r.addNatRule(firewall.GetInversePair(pair)); err != nil { + return fmt.Errorf("add inverse nat rule: %w", err) + } + } else { + // Insert RETURN rules at the head of the postrouting NAT chain for both directions, + // preventing the exit node's catch-all masquerade from rewriting the source IP + // for routes with masquerade disabled. + if err := r.addNoMasqPostRoutingRule(pair); err != nil { + return fmt.Errorf("add no-masquerade postrouting rule: %w", err) + } - if err := r.addNatRule(firewall.GetInversePair(pair)); err != nil { - return fmt.Errorf("add inverse nat rule: %w", err) + if err := r.addNoMasqPostRoutingRule(firewall.GetInversePair(pair)); err != nil { + return fmt.Errorf("add inverse no-masquerade postrouting rule: %w", err) + } } r.updateState() @@ -286,6 +295,14 @@ func (r *router) RemoveNatRule(pair firewall.RouterPair) error { if err := r.removeNatRule(firewall.GetInversePair(pair)); err != nil { return fmt.Errorf("remove inverse nat rule: %w", err) } + } else { + if err := r.removeNoMasqPostRoutingRule(pair); err != nil { + return err + } + + if err := r.removeNoMasqPostRoutingRule(firewall.GetInversePair(pair)); err != nil { + return err + } } if err := r.removeLegacyRouteRule(pair); err != nil { @@ -502,6 +519,69 @@ func (r *router) cleanupDataPlaneMark() error { return nberrors.FormatErrorOrNil(merr) } +// addNoMasqPostRoutingRule inserts a RETURN rule at position 1 of the postrouting +// NAT chain for the given destination. The mark match scopes the rule to exit-node +// traffic only, ensuring routes with masquerade=false are not masqueraded by the +// exit node's catch-all mark-based masquerade rule. +func (r *router) addNoMasqPostRoutingRule(pair firewall.RouterPair) error { + ruleKey := firewall.GenKey(firewall.NoMasqPostRoutingFormat, pair) + if rule, exists := r.rules[ruleKey]; exists { + if err := r.iptablesClient.DeleteIfExists(tableNat, chainRTNAT, rule...); err != nil { + return fmt.Errorf("remove existing no-masq rule before reinstall: %w", err) + } + if err := r.decrementSetCounter(rule); err != nil { + log.Warnf("decrement set counter for reinstalled rule %s: %v", ruleKey, err) + } + delete(r.rules, ruleKey) + } + + destExp, err := r.applyNetwork("-d", pair.Destination, nil) + if err != nil { + return fmt.Errorf("apply destination: %w", err) + } + + // Select the correct fwmark based on traffic direction: + // forward (wt0→eth0) uses PreroutingFwmarkMasquerade, + // inverse (eth0→wt0) uses PreroutingFwmarkMasqueradeReturn. + markValue := uint32(nbnet.PreroutingFwmarkMasquerade) + if pair.Inverse { + markValue = nbnet.PreroutingFwmarkMasqueradeReturn + } + + // Match exit-node mark + destination, then RETURN to skip the blanket masquerade. + rule := []string{"-m", "mark", "--mark", fmt.Sprintf("%#x", markValue)} + rule = append(rule, destExp...) + rule = append(rule, "-j", "RETURN") + + if err := r.iptablesClient.Insert(tableNat, chainRTNAT, 1, rule...); err != nil { + return fmt.Errorf("add no-masquerade return rule for %s: %w", pair.Destination, err) + } + + r.rules[ruleKey] = rule + return nil +} + +func (r *router) removeNoMasqPostRoutingRule(pair firewall.RouterPair) error { + ruleKey := firewall.GenKey(firewall.NoMasqPostRoutingFormat, pair) + + rule, exists := r.rules[ruleKey] + if !exists { + log.Debugf("no-masquerade postrouting rule %s not found", ruleKey) + return nil + } + + if err := r.iptablesClient.DeleteIfExists(tableNat, chainRTNAT, rule...); err != nil { + return fmt.Errorf("remove no-masquerade return rule %s: %w", ruleKey, err) + } + delete(r.rules, ruleKey) + + if err := r.decrementSetCounter(rule); err != nil { + return fmt.Errorf("decrement ipset counter for %s: %w", ruleKey, err) + } + + return nil +} + func (r *router) addPostroutingRules() error { // First rule for outbound masquerade rule1 := []string{ diff --git a/client/firewall/manager/firewall.go b/client/firewall/manager/firewall.go index 3511a54630d..66961a46311 100644 --- a/client/firewall/manager/firewall.go +++ b/client/firewall/manager/firewall.go @@ -12,10 +12,11 @@ import ( ) const ( - ForwardingFormatPrefix = "netbird-fwd-" - ForwardingFormat = "netbird-fwd-%s-%t" - PreroutingFormat = "netbird-prerouting-%s-%t" - NatFormat = "netbird-nat-%s-%t" + ForwardingFormatPrefix = "netbird-fwd-" + ForwardingFormat = "netbird-fwd-%s-%t" + PreroutingFormat = "netbird-prerouting-%s-%t" + NatFormat = "netbird-nat-%s-%t" + NoMasqPostRoutingFormat = "netbird-no-masq-postrouting-%s-%t" ) // Rule abstraction should be implemented by each firewall manager diff --git a/client/firewall/nftables/router_linux.go b/client/firewall/nftables/router_linux.go index fde654c20ca..21f591e00a9 100644 --- a/client/firewall/nftables/router_linux.go +++ b/client/firewall/nftables/router_linux.go @@ -662,6 +662,19 @@ func (r *router) AddNatRule(pair firewall.RouterPair) error { if err := r.addNatRule(firewall.GetInversePair(pair)); err != nil { return fmt.Errorf("add inverse nat rule: %w", err) } + } else { + // Insert return verdicts in the postrouting NAT chain for both traffic directions. + // This prevents the exit node's catch-all masquerade rules from rewriting the + // source IP for routes that explicitly have masquerade disabled. + // Forward direction: wt0→eth0 traffic marked with PreroutingFwmarkMasquerade. + // Inverse direction: eth0→wt0 traffic marked with PreroutingFwmarkMasqueradeReturn. + if err := r.addNoMasqPostRoutingRule(pair); err != nil { + return fmt.Errorf("add no-masquerade postrouting rule: %w", err) + } + + if err := r.addNoMasqPostRoutingRule(firewall.GetInversePair(pair)); err != nil { + return fmt.Errorf("add inverse no-masquerade postrouting rule: %w", err) + } } if err := r.conn.Flush(); err != nil { @@ -678,6 +691,8 @@ func (r *router) rollbackRules(pair firewall.RouterPair) { firewall.GenKey(firewall.ForwardingFormat, pair), firewall.GenKey(firewall.PreroutingFormat, pair), firewall.GenKey(firewall.PreroutingFormat, firewall.GetInversePair(pair)), + firewall.GenKey(firewall.NoMasqPostRoutingFormat, pair), + firewall.GenKey(firewall.NoMasqPostRoutingFormat, firewall.GetInversePair(pair)), } for _, key := range keys { rule, ok := r.rules[key] @@ -763,6 +778,56 @@ func (r *router) addNatRule(pair firewall.RouterPair) error { return nil } +// addNoMasqPostRoutingRule inserts a return verdict at the head of the postrouting +// NAT chain for the given destination. This ensures that when an exit node +// (0.0.0.0/0, masquerade=true) is active alongside routes with masquerade=false, +// the exit node's catch-all mark rule does not cause those destinations to be +// masqueraded. The mark match scopes the rule to exit-node traffic only. +// InsertRule places it at chain position 0, before the blanket masquerade rule. +func (r *router) addNoMasqPostRoutingRule(pair firewall.RouterPair) error { + ruleKey := firewall.GenKey(firewall.NoMasqPostRoutingFormat, pair) + if _, exists := r.rules[ruleKey]; exists { + if err := r.removeNoMasqPostRoutingRule(pair); err != nil { + return fmt.Errorf("remove existing no-masq rule before reinstall: %w", err) + } + } + + destExp, err := r.applyNetwork(pair.Destination, nil, false) + if err != nil { + return fmt.Errorf("apply destination: %w", err) + } + + // Select the correct fwmark based on traffic direction: + // forward (wt0→eth0) uses PreroutingFwmarkMasquerade, + // inverse (eth0→wt0) uses PreroutingFwmarkMasqueradeReturn. + markValue := uint32(nbnet.PreroutingFwmarkMasquerade) + if pair.Inverse { + markValue = nbnet.PreroutingFwmarkMasqueradeReturn + } + + // Match only packets carrying the exit-node masquerade mark, then match the + // destination, then return — skipping the blanket masquerade rule below. + exprs := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyMARK, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(markValue), + }, + } + exprs = append(exprs, destExp...) + exprs = append(exprs, &expr.Verdict{Kind: expr.VerdictReturn}) + + r.rules[ruleKey] = r.conn.InsertRule(&nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingNat], + Exprs: exprs, + UserData: []byte(ruleKey), + }) + + return nil +} + // addPostroutingRules adds the masquerade rules func (r *router) addPostroutingRules() { // First masquerade rule for traffic coming in from WireGuard interface @@ -1375,6 +1440,14 @@ func (r *router) RemoveNatRule(pair firewall.RouterPair) error { if err := r.removeNatRule(firewall.GetInversePair(pair)); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove inverse prerouting rule: %w", err)) } + } else { + if err := r.removeNoMasqPostRoutingRule(pair); err != nil { + merr = multierror.Append(merr, err) + } + + if err := r.removeNoMasqPostRoutingRule(firewall.GetInversePair(pair)); err != nil { + merr = multierror.Append(merr, err) + } } if err := r.removeLegacyRouteRule(pair); err != nil { @@ -1423,6 +1496,35 @@ func (r *router) removeNatRule(pair firewall.RouterPair) error { return nil } +func (r *router) removeNoMasqPostRoutingRule(pair firewall.RouterPair) error { + ruleKey := firewall.GenKey(firewall.NoMasqPostRoutingFormat, pair) + + rule, exists := r.rules[ruleKey] + if !exists { + log.Debugf("no-masquerade postrouting rule %s not found", ruleKey) + return nil + } + + if rule.Handle == 0 { + log.Warnf("no-masquerade postrouting rule %s has no handle, removing stale entry", ruleKey) + if err := r.decrementSetCounter(rule); err != nil { + log.Warnf("decrement set counter for stale no-masq rule %s: %v", ruleKey, err) + } + delete(r.rules, ruleKey) + return nil + } + + if err := r.deleteNftRule(rule, ruleKey); err != nil { + return fmt.Errorf("remove no-masquerade postrouting rule: %w", err) + } + + if err := r.decrementSetCounter(rule); err != nil { + return fmt.Errorf("decrement set counter for no-masq rule: %w", err) + } + + return nil +} + // refreshRulesMap rebuilds the rule map from the kernel. This removes stale entries // (e.g. from failed flushes) and updates handles for all existing rules. func (r *router) refreshRulesMap() error {