Skip to content

[client] Fix exit node masquerade overrides per-route masquerade=false setting#5682

Open
tobsec wants to merge 6 commits intonetbirdio:mainfrom
tobsec:fix/exit-node-overrides-masquerade-off
Open

[client] Fix exit node masquerade overrides per-route masquerade=false setting#5682
tobsec wants to merge 6 commits intonetbirdio:mainfrom
tobsec:fix/exit-node-overrides-masquerade-off

Conversation

@tobsec
Copy link
Copy Markdown
Contributor

@tobsec tobsec commented Mar 24, 2026

Problem

When a peer acts as both an exit node (0.0.0.0/0) and a routing peer for specific subnets with masquerade disabled, the exit node's postrouting masquerade rule fires for all traffic — silently ignoring the per-route masquerade=false setting.

Root cause: addNatRule() sets fwmark PreroutingFwmarkMasquerade on all wt0 traffic for the exit node. addPostroutingRules() then masquerades everything carrying that mark, regardless of per-route settings. There is no mechanism for a per-route masquerade=false to opt out of this blanket rule.

Practical effect: A client routing through an exit node cannot reach services that rely on the original source IP being preserved (e.g. firewall rules matching the client's NetBird IP, IPsec policies, per-device ACLs). All traffic appears to arrive from the routing peer's WireGuard interface IP instead.

This was reported in #2751 and is related to #4542.

Fix

When masquerade=false for a route pair, insert a RETURN verdict at the head of the postrouting NAT chain (netbird-rt-postrouting) matching the route's destination prefix. This short-circuits evaluation before the exit node's catch-all masquerade rule fires.

Both the nftables and iptables backends are updated:

  • New constant NoMasqPostRoutingFormat in firewall/manager/firewall.go
  • New addNoMasqPostRoutingRule() in both nftables/router_linux.go and iptables/router_linux.go
  • AddNatRule() / RemoveNatRule() updated to manage the return rule when masquerade=false

The rule is keyed and tracked in r.rules the same way as existing NAT rules, so it is correctly removed on route deletion or peer disconnect.

Test scenario

  1. Configure peer A as exit node (0.0.0.0/0, masquerade cannot be disabled)
  2. Configure peer B as routing peer for 192.168.0.0/24 with masquerade=OFF
  3. From a client behind the exit node, send traffic to 192.168.0.1
  4. Before fix: traffic arrives at 192.168.0.1 with src = peer B's WireGuard IP (masqueraded)
  5. After fix: traffic arrives with the client's original NetBird IP preserved

Related issues

Summary by CodeRabbit

  • New Features

    • Added destination-scoped "no-masquerade" postrouting rules for both directions to provide finer NAT control across Linux routing backends.
  • Bug Fixes

    • Improved insertion ordering, deduplication, rollback, and cleanup of no-masquerade postrouting rules.
    • Graceful handling and removal of missing or stale rule entries to prevent leftover state.

When a routing peer acts as both an exit node (0.0.0.0/0) and a routing
peer for specific subnets with masquerade disabled, the exit node's
addNatRule() generates a catch-all prerouting mark rule matching all wt0
traffic. The blanket masquerade in netbird-rt-postrouting then fires on
every packet regardless of per-route masquerade settings.

Fix: when AddNatRule() is called with pair.Masquerade=false, insert a
return verdict rule at the head of the postrouting NAT chain for that
specific destination. Since InsertRule/Insert places it at position 0,
it fires before the catch-all masquerade rule, causing the packet to
skip masquerading and preserving the original source IP.

Applied to both nftables and iptables backends.

Related: netbirdio#2751
Related: netbirdio#4542
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 24, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 24, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 769a9a7f-5bd1-4b83-904e-f6d94d7c1f62

📥 Commits

Reviewing files that changed from the base of the PR and between 4754f31 and 9fb4812.

📒 Files selected for processing (1)
  • client/firewall/iptables/router_linux.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/firewall/iptables/router_linux.go

📝 Walkthrough

Walkthrough

Adds per-route "no-masquerade" postrouting rules: when a route has masquerade=false, routers now insert destination-scoped RETURN rules (both directions), track them, and remove/cleanup tracked rules and ipset counters; also adds a formatting constant for these rules.

Changes

Cohort / File(s) Summary
iptables router
client/firewall/iptables/router_linux.go
When pair.Masquerade == false insert destination-scoped -d <dest> -j RETURN rules for both directions via addNoMasqPostRoutingRule; update RemoveNatRule to remove those RETURN rules and decrement ipset counters; handle missing/stale entries as no-op.
nftables router
client/firewall/nftables/router_linux.go
Add addNoMasqPostRoutingRule / removeNoMasqPostRoutingRule; AddNatRule/RemoveNatRule now create/remove destination-scoped VerdictReturn rules (both directions); extend rollbackRules to decrement set counters and clear rule-map entries on failures or stale handles.
manager constants
client/firewall/manager/firewall.go
Add exported constant NoMasqPostRoutingFormat = "netbird-no-masq-postrouting-%s-%t" and reformat the constant block (no other behavioral changes).

Sequence Diagram(s)

sequenceDiagram
participant Caller as Caller
participant Router as Router (iptables/nftables)
participant Netfilter as Netfilter (postrouting chain)
participant Ipset as IpsetManager

Caller->>Router: AddNatRule(pair)
alt pair.Masquerade == true
    Router->>Netfilter: insert MASQUERADE postrouting rules (in/out)
    Router->>Ipset: increment set counters (if using ipsets)
else pair.Masquerade == false
    Router->>Router: compute ruleKey, remove existing tracked rule
    Router->>Netfilter: insert "-d <dest> -j RETURN" at head (both directions)
    Router->>Ipset: increment/track set counters for destinations
end

Caller->>Router: RemoveNatRule(pair)
alt tracked RETURN rule exists
    Router->>Netfilter: delete RETURN rule(s)
    Router->>Ipset: decrement set counters and remove tracking
else missing or Handle==0
    Router-->>Caller: log and no-op
end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • pappz
  • mlsmaycon

Poem

"I nibble rules and mark the flow,
RETURN at postrouting, off we go,
Counters tended, stale ones swept,
Two-way rules now gently kept,
A rabbit cheers where packets grow 🐇"

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: preventing exit node masquerade from overriding per-route masquerade=false settings.
Description check ✅ Passed The description comprehensively covers the problem, root cause, fix approach, affected files, and test scenario, matching the template requirements.
Linked Issues check ✅ Passed The code changes directly address the root cause (missing per-route masquerade opt-out mechanism) by inserting RETURN rules at chain head when masquerade=false, honoring per-route settings and preserving source IPs.
Out of Scope Changes check ✅ Passed All changes are scoped to implementing per-route masquerade control: new constant, new helper methods, and updated AddNatRule/RemoveNatRule logic in both firewall backends.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/firewall/nftables/router_linux.go`:
- Around line 774-801: The rollback path fails to decrement ipset refs when
addNoMasqPostRoutingRule increments them for set destinations; update
rollbackRules to handle firewall.NoMasqPostRoutingFormat so that when AddNatRule
fails after addNoMasqPostRoutingRule has succeeded you call the same ipset
decrement logic used for ForwardingFormat/PreroutingFormat. Locate rollbackRules
and add a branch for NoMasqPostRoutingFormat that looks up the rule key in
r.rules, calls r.getIpSet()/network.IsSet() reversal (the same decrement/cleanup
used by addNatRule's rollback), and removes the rule entry from r.rules to
ensure counters and state are consistent.
- Around line 1415-1428: The removal block for the no-masquerade postrouting
rule is missing the stale-handle check used elsewhere; before calling
r.conn.DelRule or decrementSetCounter in the current block (the branch handling
rule, exists := r.rules[ruleKey]), check if rule.Handle == 0 and if so log a
debug message, delete the entry from r.rules and skip calling r.conn.DelRule or
r.decrementSetCounter (treat it as a stale/unflushed rule), otherwise proceed
with DelRule and decrementSetCounter as currently implemented; mirror the
behavior found in removeNatRule/DeleteRouteRule and reference the symbols
rule.Handle, r.conn.DelRule, r.decrementSetCounter, r.rules, and ruleKey.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 42c3be8b-37e6-4f3f-81fb-67efe9ddc325

📥 Commits

Reviewing files that changed from the base of the PR and between 0af5a04 and 19b8eb8.

📒 Files selected for processing (3)
  • client/firewall/iptables/router_linux.go
  • client/firewall/manager/firewall.go
  • client/firewall/nftables/router_linux.go

Comment thread client/firewall/nftables/router_linux.go
Comment thread client/firewall/nftables/router_linux.go
tobsec added 2 commits March 25, 2026 10:40
- Move idempotency check to top of nftables addNoMasqPostRoutingRule,
  before applyNetwork() call, consistent with iptables version
- Remove redundant updateState() from iptables addNoMasqPostRoutingRule;
  the caller AddNatRule already calls it unconditionally
- Use deleteNftRule helper in nftables RemoveNatRule for the no-masq
  path, consistent with how other rules are removed in the same file
- Add NoMasqPostRoutingFormat to rollbackRules key list so the
  no-masquerade RETURN rule is cleaned up when conn.Flush() fails
  after addNoMasqPostRoutingRule has already written to r.rules
- Add rule.Handle == 0 stale-entry guard in RemoveNatRule no-masq
  path, consistent with the same check in removeNatRule
@tobsec
Copy link
Copy Markdown
Contributor Author

tobsec commented Mar 25, 2026

Both points addressed in d007a62:

  • rollbackRules now includes NoMasqPostRoutingFormat in its key list so the no-masq RETURN rule is rolled back on flush failure
  • RemoveNatRule no-masq path now checks rule.Handle == 0 and handles stale entries the same way removeNatRule does

…lexity

RemoveNatRule exceeded SonarCloud's cognitive complexity threshold of 20
after the no-masquerade path was added inline. Extract the logic into a
dedicated removeNoMasqPostRoutingRule helper, mirroring the structure of
removeNatRule.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
client/firewall/nftables/router_linux.go (1)

787-799: Scope the RETURN rule to the exit-node mark.

AddNatRule() installs this for every masquerade=false pair, but the match here is destination-only. That means any packet to the prefix exits the whole netbird-rt-postrouting chain, even when no exit-node mark is present. Since this chain also hosts other NAT work, e.g. addDnatMasq() at Lines 1711-1748, matching nbnet.PreroutingFwmarkMasquerade first would keep the opt-out limited to the exit-node path this PR is fixing.

🎯 Suggested tightening
-	exprs := append(destExp, &expr.Verdict{Kind: expr.VerdictReturn})
+	exprs := []expr.Any{
+		&expr.Meta{
+			Key:      expr.MetaKeyMARK,
+			Register: 1,
+		},
+		&expr.Cmp{
+			Op:       expr.CmpOpEq,
+			Register: 1,
+			Data:     binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkMasquerade),
+		},
+	}
+	exprs = append(exprs, destExp...)
+	exprs = append(exprs, &expr.Verdict{Kind: expr.VerdictReturn})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/firewall/nftables/router_linux.go` around lines 787 - 799, The RETURN
verdict is currently unconditional for destination-only matches and will exit
the entire netbird-rt-postrouting chain; tighten it by scoping the RETURN to
packets with the exit-node mark: in AddNatRule (the block creating exprs with
destExp and expr.Verdict{Kind: expr.VerdictReturn}), prepend a match expression
that tests for nbnet.PreroutingFwmarkMasquerade (the prerouting/masquerade
fwmark) before appending expr.VerdictReturn so the rule only returns when the
fwmark is present; update the exprs built for nftables.Rule (the
r.conn.InsertRule call referencing r.workTable, r.chains[chainNameRoutingNat],
and ruleKey) to include that mark-match expression.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/firewall/nftables/router_linux.go`:
- Around line 782-785: The code currently returns early when ruleKey :=
firewall.GenKey(firewall.NoMasqPostRoutingFormat, pair) already exists in
r.rules, which leaves outdated destination matches in place; change this to
replace the existing no-masq rule instead of returning: detect the collision on
ruleKey, remove/uninstall the old rule entry from r.rules and from the active
ruleset, then continue with the normal install/registration flow (same approach
as addNatRule() and addLegacyRouteRule()) so the new destination match is
applied and r.rules is updated to the new rule for the given pair.

---

Nitpick comments:
In `@client/firewall/nftables/router_linux.go`:
- Around line 787-799: The RETURN verdict is currently unconditional for
destination-only matches and will exit the entire netbird-rt-postrouting chain;
tighten it by scoping the RETURN to packets with the exit-node mark: in
AddNatRule (the block creating exprs with destExp and expr.Verdict{Kind:
expr.VerdictReturn}), prepend a match expression that tests for
nbnet.PreroutingFwmarkMasquerade (the prerouting/masquerade fwmark) before
appending expr.VerdictReturn so the rule only returns when the fwmark is
present; update the exprs built for nftables.Rule (the r.conn.InsertRule call
referencing r.workTable, r.chains[chainNameRoutingNat], and ruleKey) to include
that mark-match expression.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fee3e00e-9d62-4696-98ef-a690d64a3289

📥 Commits

Reviewing files that changed from the base of the PR and between d007a62 and 09b1d75.

📒 Files selected for processing (1)
  • client/firewall/nftables/router_linux.go

Comment thread client/firewall/nftables/router_linux.go
@lixmal
Copy link
Copy Markdown
Collaborator

lixmal commented Mar 25, 2026

Good feature, but the PR has 2 flaws:

  1. The reverse direction is not applied (routed network -> netbird network)
  2. Nat exemption breaks more specific prefixes. Consider this example:
Route Masquerade Rule type
10.0.0.0/24 on prerouting mark
10.0.0.0/16 off RETURN in postrouting
10.0.0.0/8 on prerouting mark

The more specific 10/24 would be swallowed by the exemption of 10/16

@tobsec
Copy link
Copy Markdown
Contributor Author

tobsec commented Mar 25, 2026

Thanks for the review, @lixmal — both points are valid.

Issue 1 (inverse direction): Agreed. The masquerade=ON path calls addNatRule for both the pair and GetInversePair, but our masquerade=OFF path only adds the RETURN rule for the forward direction. Easy fix.

Issue 2 (prefix overlap): This is the trickier one. The root cause is that masquerade=ON routes currently only add a prerouting mark rule — there is no corresponding postrouting rule per destination. The blanket postrouting masquerade fires for anything with the mark. Because of that, a RETURN in postrouting for a less-specific masquerade=OFF prefix will shadow more-specific masquerade=ON prefixes, regardless of insertion order.

The correct fix seems to be: masquerade=ON routes should also get explicit per-destination postrouting rules (mark + destination → masquerade), and all postrouting rules should be ordered most-specific-first so the longest prefix match wins. This means changing the masquerade=ON path as well — a bigger refactor than the current PR.

Before going down that path, does the project have a preferred approach for this? For example:

  • Explicit per-route postrouting rules sorted by prefix length
  • A nftables verdict map with longest-prefix-match (cleaner but more invasive)
  • Something else entirely

Happy to rework the implementation once there's consensus on the right direction.

addNoMasqPostRoutingRule now matches the correct fwmark per direction:
forward traffic (wt0→eth0) uses PreroutingFwmarkMasquerade (0x1BD21),
inverse traffic (eth0→wt0) uses PreroutingFwmarkMasqueradeReturn (0x1BD22).

AddNatRule and RemoveNatRule call the helper for both the forward and
inverse RouterPair so that neither direction is masqueraded when the
route has masquerade=false.

rollbackRules also covers the inverse pair key to avoid counter leaks
on flush failure.

A removeNoMasqPostRoutingRule helper is extracted in the iptables
backend (matching the existing nftables helper) and the idempotency
path now replaces the existing rule instead of returning early.
@tobsec tobsec changed the title fix: exit node masquerade overrides per-route masquerade=false setting [client] Fix exit node masquerade overrides per-route masquerade=false setting Mar 28, 2026
@tobsec
Copy link
Copy Markdown
Contributor Author

tobsec commented Mar 28, 2026

Docs change: not needed

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/firewall/iptables/router_linux.go`:
- Around line 528-533: When removing an existing NAT no-masq rule in the block
that checks r.rules[ruleKey], also decrement the ipset usage counter to avoid
leaks: call r.decrementSetCounter(rule) before deleting the rule entry. Locate
the code handling r.rules[ruleKey] (the branch that calls
r.iptablesClient.DeleteIfExists(tableNat, chainRTNAT, rule...)) and mirror the
behavior from removeNoMasqPostRoutingRule by invoking
r.decrementSetCounter(rule) prior to delete(r.rules, ruleKey) so applyNetwork
(and subsequent increments) do not cause counter drift.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f241fa43-30ca-4c2c-8420-c70fe1e23b1e

📥 Commits

Reviewing files that changed from the base of the PR and between 09b1d75 and 4754f31.

📒 Files selected for processing (2)
  • client/firewall/iptables/router_linux.go
  • client/firewall/nftables/router_linux.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/firewall/nftables/router_linux.go

Comment thread client/firewall/iptables/router_linux.go
The idempotency path in addNoMasqPostRoutingRule deleted the old rule
but did not decrement the ipset usage counter, which could cause
counter drift. Mirror the behavior from removeNoMasqPostRoutingRule.
@sonarqubecloud
Copy link
Copy Markdown

@tobsec
Copy link
Copy Markdown
Contributor Author

tobsec commented Mar 28, 2026

Issue 1 (inverse direction): Addressed in 4754f31. addNoMasqPostRoutingRule now selects the correct fwmark based on pair.Inverse (0x1BD21 for forward, 0x1BD22 for inverse), and AddNatRule/RemoveNatRule/rollbackRules call the helper for both the forward and inverse pair — matching the pattern used by the masquerade=ON path.

Issue 2 (prefix overlap): Agreed this is a real concern. The root cause is that masquerade=ON only adds a prerouting mark but no per-destination postrouting rule, so there's nothing more-specific to override the less-specific RETURN. A proper fix likely requires explicit per-route postrouting masquerade rules ordered by prefix length (most-specific first). Happy to take direction on the preferred approach — should this be addressed in this PR or as a follow-up?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Exit node masquerade overrides per-route masquerade=false setting Disabling Masquerding not working

3 participants