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.


Series Navigation: Building a VPS VPN Chain

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.

  1. Part 1: VPS Baseline: Install, Harden, and Disable Logs
  2. Part 2: Encrypted Vault for VPN Secrets and Residual Logs
  3. Part 3: Telegram Alerts Without Leaking the Exit IP
  4. Part 4: Ansible Playbook for Edge Hop Installation
  5. Part 5: VPN Chain and End-to-End Encryption Without Persisted Logs
  6. Part 6: VPN Failover Between VPS Nodes
  7. Part 7: Operational Tasks: Heal, Add, Remove, and Replace Hops

Multi-Hop Architecture Topology

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

Component Roles:

  • 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 to vpn-2. Applies policy routing to forward tun0 traffic exclusively into tun1.
  • vpn-2 (Exit Hop): Accepts the tunnel from vpn-1 and NATs traffic out its public WAN interface. External internet destinations observe vpn-2’s public IP address.

Declared Topology Configuration

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

Transport Protocol Considerations

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.

Policy Routing on Entry Hop (vpn-1)

vpn-1 must maintain its own management SSH connection over its physical WAN (eth0) while forcing all forwarded traffic coming from tun0 into tun1.

1. Disable Default Route Overrides

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

2. Configure Advanced Policy Routing Table

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

How Fail-Closed Blackholing Works:

  • When tun1 is UP, traffic matches the metric 0 default route via tun1 and exits to vpn-2.
  • If tun1 goes DOWN, kernel automatically drops the via 10.200.0.1 route. The metric 1000 blackhole default route takes effect. Packets arriving from tun0 are dropped instantly in kernel space. No packets can ever leak onto vpn-1’s physical eth0 WAN interface.

Local Gateway Kill-Switch

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.

Verifying Egress Traffic and Fail-Closed Security

1. Egress Verification from Fleet VM

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).

2. Management Path Verification on vpn-1

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.

3. Fail-Closed Blackhole Test

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.