Check whether a given host can be reached via a given interface (`tun0`, created by OpenVPN)

I have multiple network interfaces on my Linux system. Some physical (eth0 etc), other virtual (tun0 etc, created by OpenVPN).

Is it possible to check whether a given host (IP address) can be reached through a given interface?

ping supports a -I option to choose the interface. However ping -I tun0 1.1.1.1 doesn’t work: I receive no packets back. That’s surprising, because I can reach 1.1.1.1 through tun0, if I set that interface as the gateway in my routing tables.

How else can I check whether 1.1.1.1 is reachable via e.g. tun0, without changing the host’s gateway for that route?


More details: I’m using this system to forward the traffic of my whole network to the Internet through different interfaces. Sometimes one stops providing Internet access, and my goal is to write a script that changes interfaces when that happens. If you have good guides/tutorials on how to set this up more nicely, I’m very happy to read them.

Here is the output (trimmed and redacted a bit for privacy) of a few commands that show my system’s state:

# ping -c1 -W1 1.1.1.1 -Ieth0 | tail -n2
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 8.997/8.997/8.997/0.000 ms
# ping -c1 -W1 1.1.1.1 -Itun0 | tail -n2
1 packets transmitted, 0 received, 100% packet loss, time 0ms

# ping -c1 -W1 1.1.1.1 -Itun1 | tail -n2
1 packets transmitted, 0 received, 100% packet loss, time 0ms
# ip -br link
lo               UNKNOWN        00:00:00:00:00:00 <LOOPBACK,UP,LOWER_UP> 
eth0             UP             01:23:45:67:89:ab <BROADCAST,MULTICAST,UP,LOWER_UP> 
tun1             UNKNOWN        <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> 
tun0             UNKNOWN        <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> 
# ip -4 addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default 
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    inet 192.168.0.1/24 brd 192.168.0.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet 192.168.1.1/24 brd 192.168.1.255 scope global eth0
       valid_lft forever preferred_lft forever
12: tun1: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN group default qlen 100
    inet 172.16.1.8 peer 172.16.1.9/32 scope global tun1
       valid_lft forever preferred_lft forever
13: tun0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN group default qlen 100
    inet 172.16.0.6 peer 172.16.0.5/32 scope global tun0
       valid_lft forever preferred_lft forever
# ip route
10.0.0.0/22 via 172.16.0.5 dev tun0 
2.3.4.5 via 192.168.1.2 dev eth0 
172.16.0.0/24 via 172.16.0.5 dev tun0 
172.16.0.5 dev tun0 proto kernel scope link src 172.16.0.6 
172.16.1.0/24 via 172.16.1.9 dev tun1 
172.16.1.9 dev tun1 proto kernel scope link src 172.16.1.8 
192.168.0.0/16 via 172.16.1.9 dev tun1 
192.168.0.0/24 dev eth0 proto kernel scope link src 192.168.0.1 
192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.1 
1.2.3.4 via 192.168.1.2 dev eth0 
# ip rule
0:      from all lookup local 
32766:  from all lookup main 
32767:  from all lookup default 
40000:  from 192.168.0.0/28 lookup physical 
500000: from all iif lo lookup physical 
600000: from 192.168.0.4 lookup physical 
900000: from all lookup fallback 
# ip -details link show dev tun0
13: tun0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN mode DEFAULT group default qlen 100
    link/none  promiscuity 0 
    tun numtxqueues 1 numrxqueues 1 

# ip -details link show dev tun1
12: tun1: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN mode DEFAULT group default qlen 100
    link/none  promiscuity 0 
    tun numtxqueues 1 numrxqueues 1 

# iptables-save -c
# Generated by iptables-save v1.6.2 on Tue Sep  5 10:15:50 2023
*filter
:INPUT ACCEPT [59898142:66144679517]
:FORWARD ACCEPT [82206554:78274304851]
:OUTPUT ACCEPT [26945751:6646653799]
COMMIT
# Completed on Tue Sep  5 10:15:50 2023
# Generated by iptables-save v1.6.2 on Tue Sep  5 10:15:50 2023
*nat
:PREROUTING ACCEPT [2568037:216846789]
:INPUT ACCEPT [2278788:177840950]
:OUTPUT ACCEPT [853479:59465416]
:POSTROUTING ACCEPT [0:0]
[1133282:97570449] -A POSTROUTING -j MASQUERADE
COMMIT
# Completed on Tue Sep  5 10:15:50 2023
# sudo iptables -t nat -S
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
-A POSTROUTING -j MASQUERADE
# ip route show table default

# ip route show table physical
default via 192.168.1.2 dev eth0 proto static 

# ip route show table fallback
default via 172.16.0.5 dev tun0 metric 5 
default via 172.16.1.9 dev tun1 metric 10 
Asked By: Blue Nebula

||

Setup

OP’s policy routing setup is uncommon:

  • there are no rules overriding the rule for the main table

  • instead there is no default route in the main table,

  • and additional rules are evaluated after the main table

  • The actual important routing rule for the host is 500000: from all iif lo lookup physical

    Because that’s the first rule for the host (the local node rather than forwarded traffic) that will match INADDR_ANY (aka the address 0.0.0.0, that will match the 0.0.0.0/0 route), it will be used for any (TCP, UDP, ICMP …) client query that doesn’t bind to an address or interface, selecting eth0.

  • no comment on the one-armed routing done with two networks sharing the same broadcast domain with eth0, it’s unrelated to the problem.

I can imagine this setup is mainly intended to allow traffic coming from tun1 to be routed back and forth with tun0 to reach Internet without (much) interaction with host’s own traffic. I would think there should be simpler or more standard ways to do the same, but that’s the setup.

Detecting the routing problem

Every choice of the kernel’s routing stack can be checked with ip route get:

ip route get

get a single route

this command gets a single route to a destination and prints its
contents exactly as the kernel sees it.

When binding to an interface (using SO_BINDTODEVICE), the forced route effect can be queried with the additional selector oif.

Emitting a packet:

# ip route get oif tun0 to 1.1.1.1
1.1.1.1 via 172.16.0.5 dev tun0 table fallback src 172.16.0.6 uid 0 
    cache 

As there’s a route, tcpdump should see emitted packets on the tun0 interface.

The return path, at first glance, should also be working:

# ip route get from 1.1.1.1 iif tun0 to 172.16.0.6
local 172.16.0.6 from 1.1.1.1 dev lo table local 
    cache <local> iif tun0 

and tcpdump will also have captured return packets.

But if rp_filter=1 is in place to use Strict Reverse Path Forwarding (SRPF) then instead:

# ip route get from 1.1.1.1 iif tun0 to 172.16.0.6
RTNETLINK answers: Invalid cross-device link

The reply packet, while still captured by tcpdump will be dropped by the routing stack: ping fails.

So I can only assume that rp_filter=1 and with the uncommon routing setup such route is seen as asymmetric route, because then it’s expecting the return path matching the route from 500000: from all iif lo lookup physical which uses eth0. As the packet is seen on tun0 rather than eth0, it fails the SRPF validation. The fact that the ping process did bind to an interface is irrelevant when the routing stack validates SRPF with the return traffic.

Fixing it

For this to work while keeping SRPF, one should add a routing rule matching the source address that is eventually selected by oif tun0: 172.16.0.6. Its preference can be any value < 500000 (after which eth0 would have been chosen) to satisfy the SRPF algorithm implementation.

ip rule add from 172.16.0.6 lookup fallback

which now gets (still with rp_filter=1):

# ip route get from 1.1.1.1 iif tun0 to 172.16.0.6
local 172.16.0.6 from 1.1.1.1 dev lo table local 
    cache <local> iif tun0 

meaning: packet accepted and received for local consumption (by the ping command).

This also allows to work without binding to the interface, but just to the address with:

ping -I 172.16.0.6 1.1.1.1

Indeed, before there was:

# ip route get from 172.16.0.6 to 1.1.1.1
1.1.1.1 from 172.16.0.6 via 192.168.1.2 dev eth0 table physical uid 0 
    cache 

which wouldn’t use the tunnel.

But after:

# ip route get from 172.16.0.6 to 1.1.1.1
1.1.1.1 from 172.16.0.6 via 172.16.0.5 dev tun0 table fallback uid 0 
    cache 

Alternatively, instead of the rule above, to avoid change in routing rules, one can set the tunnel interface to use Loose RPF which takes precedence over SRPF:

sysctl -w net.ipv4.conf.tun0.rp_filter=2

This allows the RPF check to pass despite the (assumed) asymmetric route.
This gets the ping working with ping -I tun0 1.1.1.1 without any other routing change, (but not with ping -I 172.16.0.6 1.1.1.1 anymore, since eth0 is selected again as before (see above)).

The same can be done with tun1. Either:

ip rule add from 172.16.1.8 lookup fallback

or:

sysctl -w net.ipv4.conf.tun1.rp_filter=2
Answered By: A.B
Categories: Answers Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.