docs: rework Site-to-VPN SNAT guidance, verified end-to-end (#758)

* docs: rewrite Site-to-VPN SNAT requirement to always be manual

The dashboard Masquerade flag does not cover the Site-to-VPN direction on
any route — the marking rules NetBird installs are only wired up when a
policy targets the resource, and the documented flow uses a peer-to-peer
policy. The old guidance ("on Linux kernel mode, no manual SNAT needed")
was wrong, so manual SNAT is now framed as required on every routing peer
regardless of OS or WireGuard mode.

Also adds an nftables example alongside the iptables one, drops the
"(If applicable)" qualifier from the Step 3 heading, fixes the four
in-page anchor links that were already 404ing against the old heading,
and reframes the Step 4 masquerade-flag note plus the Outbound SNAT
requirement and Troubleshooting sections to match.

Verified end-to-end in a kernel-mode Linux lab: with the documented setup
(masquerade=true, peer-to-peer policy only, no manual SNAT) curl from a
clientless device to the overlay peer's NetBird IP times out; adding
either the iptables or the nftables rule from the new Step 3 makes it
return HTTP 200.

* docs: make Step 3 SNAT examples persistent

The previous version showed runtime iptables/nft commands with
persistence as a trailing comment. Replace with two equivalent
fully-persistent options: iptables-persistent (recommended) and a
dedicated systemd-unit + /etc/nftables.d/ file for nftables-native
setups.

Explicitly call out why /etc/nftables.conf is not the right persistence
target — its default starts with "flush ruleset", which wipes the
iptables-nft chains NetBird installs.

Both options verified end-to-end in a lab: rule applied, curl succeeds;
rule removed (simulating reboot), curl fails; reload via
netfilter-persistent / systemctl restart, curl succeeds again.

* docs: drop nftables-only option from Step 3

iptables-persistent works on every Linux NetBird supports — whether the
underlying backend is iptables-legacy or iptables-nft — so a separate
nftables variant with its own systemd unit was strictly more complexity
for the same outcome. Keep the iptables-persistent block as the single
Linux instruction.

* docs: drop UFW/firewalld FORWARD caveat from Step 3

The note was scoped to a minority of routing peers (those running UFW or
firewalld with default-DROP on FORWARD), and the persistence guidance
was too vague to be actionable. The symptom — packets reaching the
routing peer but not the target — is already covered by the
Troubleshooting section, which is enough of a lead for affected users.

* docs: drop standalone Outbound SNAT requirement section

The section's three takeaways — ACL ipset rejects routed-CIDR sources,
SNAT rewrites the source to a known NetBird IP, dashboard Masquerade
flag doesn't cover this direction — are already covered inline in Step
3 and the Step 4 masquerade note. Fold the one unique bit (the ipset
mechanic) into Step 3's opening sentence and drop the standalone
section plus the three "see Outbound SNAT requirement" backreferences
that pointed at it.

* docs: use ss -tan in Test Connectivity verification

The previous command (ss -tnp | grep :8080) filters by process and
misses TIME-WAIT sockets, which are kernel-owned. After a fast curl the
TCP connection closes before ss runs, so the user only ever sees the
LISTEN socket — no indication of the source IP. ss -tan lists all
states, so the TIME-WAIT entry showing the routing peer's NetBird IP as
the remote address is reliably visible for ~60 s.

Verified end-to-end in a kernel-mode Linux lab.

* docs: make Linux static route in Step 6 persistent

Replace the runtime "ip route add" + persistence-as-a-comment with two
fully-persistent options: a netplan drop-in (Ubuntu Server default) and
an nmcli equivalent (RHEL / Fedora / desktop). The netplan YAML was
validated against netplan generate. Also annotate the Windows command
to highlight that "-p" is what makes it persistent.

* docs: drop unverified pfSense/OPNsense and MikroTik examples

Neither platform ships a first-class NetBird routing-peer setup, and we
have no way to verify the SNAT commands in those sections work as
written. Replace with a single "Other platforms" paragraph that points
back to the general principle (any SNAT that rewrites the site-CIDR
source on egress from wt0 is sufficient) without claiming to give
verified instructions. Also tighten the Prerequisites line that
referenced "per-platform SNAT syntax" that no longer exists.

* docs: rewrite DNS NXDOMAIN troubleshooting entry

The previous entry referenced a "127.0.0.1-co-located NetBird resolver"
that doesn't exist — the doc's own DNS section explicitly notes that
the NetBird daemon binds its resolver on the peer's own NetBird IP,
not on 127.0.0.1. The "binding loopback causes it to refuse forwarding"
explanation was therefore exactly backwards.

Replace with a dig-based isolation flow that distinguishes the three
real failure modes (timeout = dnsmasq not reachable on the site IP;
SERVFAIL = forward to NetBird resolver failed; NXDOMAIN = wrong FQDN).
Diagnostic flow verified end-to-end in a lab — valid hostnames return
NOERROR with an answer record, unknown ones return status: NXDOMAIN as
described.
This commit is contained in:
Jack Carter
2026-05-18 14:40:53 +02:00
committed by GitHub
parent eb752fd70b
commit 47f7ab7b33

View File

@@ -24,19 +24,19 @@ Clientless Device ──► Routing Peer ──► NetBird Overlay ──► Net
<Warning>
The routing peer must perform **outbound source NAT** for site traffic
entering the NetBird overlay. NetBird performs this automatically only when
the routing peer is **Linux running in kernel mode**. On any other platform
(pfSense, OPNsense, MikroTik, Windows, macOS, or Linux in userspace mode),
you must configure the outbound NAT yourself on the routing peer or on its
upstream firewall. Without this, the overlay peer drops the traffic at its
access control. See [Outbound SNAT requirement](#outbound-snat-requirement).
entering the NetBird overlay. The dashboard **Masquerade** flag does not
cover this direction on any route — you must install the SNAT rule
manually on the routing peer (or on its upstream firewall) on every
platform. Without this, the overlay peer drops the traffic at its access
control.
</Warning>
## Prerequisites
- A device on the local network to serve as the routing peer. **Linux is
strongly recommended** for the routing peer because it can install the
required outbound SNAT automatically (see the warning above).
- A device on the local network to serve as the routing peer. Linux is
assumed throughout this guide; on other platforms the steps are the
same but the [Step 3](#step-3-configure-the-outbound-snat) SNAT rule
has to be installed via the platform's native NAT mechanism.
- A separate device running the NetBird client that the clientless device
needs to reach
- The ability to either add a static route on the clientless device (or its
@@ -83,43 +83,35 @@ sudo netbird up --setup-key YOUR_SITE_SETUP_KEY
Confirm the peer appears in the dashboard and shows the `site-routing-peers`
group.
## Step 3: Configure the Outbound SNAT (If applicable)
## Step 3: Configure the Outbound SNAT
The routing peer must SNAT site traffic onto its NetBird interface so the
overlay peer's access control sees a NetBird IP it recognises. See
[Outbound SNAT requirement](#outbound-snat-requirement) for the reasoning.
overlay peer's access control sees a NetBird IP it recognises — the
overlay peer's per-policy ipset only contains the NetBird IPs of source
peers, so unrewritten packets sourced from a routed CIDR are dropped.
**On Linux:** no manual SNAT configuration is needed. NetBird enables IP
forwarding and installs the SNAT itself when masquerade is enabled on the
routing peer (Step 4).
This step is **required on every routing peer**, regardless of OS or
WireGuard mode. The dashboard **Masquerade** flag (Step 4) does not
install a SNAT for the Site-to-VPN direction.
The only Linux-side caveat is if you run a host firewall (UFW, firewalld)
with the `FORWARD` chain default set to `DROP` — in that case, allow
forwarding between the site-facing interface and `wt0`:
### Linux
Install the SNAT rule via `iptables-persistent` so it survives reboot.
This works on systems using either `iptables-legacy` or `iptables-nft`
underneath:
```bash
sudo iptables -I FORWARD 1 -i <site-iface> -o wt0 -j ACCEPT
sudo apt-get install -y iptables-persistent
sudo iptables -t nat -A POSTROUTING -s 192.168.50.0/24 -o wt0 -j MASQUERADE
sudo netfilter-persistent save
```
**On any other platform** — pfSense, OPNsense, MikroTik, Windows, macOS, or
Linux running in userspace mode — NetBird does not install the SNAT for
you. Configure it manually on the routing peer or its upstream firewall.
### Other platforms
For pfSense / OPNsense, add an outbound NAT rule on the `wt0` (or
equivalent) interface that translates traffic sourced from `192.168.50.0/24`
to the interface address. Switch outbound NAT mode to **Manual** (or
**Hybrid**) so this rule is honoured.
For MikroTik (RouterOS):
```
/ip firewall nat add chain=srcnat src-address=192.168.50.0/24 \
out-interface=wt0 action=masquerade
```
Any outbound source NAT mechanism that rewrites the site-CIDR source to the
routing peer's NetBird IP (or to the `wt0` interface address) on the
NetBird egress path is sufficient.
On non-Linux routing peers, install the equivalent rule via the
platform's native NAT mechanism. Any outbound source NAT that rewrites
the site-CIDR source to the routing peer's NetBird IP (or to the `wt0`
interface address) on egress from `wt0` is sufficient.
## Step 4: Create the Network
@@ -144,10 +136,12 @@ Attach the routing peer:
1. In the network, click **Add Routing Peer**
2. Select `site-router` (or the `site-routing-peers` group)
3. **Masquerade:** Enabled is fine, but does not replace
[Step 3](#step-3-configure-the-outbound-snat) — this dashboard flag does
not install a working SNAT on non-Linux routing peers and is unreliable in
userspace mode
3. **Masquerade:** Leave at the default. This flag controls SNAT for
traffic flowing **outbound from NetBird peers through the routing
peer** (the VPN-to-Site direction). It has no effect on Site-to-VPN
traffic, which is what this guide configures — the manual SNAT from
[Step 3](#step-3-configure-the-outbound-snat) is what makes Site-to-VPN
work.
4. Click **Save**
## Step 5: Create the Access Policy
@@ -192,14 +186,34 @@ In the examples below, replace `100.121.0.0/16` with your own block.
### Install the route
**On a Linux clientless device:**
**On a Linux clientless device with netplan** (Ubuntu Server default):
```bash
sudo ip route add 100.121.0.0/16 via 192.168.50.10
# Persist via /etc/network/interfaces, netplan, or NetworkManager
```yaml
# /etc/netplan/99-netbird-route.yaml
network:
version: 2
ethernets:
eth0: # the interface holding the device's site IP
routes:
- to: 100.121.0.0/16
via: 192.168.50.10
```
**On Windows:**
```bash
sudo chmod 600 /etc/netplan/99-netbird-route.yaml
sudo netplan apply
```
**On a Linux clientless device with NetworkManager** (RHEL / Fedora /
desktop distros):
```bash
sudo nmcli connection modify "<connection>" \
+ipv4.routes "100.121.0.0/16 192.168.50.10"
sudo nmcli connection up "<connection>"
```
**On Windows** (the `-p` flag persists the route across reboots):
```powershell
route -p add 100.121.0.0 mask 255.255.0.0 192.168.50.10
@@ -234,11 +248,12 @@ curl -v http://<TARGET_PEER_NETBIRD_IP>:8080/
Verify on the target peer that the request arrived:
```bash
sudo ss -tnp | grep :8080
sudo ss -tan | grep :8080
```
The connection's remote address on the target peer will be the **routing
peer's NetBird IP**, not the clientless device's local IP.
The connection appears as a `TIME-WAIT` entry for about a minute after
curl closes; its remote address is the **routing peer's NetBird IP**,
not the clientless device's local IP.
## Resolving NetBird DNS Names
@@ -279,27 +294,6 @@ the `server=/netbird.cloud/...` line — you can find it with
`netbird status` on the routing peer.
</Note>
## Outbound SNAT requirement
NetBird's per-peer access control on the destination peer matches inbound
traffic against an ipset of allowed source IPs. The ipset is populated from
the **NetBird IPs of peers in the policy's source group** — it cannot
contain a routed CIDR like `192.168.50.0/24`. So when a packet from
`192.168.50.20` arrives at the overlay peer, the access control has no
matching entry and the packet is dropped.
The fix is to rewrite the source IP at the routing peer before the packet
enters the overlay, replacing the site IP with the routing peer's NetBird
IP. That NetBird IP **is** in the policy's source group, so the access
control matches and the packet is accepted.
On a Linux routing peer running in kernel mode, NetBird installs the SNAT
itself via the kernel netfilter hooks when masquerade is enabled on the
routing peer. On any other platform — pfSense, OPNsense, MikroTik,
Windows, macOS, or Linux in userspace mode — that hook isn't available, so
the SNAT must be configured manually on the routing peer or on its
upstream firewall, as shown in [Step 3](#step-3-configure-the-outbound-snat).
## Troubleshooting
**Connection times out from the clientless device.**
@@ -335,31 +329,43 @@ allows `site-routing-peers` → target peer's group on the required port.
**Target peer receives packets but drops them.**
The outbound SNAT in [Step 3](#step-3-configure-the-outbound-snat) is
missing or not effective. On the routing peer, packets going out `wt0` must
have their source IP rewritten to the routing peer's NetBird IP:
The outbound SNAT from
[Step 3](#step-3-configure-the-outbound-snat) is missing or not effective.
On the routing peer, packets going out `wt0` must have their source IP
rewritten to the routing peer's NetBird IP:
```bash
# On the routing peer, verify packet counters on the MASQUERADE rule:
# Verify packet counters on the MASQUERADE rule installed in Step 3:
sudo iptables -t nat -L POSTROUTING -n -v
# Or watch overlay traffic on the way out:
sudo tcpdump -ni wt0 'src net 192.168.50.0/24'
# Seeing site IPs here means SNAT is NOT firing; the target peer will drop.
```
If the routing peer is Linux in kernel mode and masquerade is enabled but
the SNAT counters stay at zero, drop in an explicit rule as a fallback:
If the Step 3 rule is missing or its counters stay at zero, re-install it
(and add it to your persistence layer so it survives a reboot):
```bash
sudo iptables -t nat -A POSTROUTING -s 192.168.50.0/24 -o wt0 -j MASQUERADE
```
**DNS resolution returns NXDOMAIN.**
**DNS resolution returns NXDOMAIN or times out.**
Confirm the routing peer's NetBird IP is correct in `dnsmasq.conf` (it
changes if the peer is re-enrolled), and that `dnsmasq` is not bound to
`lo` — binding loopback causes it to refuse forwarding to its own
`127.0.0.1`-co-located NetBird resolver.
Confirm the `server=/netbird.cloud/...` line in
`/etc/dnsmasq.d/netbird.conf` still matches the routing peer's current
NetBird IP — re-enrolling the peer changes it. Isolate from the
clientless device:
```bash
dig @<ROUTING_PEER_SITE_IP> <hostname>.netbird.cloud
```
A timeout means `dnsmasq` isn't reachable on the routing peer's site IP
(check `listen-address` in the config). `SERVFAIL` means `dnsmasq`
received the query but its forward to the NetBird resolver failed.
`NXDOMAIN` means the NetBird resolver answered but doesn't know that
hostname — verify the target peer's actual FQDN with `netbird status`
on the peer itself.
## Appendix: Per-Service Port Forwarding
@@ -391,8 +397,9 @@ local IP and the forwarded port:
curl http://192.168.50.10:18080/
```
The outbound SNAT configured in [Step 3](#step-3-configure-the-outbound-snat)
applies to this traffic as well — the target peer still observes the
The outbound SNAT configured in
[Step 3](#step-3-configure-the-outbound-snat) applies to this
traffic as well — the target peer still observes the
routing peer's NetBird IP as the source.
Each forwarded service needs its own DNAT rule. This pattern is a good fit