VPN Chain and End-to-End Encryption Without Persisted Logs
TL;DR: Connecting VPS nodes in series creates a multi-hop tunnel. Internal fleet virtual machines route traffic through a local gateway to entry node vpn-1, which policy-routes the traffic into exit node vpn-2. If any intermediate tunnel drops, traffic hits a blackhole route and dies on the floor rather than leaking out an intermediate WAN interface.
This article is Part 5 of a 7-part series on building a multi-hop, fail-closed VPS VPN chain with Ansible, LUKS encrypted vaults, Telegram monitoring, and zero-log policy routing.
- Part 1: VPS Baseline: Install, Harden, and Disable Logs
- Part 2: Encrypted Vault for VPN Secrets and Residual Logs
- Part 3: Telegram Alerts Without Leaking the Exit IP
- Part 4: Ansible Playbook for Edge Hop Installation
- Part 5: VPN Chain and End-to-End Encryption Without Persisted Logs
- Part 6: VPN Failover Between VPS Nodes
- Part 7: Operational Tasks: Heal, Add, Remove, and Replace Hops
The multi-hop routing topology forces all egress traffic through a chain of independent VPS servers:
Fleet Virtual Machines (Single NIC)
|
| Isolated LAN (Gateway provides DHCP + default route)
v
Local Gateway --OpenVPN (tun0)--> vpn-1 --OpenVPN (tun1)--> vpn-2 --> Public Internet
(Entry Hop) (Exit Hop)
WAN unused for Public Exit IP
fleet egress visible to web
- Fleet Virtual Machines: Configured with a single NIC attached to an isolated LAN. No direct internet access or secondary default route.
- Local Gateway: Acts as an OpenVPN client connecting to
vpn-1. Applies NAT (MASQUERADE) from the LAN onto the tunnel interface, guarded by a fail-closed firewall kill-switch. vpn-1(Entry Hop): Operates an OpenVPN server accepting gateway connections (tun0), and an OpenVPN client (tun1) connecting tovpn-2. Applies policy routing to forwardtun0traffic exclusively intotun1.vpn-2(Exit Hop): Accepts the tunnel fromvpn-1and NATs traffic out its public WAN interface. External internet destinations observevpn-2’s public IP address.
Maintain the official node topology manifest in /etc/vps-chain/chains.conf:
# Node definitions
node vpn-1 203.0.113.10
node vpn-2 203.0.113.20
# Gateway mapping (Gateway -> Entry Hop -> Exit Hop)
gw kvm-cluster-a 10.130.0.6 : vpn-1 vpn-2
gw kvm-cluster-b 10.130.0.10 : vpn-1 vpn-2
Before selecting WireGuard or OpenVPN for host-to-host backbone links, test UDP packet transport between nodes:
- Clean UDP Path: WireGuard offers lower overhead and higher performance.
- Filtered UDP Path: Fall back to OpenVPN over TCP/443.
When running TCP-in-TCP tunneling, clamp the MSS (Maximum Segment Size) to prevent packet fragmentation stalls:
iptables -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1200
Important: Avoid OpenVPN 2.6 DCO on Linux kernels when wrapping TCP traffic inside TCP tunnels. Run OpenVPN inside a 2.4 container or add disable-dco to client configurations.
vpn-1 must maintain its own management SSH connection over its physical WAN (eth0) while forcing all forwarded traffic coming from tun0 into tun1.
Ensure vpn-1’s uplink client config contains route-nopull so pushed routes from vpn-2 do not overwrite vpn-1’s main routing table:
# /etc/openvpn/uplink-vpn2.conf
dev tun1
route-nopull
disable-dco
Create routing table 200 and direct incoming traffic from tun0 to look up table 200:
# Add policy rule
ip rule add iif tun0 lookup 200 priority 100
# Populate Table 200 with default via-route and a fallback blackhole
ip route add default via 10.200.0.1 dev tun1 metric 0 table 200
ip route add blackhole default metric 1000 table 200
# Apply MASQUERADE NAT for outgoing tunnel interface
iptables -t nat -A POSTROUTING -o tun1 -j MASQUERADE
- When
tun1is UP, traffic matches the metric0default route viatun1and exits tovpn-2. - If
tun1goes DOWN, kernel automatically drops thevia 10.200.0.1route. The metric1000blackhole defaultroute takes effect. Packets arriving fromtun0are dropped instantly in kernel space. No packets can ever leak ontovpn-1’s physicaleth0WAN interface.
On the local network gateway, enforce firewall rules before Docker or tunnel daemons initialize:
# Block all FORWARD traffic out eth0 (physical WAN)
iptables -A FORWARD -o eth0 -j DROP
# Allow FORWARD traffic strictly out tun0
iptables -A FORWARD -o tun0 -j ACCEPT
Systemd units for firewall enforcement must set Before=docker.service openvpn.service.
From a virtual machine inside the fleet network:
curl -4 ifconfig.me
Expected output: Returns the public IP of vpn-2 (203.0.113.20).
From vpn-1’s own SSH prompt:
curl -4 ifconfig.me
Expected output: Returns the public IP of vpn-1 (203.0.113.10). If this command returns vpn-2’s IP, route-nopull failed and vpn-1’s management path was incorrectly routed into the chain.
Simulate an uplink failure on vpn-1:
systemctl stop openvpn-client@uplink-vpn2
Attempt a curl request from a fleet virtual machine. The connection must time out completely. Check packet drops on vpn-1:
ip route show table 200
Output: blackhole default metric 1000. The network path is fully dark with zero WAN leaks.
Next in the series: Part 6: VPN Failover Between VPS Nodes - Managing node flaps, explicit entry hop fallbacks, and replacing dead VPS nodes safely.