Skip to content

Instantly share code, notes, and snippets.

@azilber
Last active February 19, 2026 03:22
Show Gist options
  • Select an option

  • Save azilber/279a8b9e365b77dccfe8071cccb3e660 to your computer and use it in GitHub Desktop.

Select an option

Save azilber/279a8b9e365b77dccfe8071cccb3e660 to your computer and use it in GitHub Desktop.
# Raspberry Pi VPN Gateway A single bash script that configures a Raspberry Pi with dual NICs to route LAN client traffic through a Windscribe Static Port OpenVPN tunnel, while keeping the Pi's own traffic on the normal LAN gateway.

Raspberry Pi VPN Gateway

A single bash script that configures a Raspberry Pi with dual NICs to route LAN client traffic through a Windscribe OpenVPN tunnel, while keeping the Pi's own traffic on the normal LAN gateway.

Network Layout

Interface IP Role
eth0 192.168.0.5 Pi management - routes via LAN gateway 192.168.0.1
eth1 192.168.0.6 Gateway for VPN clients - clients set this as their default gateway
tun0 (assigned) Windscribe VPN tunnel

Port forwarding: VPN static port 12345 -> LAN host 192.168.0.35:12345

Prerequisites

  • Raspberry Pi with two Ethernet interfaces (built-in + USB adapter) running Raspberry Pi OS Lite (headless, no desktop). Flash with Raspberry Pi Imager and enable SSH during setup.
  • A Windscribe account with a static IP add-on
  • A .ovpn config file from Windscribe (placed in the repo root as Windscribe-StaticIP.ovpn)

Not PiVPN

This project is a VPN gateway - it routes LAN clients' traffic through a commercial VPN tunnel to the internet. Devices on your network point to the Pi as their default gateway and all their traffic exits via the VPN.

PiVPN solves a different problem: it turns a Pi into a VPN server so you can access your home network remotely from outside.

Configuration

User-editable variables are at the top of setup_vpn_gateway.sh:

Variable Description
VPN_USER / VPN_PASS Windscribe credentials
VPN_REMOTE VPN server hostname
ETH0_IP / ETH1_IP Pi's two NIC addresses
SUBNET LAN subnet (default 192.168.0.0/24)
LAN_GW LAN gateway (default 192.168.0.1)
VPN_STATIC_PORT Windscribe static IP port
LOCAL_DEST LAN host for port forwarding

Installation

sudo bash setup_vpn_gateway.sh

Verify after install:

sudo systemctl status openvpn-client@windscribe
ip addr show tun0
ip route show table 200

Uninstallation

Reverses all changes made by the installer:

sudo bash setup_vpn_gateway.sh --uninstall

How It Works

The script performs a 6-step setup:

  1. eth1 static connection - Creates a NetworkManager connection on eth1 with a static IP and no default route
  2. Policy routing - Adds custom routing tables (eth1rt=100, vpntunnel=200) and a dispatcher script to reapply rules when eth1 comes up
  3. OpenVPN client - Installs the .ovpn config with route-nopull and custom up/down scripts that manage the VPN routing table
  4. systemd service - Enables openvpn-client@windscribe with a restart-on-failure drop-in
  5. iptables firewall - Marks subnet traffic for VPN routing, masquerades on tun0, sets a kill switch (FORWARD DROP when tunnel is down), and configures DNAT port forwarding
  6. IP forwarding - Enables and persists net.ipv4.ip_forward

Both NICs share the same subnet, so policy routing and ARP filtering are required to keep traffic on the correct interface.

Security Notes

  • The .ovpn file contains embedded CA certs and TLS auth keys - do not commit it
  • auth.txt (created by the script) contains VPN credentials - do not commit it
  • The kill switch drops all forwarded traffic when the VPN tunnel is down, preventing leaks
#!/usr/bin/env bash
#
# setup_vpn_gateway.sh - Configure this Raspberry Pi as a VPN gateway.
#
# Clients that set their default gateway to 192.168.0.6 (eth1) will have all
# traffic routed through the Windscribe VPN tunnel (tun0). The Pi's own
# traffic on eth0 (192.168.0.5) continues to route normally via 192.168.0.1.
#
# Usage:
# 1. Edit VPN_USER and VPN_PASS below
# 2. sudo bash setup_vpn_gateway.sh
# 3. sudo bash setup_vpn_gateway.sh --uninstall (to reverse everything)
set -euo pipefail
# - - User-editable credentials - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
VPN_USER=""
VPN_PASS=""
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
OVPN_SOURCE="/home/rpi/openvpn_gateway/Windscribe-StaticIP.ovpn"
VPN_REMOTE="<insert your VPN static ip>"
ETH0_IP="192.168.0.5"
ETH1_IP="192.168.0.6"
SUBNET="192.168.0.0/24"
LAN_GW="192.168.0.1"
VPN_STATIC_PORT="12345" # Windscribe static port
LOCAL_DEST="192.168.0.35:12345" # LAN IP:port to forward to
if [[ $EUID -ne 0 ]]; then
echo "Error: Run this script with sudo." >&2
exit 1
fi
# - - Uninstall mode - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
if [[ "${1:-}" == "--uninstall" ]]; then
echo "=== Uninstalling VPN gateway ==="
# Stop and disable OpenVPN
systemctl stop openvpn-client@windscribe.service 2>/dev/null || true
systemctl disable openvpn-client@windscribe.service 2>/dev/null || true
# Remove systemd drop-in override
rm -rf /etc/systemd/system/openvpn-client@windscribe.service.d
systemctl daemon-reload
# Remove OpenVPN config files
rm -f /etc/openvpn/client/windscribe.conf
rm -f /etc/openvpn/auth.txt
rm -f /etc/openvpn/client/tun-up.sh
rm -f /etc/openvpn/client/tun-down.sh
# Remove NM dispatcher script
rm -f /etc/NetworkManager/dispatcher.d/10-eth1-policy-route
# Remove sysctl config
rm -f /etc/sysctl.d/50-vpn-gateway.conf
# Remove routing table entries from rt_tables
sed -i '/eth1rt/d' /usr/share/iproute2/rt_tables
sed -i '/vpntunnel/d' /usr/share/iproute2/rt_tables
# Flush ip rules and routing tables
ip rule del from $ETH1_IP table 100 2>/dev/null || true
ip rule del fwmark 0x1 table 200 2>/dev/null || true
ip route flush table 200 2>/dev/null || true
ip route flush table 100 2>/dev/null || true
# Reset iptables
iptables -F
iptables -t nat -F
iptables -t mangle -F
iptables -X 2>/dev/null || true
iptables -P FORWARD ACCEPT
netfilter-persistent save 2>/dev/null || true
# Disable IP forwarding
sysctl -qw net.ipv4.ip_forward=0
# Remove eth1 NM connection
nmcli con delete eth1-static 2>/dev/null || true
echo ""
echo "Remove port ${LOCAL_DEST##*:} from target machine (${LOCAL_DEST%%:*}) firewall:"
echo " sudo iptables -D INPUT_direct -p tcp --dport ${LOCAL_DEST##*:} -j ACCEPT"
echo " Or using firewalld:"
echo " sudo firewall-cmd --permanent --direct --remove-rule ipv4 filter INPUT_direct 0 -p tcp --dport ${LOCAL_DEST##*:} -j ACCEPT"
echo " sudo firewall-cmd --reload"
echo ""
echo "=== Uninstall complete. All VPN gateway config removed. ==="
exit 0
fi
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
if [[ "$VPN_USER" == "your_windscribe_username" ]]; then
echo "Error: Edit VPN_USER and VPN_PASS in this script before running." >&2
exit 1
fi
if [[ ! -f "$OVPN_SOURCE" ]]; then
echo "Error: $OVPN_SOURCE not found." >&2
exit 1
fi
echo "=== 1/6 Configuring eth1 via nmcli ==="
# Remove existing connection for eth1 if any
existing=$(nmcli -t -f NAME,DEVICE con show 2>/dev/null | grep ':eth1$' | cut -d: -f1 || true)
if [[ -n "$existing" ]]; then
while read -r name; do
nmcli con delete "$name" 2>/dev/null || true
done <<< "$existing"
fi
nmcli con add type ethernet con-name eth1-static ifname eth1 \
ipv4.method manual \
ipv4.addresses "${ETH1_IP}/24" \
ipv4.never-default yes \
ipv6.method disabled \
connection.autoconnect yes
nmcli con up eth1-static || echo "Warning: eth1 may not be plugged in yet; will activate on connect."
echo "=== 2/6 Policy-based routing (dual-NIC same-subnet fix) ==="
# Add routing tables if not already present
if ! grep -q 'eth1rt' /usr/share/iproute2/rt_tables; then
echo "100 eth1rt" >> /usr/share/iproute2/rt_tables
fi
if ! grep -q 'vpntunnel' /usr/share/iproute2/rt_tables; then
echo "200 vpntunnel" >> /usr/share/iproute2/rt_tables
fi
# NM dispatcher script - re-applies policy routing whenever eth1 comes up
cat > /etc/NetworkManager/dispatcher.d/10-eth1-policy-route << DISPATCHER
#!/usr/bin/env bash
IFACE="\$1"
ACTION="\$2"
if [[ "\$IFACE" == "eth1" && "\$ACTION" == "up" ]]; then
# Policy rule: traffic originating from eth1's IP uses table 100
ip rule del from $ETH1_IP table 100 2>/dev/null || true
ip rule add from $ETH1_IP table 100
# Subnet route in table 100 so ARP/local replies go out eth1
ip route replace $SUBNET dev eth1 src $ETH1_IP table 100
# ARP / reverse-path filtering for both interfaces
sysctl -qw net.ipv4.conf.eth0.arp_filter=1
sysctl -qw net.ipv4.conf.eth1.arp_filter=1
sysctl -qw net.ipv4.conf.eth0.rp_filter=2
sysctl -qw net.ipv4.conf.eth1.rp_filter=2
fi
DISPATCHER
chmod 755 /etc/NetworkManager/dispatcher.d/10-eth1-policy-route
# Persist sysctl settings (also enables IP forwarding)
cat > /etc/sysctl.d/50-vpn-gateway.conf << 'SYSCTL'
net.ipv4.ip_forward = 1
net.ipv4.conf.eth0.arp_filter = 1
net.ipv4.conf.eth1.arp_filter = 1
net.ipv4.conf.eth0.rp_filter = 2
net.ipv4.conf.eth1.rp_filter = 2
SYSCTL
echo "=== 3/6 Configuring OpenVPN client ==="
# Credentials file
cat > /etc/openvpn/auth.txt << AUTHEOF
${VPN_USER}
${VPN_PASS}
AUTHEOF
chmod 600 /etc/openvpn/auth.txt
# Copy .ovpn and append customizations
cp "$OVPN_SOURCE" /etc/openvpn/client/windscribe.conf
cat >> /etc/openvpn/client/windscribe.conf << VPNCONF
# --- Gateway customizations ---
auth-user-pass /etc/openvpn/auth.txt
route-nopull
route ${VPN_REMOTE} 255.255.255.255 ${LAN_GW}
script-security 2
up /etc/openvpn/client/tun-up.sh
down /etc/openvpn/client/tun-down.sh
VPNCONF
# tun-up script - adds VPN default route in table 200 for marked packets
cat > /etc/openvpn/client/tun-up.sh << 'TUNUP'
#!/usr/bin/env bash
# OpenVPN passes: $1=tun_dev, env has route_vpn_gateway or ifconfig_remote
DEV="$1"
# Prefer route_vpn_gateway (pushed by server), fall back to ifconfig_remote
GW="${route_vpn_gateway:-${ifconfig_remote:-}}"
if [[ -n "$GW" ]]; then
ip route replace default via "$GW" dev "$DEV" table 200
else
ip route replace default dev "$DEV" table 200
fi
ip rule del fwmark 0x1 table 200 2>/dev/null || true
ip rule add fwmark 0x1 table 200
TUNUP
chmod 755 /etc/openvpn/client/tun-up.sh
# tun-down script - cleans up table 200
cat > /etc/openvpn/client/tun-down.sh << 'TUNDOWN'
#!/usr/bin/env bash
ip route flush table 200
ip rule del fwmark 0x1 table 200 2>/dev/null || true
TUNDOWN
chmod 755 /etc/openvpn/client/tun-down.sh
echo "=== 4/6 Enabling OpenVPN service (with restart on failure) ==="
systemctl enable openvpn-client@windscribe.service
# Add drop-in override for automatic restart on failure
mkdir -p /etc/systemd/system/openvpn-client@windscribe.service.d
cat > /etc/systemd/system/openvpn-client@windscribe.service.d/restart.conf << 'DROPIN'
[Service]
Restart=on-failure
RestartSec=5
DROPIN
systemctl daemon-reload
echo "=== 5/6 Configuring iptables (NAT + kill switch) ==="
# Ensure iptables-persistent is installed
if ! dpkg -s iptables-persistent &>/dev/null; then
DEBIAN_FRONTEND=noninteractive apt-get install -y iptables-persistent
fi
# Flush existing rules for a clean slate
iptables -F
iptables -t nat -F
iptables -t mangle -F
iptables -X 2>/dev/null || true
# Default policies
iptables -P INPUT ACCEPT
iptables -P OUTPUT ACCEPT
iptables -P FORWARD DROP # Kill switch: no forwarding unless tun0 is up
# Mangle: mark client subnet traffic for VPN routing (skip Pi's own IPs)
# Both NICs share the 192.168.0.0/24 subnet, so clients may arrive on either interface
iptables -t mangle -A PREROUTING -s $ETH0_IP -j RETURN
iptables -t mangle -A PREROUTING -s $ETH1_IP -j RETURN
iptables -t mangle -A PREROUTING -s $SUBNET -j MARK --set-mark 0x1
# NAT: masquerade client traffic leaving via tun0
iptables -t nat -A POSTROUTING -s $SUBNET -o tun0 -j MASQUERADE
# FORWARD: allow marked client traffic -> tun0
iptables -A FORWARD -m mark --mark 0x1 -o tun0 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT
# Port forwarding: DNAT incoming VPN static port to LAN client
iptables -t nat -A PREROUTING -i tun0 -p tcp --dport "$VPN_STATIC_PORT" -j DNAT --to-destination "$LOCAL_DEST"
iptables -t nat -A PREROUTING -i tun0 -p udp --dport "$VPN_STATIC_PORT" -j DNAT --to-destination "$LOCAL_DEST"
# Allow the DNAT'd forwarded traffic through
iptables -A FORWARD -i tun0 -p tcp -d "${LOCAL_DEST%%:*}" --dport "${LOCAL_DEST##*:}" -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT
iptables -A FORWARD -i tun0 -p udp -d "${LOCAL_DEST%%:*}" --dport "${LOCAL_DEST##*:}" -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT
# Hairpin NAT: SNAT DNAT'd traffic so replies return through the Pi
iptables -t nat -A POSTROUTING -d "${LOCAL_DEST%%:*}" -p tcp --dport "${LOCAL_DEST##*:}" -j MASQUERADE
iptables -t nat -A POSTROUTING -d "${LOCAL_DEST%%:*}" -p udp --dport "${LOCAL_DEST##*:}" -j MASQUERADE
# FORWARD: allow return traffic from tun0
iptables -A FORWARD -i tun0 -m state --state ESTABLISHED,RELATED -j ACCEPT
# Save rules
netfilter-persistent save
echo "=== 6/6 Enabling IP forwarding ==="
sysctl -p /etc/sysctl.d/50-vpn-gateway.conf
echo ""
echo "=== Setup complete - starting OpenVPN ==="
echo ""
systemctl start openvpn-client@windscribe.service
echo "Verify with:"
echo " sudo systemctl status openvpn-client@windscribe"
echo " ip addr show tun0"
echo " ip route show table 200"
echo " From a client (gateway=$ETH1_IP): curl https://ifconfig.me"
echo " Kill switch test: sudo systemctl stop openvpn-client@windscribe"
echo " -> client traffic should stop"
echo " Pi's own traffic: curl https://ifconfig.me -> should show home IP"
echo "Port forwarding target machine (${LOCAL_DEST%%:*}) firewall setup:"
echo " sudo iptables -I INPUT_direct 15 -p tcp --dport ${LOCAL_DEST##*:} -j ACCEPT"
echo " Or using firewalld (persistent):"
echo " sudo firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT_direct 0 -p tcp --dport ${LOCAL_DEST##*:} -j ACCEPT"
echo " sudo firewall-cmd --reload"
echo ""
echo "To uninstall: sudo bash $0 --uninstall"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment