Three Linux hosts, one design: a publicly reachable OpenVPN hub, a consumer laptop, and an exit machine sitting behind an ordinary home router that forwards nothing inbound. The exit dials the hub itself. Consumer internet traffic then enters the tunnel at the laptop and leaves through the exit's own uplink. Everything surprising about this setup follows from one detail: the exit initiated the connection, so the path inward already exists and no port forward is needed to create it.

What follows traces the packet path rather than the configuration order, because the configuration only makes sense once you know which component owns which decision. Each layer owns exactly one decision, and the mistakes cluster where two layers are assumed to be the same one.

Two transport sessions, one inner path

Each host keeps its own outer connection to the hub, and neither of those connections knows about the other. The consumer's traffic to the internet is a separate, inner flow that happens to be carried inside the consumer's tunnel and then re-emitted from the exit's tunnel. That inner flow is what the hub has to route, and it is what the exit has to forward.

outer: consumer --TLS--> hub
outer: exit     --TLS--> hub
inner: consumer --tun--> hub --internal--> exit --tun--> uplink

Because both outer connections are outbound from the client side, the exit's router never sees an inbound SYN and never needs a destination NAT rule. It only has to keep the established transport alive, which it already does for any long-lived connection. Nothing on the public internet needs to reach the exit first, which is why a dynamic address on that uplink is not a problem. The router still has to permit the outbound session, but that is the same permission a browser already uses.

What iroute actually installs inside the server

The hub is the only device that decides which peer owns which destination, and it makes that decision in its own userspace routing table, not in the kernel's. A client-config-dir entry for the exit named exit-node can claim the default prefix there:

# /etc/openvpn/server/ccd/exit-node
iroute 0.0.0.0 0.0.0.0

That line tells the server process "inner packets with any destination belong to this client". It does not add a default route to the hub's operating system, and it does not make the hub an internet gateway. The OpenVPN 2.6 reference keeps client-specific internal routing and kernel routing as separate mechanisms, and that separation is the reason this design works without touching the hub's default route.

The consumer needs the mirror-image instruction, delivered as a push, so that its own traffic enters the tunnel instead of leaving through its local gateway. With subnet topology the pool is a connected route on the exit's tun device, so the return direction needs no extra push; what the exit must not receive is the redirection below, which would send its own uplink traffic back into the tunnel it is trying to use. Send it to the consumer only, and read the applied routes on the client instead of trusting that the CCD file was selected.

# /etc/openvpn/server/ccd/consumer-01
push "redirect-gateway def1"

The exit's kernel does the forwarding

Inside the exit, a packet from the consumer arrives on the tun interface addressed to some internet host. The kernel consults its routing table, finds the real uplink as the best match, and then applies two independent policies: may this packet be forwarded at all, and may it leave by that interface with that source address. Forwarding is a sysctl, and it is off by default on many distributions.

So the exit needs IPv4 forwarding enabled persistently, a forward rule that admits the consumer's tunnel sources toward the uplink, and the reply path back through the tun. Enabling forwarding for the whole host also changes behaviour for anything else that routes through it, which is why the rule should name the sources and interfaces instead of accepting everything.

Source translation is what buys the return path

An internet host has no route to a 10.90.0.x address, so replies would be dropped before they ever reached the exit. Rewriting the source as the packet leaves the uplink solves that, and connection tracking reverses the rewrite on the way back.

sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -s 10.90.0.0/24 -o enp1s0 -j MASQUERADE
ip -4 route get 10.90.0.9   # run on the exit: must point at the tun

The rule above is a starting point for one uplink interface, not a firewall policy. Narrow the source to the assignments you actually authorized, and keep the exit's own input policy separate so that masquerading for tunnel traffic does not quietly expose local services. On a host with a dynamic uplink address, masquerade also spares you from tracking the address by hand.

Where the hub can and cannot enforce

With client-to-client and the traditional userspace data path, the hub hands inner packets between peers inside the server process. They never traverse the hub's kernel FORWARD chain, so a kernel rule there cannot restrict peer-to-peer transit, however carefully it is written. Enforcement has to live in the server's own policy: who is allowed to connect, which CCD file they get, and whether the exit's default ownership is granted at all.

That is also why a default iroute is a broad trust decision. The exit must legitimately accept return traffic from arbitrary internet addresses, and it must be a machine you control, patched, and monitored. Granting the same ownership to a peer you would not hand a router to is the mistake this design invites.

Membership is the real control. Any client that can configure its own routes can try to use the exit, so restricting who may connect, or which peers the exit will serve, is part of the design rather than an afterthought.

Failing safely when the exit disappears

Pull the exit offline and the internal default ownership disappears with its session. What must not happen is the hub quietly becoming the replacement exit: no default route from the VPN pool into the hub's uplink, and no broad masquerade rule on the hub that would happily forward the same traffic. Test that failure deliberately, then test recovery with a fresh flow rather than a page that was already open.

# on the consumer, with the exit up and then down
ip -4 route get 1.1.1.1
curl -sS -o /dev/null -w '%{http_code}\n' https://example.com

If the consumer's traffic still reaches the internet after the exit is gone, something else is carrying it and the boundary you intended does not exist. Handle DNS and IPv6 as separate decisions as well: an IPv4 route plus masquerade says nothing about AAAA traffic, and a resolver pushed by the hub will answer from wherever the query actually leaves.

Ebitabo ebifaananyi