Skip to content

[client] Add dual-stack iptables manager with ip6tables support#5708

Open
lixmal wants to merge 5 commits intoclient-ipv6-nftablesfrom
client-ipv6-iptables
Open

[client] Add dual-stack iptables manager with ip6tables support#5708
lixmal wants to merge 5 commits intoclient-ipv6-nftablesfrom
client-ipv6-iptables

Conversation

@lixmal
Copy link
Copy Markdown
Collaborator

@lixmal lixmal commented Mar 26, 2026

Describe your changes

iptables

  • Add parallel ipv6Client/aclMgr6/router6 in the iptables manager, dispatching operations by address family
  • Use -v6 suffix for ip6 ipset names (kernel ipsets are global, need family separation)
  • MSS clamping uses correct overhead per family (40 for v4, 60 for v6)
  • State cleanup creates v6 components even when current run has no IPv6 (previous run may have left state)

Misc

  • Fix blockLanAccess to use matching source family for IPv6 LAN prefixes
  • Accumulate errors in Windows firewall cleanup so v6 rule deletion isn't skipped on v4 failure
  • Fix shouldForward in USP filter for dual-stack address comparison
  • Fix anonymizer test assertions for updated v6 start address
  • Nil guard on fakeAddress for v6 peer address handling
  • SSH normalizeLocalHost("*") returns "" for dual-stack listening

Stacked on #5707.

Issue ticket number and link

Stack

Checklist

  • Is it a bug fix
  • Is a typo/documentation fix
  • Is a feature enhancement
  • It is a refactor
  • Created tests that fail without the change (if possible)

By submitting this pull request, you confirm that you have read and agree to the terms of the Contributor License Agreement.

Documentation

Select exactly one:

  • I added/updated documentation for this change
  • Documentation is not needed for this change (explain why)

Docs PR URL (required if "docs added" is checked)

Paste the PR link from https://github.com/netbirdio/docs here:

netbirdio/docs#594

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 26, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 95ad8cff-bd6d-41cc-a71e-643fb2ded057

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds dual-stack (IPv6) support across firewall, routing, and related components, plus CIDR-aware IP anonymization and several address/formatting adjustments; tests expanded for IPv6 compatibility.

Changes

Cohort / File(s) Summary
IPv6 Firewall Manager & ACL
client/firewall/iptables/manager_linux.go, client/firewall/iptables/acl_linux.go
Introduce parallel IPv6 components (ipv6Client, aclMgr6, router6) initialized when IPv6 is present; dispatch ipv4/v6 operations accordingly; add proto translation for ICMP→ICMPv6; adjust ipset naming and state persistence.
IPv6 Router & MSS Clamping
client/firewall/iptables/router_linux.go, client/firewall/iptables/rule.go
Add router v6 flag and rule v6 field; use protocol-specific TCP header overheads for MSS clamping; implement IPv6 ipset family/suffixing; fix DNAT deletion chain; improve stale-chain handling and state updates.
IPv6 State Persistence
client/firewall/iptables/state_linux.go
Extend persisted ShutdownState with IPv6 fields (RouteRules6, RouteIPsetCounter6, ACLEntries6, ACLIPsetStore6) and restore/cleanup logic for IPv6 with non-fatal warnings on failures.
nftables ↔ iptables Integration & Tests
client/firewall/nftables/router_linux.go, client/firewall/nftables/manager_linux_test.go, client/firewall/nftables/manager_linux.go
Select family-specific iptables protocol (iptablesProto()), add iptables fallback/error-recovery paths, extend tests with IPv6 DNAT/filtering and ip6tables-save validation; update doc comment for AllowNetbird.
Windows USP Firewall IPv6
client/firewall/uspfilter/allow_netbird_windows.go
Manage IPv4 and IPv6 allow rules separately (suffixed -v6), conditionally add/remove IPv6 rule when interface has IPv6, and aggregate deletion errors into multierror.
USP Filter Forwarding Logic & Tests
client/firewall/uspfilter/filter.go, client/firewall/uspfilter/filter_test.go, client/firewall/uspfilter/localip_test.go
Enhance shouldForward to compare against both IPv4 and IPv6 WG addresses; extend tests for dual-stack cases; minor test cleanup (newline).
Route Manager & SysOps IPv6 Handling
client/internal/routemanager/systemops/systemops.go, client/internal/routemanager/systemops/systemops_generic.go, client/internal/routemanager/server/server.go
Add isOwnAddress helper checking both v4/v6 networks; refactor IPv6 split-default add/remove into helpers; change dynamic route fallback to use route network family.
Anonymization & Debug Tests
client/anonymize/anonymize.go, client/internal/debug/debug_test.go
AnonymizeIPString now detects CIDR prefixes via netip.ParsePrefix, anonymizes address and preserves prefix length; expand anonymization tests to cover IPv6 and ip6tables fixtures.
Address/Formatting & Misc. Changes
client/iface/configurer/usp.go, client/iface/wgproxy/bind/proxy.go, client/firewall/uspfilter/conntrack/common.go, proxy/internal/debug/handler.go, shared/relay/client/dialer/quic/quic.go
Use unmapped IPs for activity recording; rewrite fakeAddress to derive IPv4 from IPv6 last bytes; replace fmt Sprintf with net.JoinHostPort; remove unused imports; change QUIC UDP bind to unspecified address.
DNS, Engine & Listener Bind Adjustments
client/internal/dns/service_listener.go, client/internal/dnsfwd/manager.go, client/internal/engine.go, client/internal/lazyconn/activity/listener_bind.go
Clarify IPv4-only DNS comments; use interface IPv4 for DNAT/firewall in dnsfwd; include IPv6 unspecified in blockLanAccess; derive fake IP by preferring IPv4 in interface subnet, falling back to IPv6-derived fake IPv4.
SSH Port-Forward Bind Normalization & Test
client/cmd/ssh.go, client/cmd/ssh_test.go
When bind host is "*", return "" (unspecified) instead of "0.0.0.0" to enable dual-stack binding; update test expectation accordingly.

Sequence Diagram(s)

sequenceDiagram
participant CLI as Client CLI
participant Manager as Firewall Manager
participant RouterV4 as Router (v4)
participant RouterV6 as Router (v6)
participant IPTables as iptables/ip6tables

CLI->>Manager: AddPeerFiltering(peerIP)
alt peerIP is IPv6 and v6 enabled
    Manager->>RouterV6: applyPeerFiltering(peerIP)
    RouterV6->>IPTables: create/update rules (proto=ipv6)
else peerIP is IPv4 or v6 not enabled
    Manager->>RouterV4: applyPeerFiltering(peerIP)
    RouterV4->>IPTables: create/update rules (proto=ipv4)
end
Manager-->>CLI: result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • pappz

Poem

🐰 I hopped through stacks, both old and new,

IPv4 and v6 in view,
Rules split clean, prefixes kept neat,
Anonymized bytes march in their fleet,
Hop on — the dual-stack path is true.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding dual-stack iptables manager with ip6tables support, which aligns with the core objective of introducing IPv6 firewall capabilities alongside IPv4.
Description check ✅ Passed The pull request description follows the template structure with all required sections completed: changes described, issue ticket link provided, stack information included, checklist properly filled, CLA acknowledgment present, and documentation status specified with docs PR URL.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch client-ipv6-iptables

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.

@lixmal
Copy link
Copy Markdown
Collaborator Author

lixmal commented Mar 26, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 26, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@lixmal
Copy link
Copy Markdown
Collaborator Author

lixmal commented Mar 26, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 26, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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: 7

🧹 Nitpick comments (1)
client/internal/lazyconn/activity/listener_bind.go (1)

86-96: Minor optimization: avoid duplicate wgIface.Address() call.

wgIface.Address() is called at line 66 (for .Network) and again at line 87 (for .IPv6Net). Consider storing the full address result once to avoid the redundant call.

♻️ Suggested refactor
 func deriveFakeIP(wgIface WgInterface, allowedIPs []netip.Prefix) (netip.Addr, error) {
 	if len(allowedIPs) == 0 {
 		return netip.Addr{}, fmt.Errorf("no allowed IPs for peer")
 	}

-	ourNetwork := wgIface.Address().Network
+	addr := wgIface.Address()
+	ourNetwork := addr.Network

 	// Try v4 first (preferred: deterministic from overlay IP)
 	var peerIP netip.Addr
 	for _, allowedIP := range allowedIPs {
 		ip := allowedIP.Addr()
 		if !ip.Is4() {
 			continue
 		}
 		if ourNetwork.Contains(ip) {
 			peerIP = ip
 			break
 		}
 	}

 	if peerIP.IsValid() {
 		octets := peerIP.As4()
 		return netip.AddrFrom4([4]byte{127, 2, octets[2], octets[3]}), nil
 	}

 	// Fallback: use last two bytes of first v6 overlay IP
-	addr := wgIface.Address()
 	if addr.IPv6Net.IsValid() {
 		for _, allowedIP := range allowedIPs {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/internal/lazyconn/activity/listener_bind.go` around lines 86 - 96,
Store the result of wgIface.Address() once into a local variable (already named
addr in the diff) before the IPv6 fallback block and reuse it instead of calling
wgIface.Address() again; update the code that checks addr.IPv6Net.IsValid() and
later uses addr.IPv6Net.Contains(ip) to reference that cached addr, leaving the
allowedIPs loop and netip.AddrFrom4([4]byte{127, 2, raw[14], raw[15]}) logic
unchanged.
🤖 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 1257-1268: The code currently swallows iptables errors when
falling back to nftables; change the logic in the iptables/nftables cleanup path
so that any iptables-related error is not masked when the nftables fallback
succeeds. Specifically, in the block that calls
iptables.NewWithProtocol(r.iptablesProto()) and in the block that calls
r.removeAcceptFilterRulesIptables(ipt), attempt the nftables cleanup via
r.removeAcceptRulesFromTable(r.filterTable) but if that nftables call succeeds
still return the original iptables error (or return an aggregated error
combining the iptables error and the nftables result) instead of returning nil;
reference the symbols iptables.NewWithProtocol, r.iptablesProto,
r.removeAcceptFilterRulesIptables, and r.removeAcceptRulesFromTable when making
the change.

In `@client/firewall/uspfilter/allow_netbird_windows.go`:
- Around line 32-41: The current cleanup returns immediately on the first
manageFirewallRule error, so deleting the IPv4 rule can short-circuit IPv6
removal; change the logic in the shutdown/cleanup function that calls
isFirewallRuleActive and manageFirewallRule (using firewallRuleName and
firewallRuleName+"-v6" with deleteRule) to attempt both deletes regardless of
the first error, collect any errors (e.g., append to a slice or wrap with
fmt.Errorf) and after both attempts return either nil if none failed or a
combined error describing which deletions failed; ensure both calls to
manageFirewallRule are executed even if the first returns an error.

In `@client/firewall/uspfilter/filter_test.go`:
- Around line 1408-1430: The new dual-stack subtests are environment-dependent
because Create() sets manager.netstack from netstack.IsEnabled(), so set
manager.netstack = false in each v6Cases subtest (before calling shouldForward)
to force non-netstack behavior; specifically, after setting
manager.localForwarding = true in the t.Run body, assign manager.netstack =
false so shouldForward will exercise the IPv4/IPv6 destination comparison paths
added.

In `@client/iface/wgproxy/bind/proxy.go`:
- Around line 202-205: The function fakeAddress currently dereferences
peerAddress.IP without checking for nil; update fakeAddress to first guard
against a nil peerAddress and return a clear error (e.g., "nil peerAddress") if
it is nil, then proceed to call netip.AddrFromSlice(peerAddress.IP) and handle
the existing invalid-IP error path—ensure the check references the fakeAddress
parameter name peerAddress so the panic is avoided.

In `@client/internal/debug/debug_test.go`:
- Around line 556-580: The test uses a hardcoded IPv6 anonymization prefix
("100::") which is brittle; instead retrieve the configured default IPv6 base
from anonymize.DefaultAddresses() (or from the anonymizer used to create
anonNftables/anonIp6tablesSave) and use that value in the assertions—replace the
two assert.Contains checks that reference "100::" with assertions that check for
the actual DefaultAddresses().IPv6Base (or the anonymizer's IPv6 base) so the
test stays correct if the default prefix changes.

In `@client/internal/engine.go`:
- Around line 2355-2357: blockLanAccess() currently only uses an IPv4 source
prefix (v4 := netip.PrefixFrom(netip.IPv4Unspecified(), 0)) so IPv6 LAN prefixes
from getInterfacePrefixes() are never matched; update blockLanAccess() to create
both v4 and v6 unspecified source prefixes (IPv4Unspecified/IPv6Unspecified) and
when iterating the interface prefixes choose the matching source prefix based on
each prefix's address family (e.g., prefix.Addr().Is4() / Is6()) and apply the
corresponding rule for IPv4 vs IPv6 destinations so IPv6 LAN prefixes are
properly blocked.

In `@client/internal/routemanager/systemops/systemops.go`:
- Around line 110-118: The current check only tests prefix.Addr() against
isOwnAddress(addr); update validation to reject any candidate prefix that
overlaps the WireGuard address/prefix, not just matching base addresses: modify
validateRoute (or isOwnAddress) to accept the candidate netip.Prefix and check
both directions for overlap using the wg interface address/prefix from
r.wgInterface.Address() — e.g., reject when
candidatePrefix.Contains(wgAddr.Addr()) OR
wgAddr.Network.Contains(candidatePrefix.Addr()), and for IPv6 also check
wgAddr.IPv6Net.IsValid() && (candidatePrefix.Contains(wgAddr.IPv6Net.Addr()) ||
wgAddr.IPv6Net.Contains(candidatePrefix.Addr())); return vars.ErrRouteNotAllowed
when any overlap is found.

---

Nitpick comments:
In `@client/internal/lazyconn/activity/listener_bind.go`:
- Around line 86-96: Store the result of wgIface.Address() once into a local
variable (already named addr in the diff) before the IPv6 fallback block and
reuse it instead of calling wgIface.Address() again; update the code that checks
addr.IPv6Net.IsValid() and later uses addr.IPv6Net.Contains(ip) to reference
that cached addr, leaving the allowedIPs loop and netip.AddrFrom4([4]byte{127,
2, raw[14], raw[15]}) logic unchanged.
🪄 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: a8268a42-1c96-456a-ad15-3dac2fd76962

📥 Commits

Reviewing files that changed from the base of the PR and between e4857b4 and f79dcee.

📒 Files selected for processing (28)
  • client/anonymize/anonymize.go
  • client/cmd/ssh.go
  • client/cmd/ssh_test.go
  • client/firewall/iptables/acl_linux.go
  • client/firewall/iptables/manager_linux.go
  • client/firewall/iptables/router_linux.go
  • client/firewall/iptables/rule.go
  • client/firewall/iptables/state_linux.go
  • client/firewall/nftables/manager_linux.go
  • client/firewall/nftables/manager_linux_test.go
  • client/firewall/nftables/router_linux.go
  • client/firewall/uspfilter/allow_netbird_windows.go
  • client/firewall/uspfilter/conntrack/common.go
  • client/firewall/uspfilter/filter.go
  • client/firewall/uspfilter/filter_test.go
  • client/firewall/uspfilter/localip_test.go
  • client/iface/configurer/usp.go
  • client/iface/wgproxy/bind/proxy.go
  • client/internal/debug/debug_test.go
  • client/internal/dns/service_listener.go
  • client/internal/dnsfwd/manager.go
  • client/internal/engine.go
  • client/internal/lazyconn/activity/listener_bind.go
  • client/internal/routemanager/server/server.go
  • client/internal/routemanager/systemops/systemops.go
  • client/internal/routemanager/systemops/systemops_generic.go
  • proxy/internal/debug/handler.go
  • shared/relay/client/dialer/quic/quic.go
💤 Files with no reviewable changes (1)
  • client/firewall/uspfilter/localip_test.go

@lixmal
Copy link
Copy Markdown
Collaborator Author

lixmal commented Mar 26, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 26, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@lixmal lixmal force-pushed the client-ipv6-iptables branch from 076b6c6 to dacdd7f Compare March 27, 2026 04:35
@lixmal lixmal force-pushed the client-ipv6-iptables branch 2 times, most recently from 4e7f1b3 to 84b6716 Compare March 27, 2026 11:49
@lixmal lixmal force-pushed the client-ipv6-iptables branch from 84b6716 to faef5a5 Compare March 27, 2026 12:13
@sonarqubecloud
Copy link
Copy Markdown

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.

1 participant