Two green connection icons look like a comparison and are usually a coincidence. If the test request could have used either tunnel, the result describes one of them at best and neither at worst. Give every observation a network context, an intended destination and an expectation written before the test runs. On a self-managed Linux box, named network namespaces are the cheapest way to buy that separation.

Two green lights are not a comparison

A namespace separates routing tables, interfaces and network-stack state, so a request started inside one cannot quietly borrow another one's tunnel. What it does not separate is a CPU, a physical uplink, or the resolver daemon running on the host. That last gap is where most of these tests fail without anyone noticing. A lookup that leaves the namespace and asks the host resolver returns an answer with no relationship to the tunnel under test.

This procedure assumes two already-provisioned, approved test namespaces with working outer connectivity and supported clients. It creates nothing, enrols nothing and replaces no configuration. Separate virtual machines remain the honest choice when a client needs a full desktop session or refuses to run outside one.

Fix the expectation before the first run

Decide per context what success looks like: the resolver, the service name, the expected address and the behaviour required when the tunnel drops. Keep those planned values in their own column, because a record that mixes plan and observation is fiction with a timestamp. The checklist below is the shape the record needs, and every row has to be fillable before the first connection.

TunnelOwnerClient interface inside the namespaceProof commandRequired on disconnect
vpn-athe identity that owns profile A, recorded by nameits own tun device, never the host'sip -n vpn-a -4 route get 10.55.10.20traffic stops; no host-side fallback
vpn-ba separate owner, even when both run on one boxits own tun device in its own namespaceip -n vpn-b -4 route get 10.55.20.20verified separately, never inferred from A

Resolver wiring needs its own line in that record, because it is the field most likely to lie. Namespace-scoped files such as /etc/netns/vpn-a/resolv.conf appear at their customary paths when a command runs inside the namespace, as the iproute2 namespace reference describes, but the name-service switch configuration and any local resolver it launches can still pull a lookup back to the host.

One tunnel at a time, the other disconnected

Connect A through its existing owner while B stays down, then inspect policy, routes and the selected path from the administration context. Three of the commands below read state; only the last one contacts a name service, and none of them sends an application request.

sudo ip -n vpn-a -4 rule show
sudo ip -n vpn-a -4 route show table all
sudo ip -n vpn-a -4 route get 10.55.10.20
sudo ip netns exec vpn-a getent ahostsv4 svc-a.lab.internal

A route lookup answers a hypothetical question about a destination, so it cannot show that the service accepts traffic. Keep those two kinds of evidence in separate columns, because a healthy route beside a refused connection is a real and common result. Then send one harmless authorised request with the real application, launched in A's context as its normal unprivileged user with certificate validation and application authentication left intact, and record the outcome together with the source address the service observed. Disconnect A through its owner and repeat the whole baseline for B, noting any configuration change you make, or the two baselines stop being comparable.

Both up, then one down at a time

Steady state is the easy part. With both tunnels connected, repeat each context's lookup and application request, then break one tunnel and check the other. Reconnect in the reverse sequence when the connection order itself is the question being asked.

sudo ip netns exec vpn-a tcpdump -ni any port 443 -c 5
sudo ip netns exec vpn-b getent ahostsv4 svc-b.lab.internal
sudo ip -n vpn-b -4 route get 10.55.20.20

The capture settles arguments that an application result cannot: it shows whether the request left through the interface you expected, or through something else entirely. For every transition record the owner action, the timestamp, the route result, the name result and the application outcome. Then check the fail-closed requirement inside the affected namespace, because isolation is not a kill switch. A namespace keeps two tunnels apart; it does not stop either one from carrying traffic when its profile says traffic must stop.

What this record can and cannot claim

A pass proves those observations for this arrangement, on this host, at that time. It says nothing about two clients sharing one desktop routing table, and nothing about throughput while both tunnels carry load. Those are separate questions with separate tests, and a comparison is only as good as the context attached to each row.

Keep the failures in the record beside the successes, labelled with which tunnel was up at the time. A clean page that hides the state at measurement is worse than an ugly one that shows it. What makes the record reusable later is the context, not the score.

Two tunnels on one box can be compared honestly, but only inside as many contexts as you actually controlled. Anything beyond that is a wish with a green icon next to it.

Related articles