Skip to content

Instantly share code, notes, and snippets.

@xen0bit
Last active April 1, 2026 14:37
Show Gist options
  • Select an option

  • Save xen0bit/2eef4ce5641c9a6a12be6d26edcb1dcb to your computer and use it in GitHub Desktop.

Select an option

Save xen0bit/2eef4ce5641c9a6a12be6d26edcb1dcb to your computer and use it in GitHub Desktop.
sshvpn
#!/usr/bin/env bash
set -euo pipefail
########################################
# sshvpn — unified SSH tunnel manager
#
# No systemd required. Runs as a foreground process.
# Use screen/tmux/nohup to run in background, or any process supervisor.
#
# MODES
# forward Route all local traffic out through the remote (VPN).
# Your public IP becomes the remote's public IP.
#
# reverse Forward all traffic arriving at the remote's public IP
# back through the tunnel to the local machine.
# Preserves original source IPs. Local listener must use
# IP_TRANSPARENT (CAP_NET_ADMIN / root).
#
# stop Tear down all rules and exit.
#
# USAGE
# ./sshvpn.sh forward user@remote [ssh_port]
# ./sshvpn.sh reverse user@remote [ssh_port]
# ./sshvpn.sh stop user@remote [ssh_port]
#
# TUNNEL ADDRESSING
# Local tun0: 10.200.0.1/30
# Remote tun0: 10.200.0.2/30
#
# POLICY ROUTING TABLE IDs
# 100 — remote: routes DNAT'd traffic destined for 10.200.0.1 out tun0
# 200 — local: routes replies sourced from 10.200.0.1 back through tun0
########################################
########################################
# ARG PARSING
########################################
usage() {
echo "Usage: $0 {forward|reverse|stop} user@remote [ssh_port]" >&2
exit 1
}
MODE="${1:-}"
REMOTE="${2:-}"
REMOTE_PORT="${3:-22}"
[[ "$MODE" =~ ^(forward|reverse|stop)$ ]] || usage
[[ -n "$REMOTE" ]] || usage
REMOTE_USER="${REMOTE%%@*}"
VPN_HOST="${REMOTE##*@}"
########################################
# CONSTANTS
########################################
KEY="$HOME/.ssh/id_sshvpn"
LOCAL_TUN_IP="10.200.0.1"
REMOTE_TUN_IP="10.200.0.2"
TUN_SUBNET="10.200.0.0/30"
REMOTE_TABLE=100
LOCAL_TABLE=200
SSH_TUNNEL_PID=""
########################################
# HELPERS
########################################
log() { echo "[*] $*"; }
err() { echo "[!] $*" >&2; }
die() { err "$@"; exit 1; }
require_bin() {
command -v "$1" >/dev/null 2>&1 || die "Required binary not found: $1"
}
ssh_remote() {
ssh -p "$REMOTE_PORT" \
-i "$KEY" \
-o StrictHostKeyChecking=yes \
-o BatchMode=yes \
"$REMOTE" "$@"
}
########################################
# TEARDOWN
# Called on exit (signal or error) and by the stop subcommand.
# Idempotent — every rule deletion tolerates already-gone state.
########################################
teardown() {
log "Tearing down..."
# Kill the SSH tunnel process if we started one
if [[ -n "$SSH_TUNNEL_PID" ]]; then
kill "$SSH_TUNNEL_PID" 2>/dev/null || true
wait "$SSH_TUNNEL_PID" 2>/dev/null || true
fi
# Local forward-mode route cleanup
sudo ip route del default dev tun0 2>/dev/null || true
# Restore original default route from backup
local backup
backup=$(cat "$HOME/.ssh/.default_route_backup" 2>/dev/null || true)
if [[ -n "$backup" ]]; then
sudo ip route replace $backup 2>/dev/null || true
fi
# Local reverse-mode route cleanup
sudo ip rule del from "${LOCAL_TUN_IP}" table "${LOCAL_TABLE}" 2>/dev/null || true
sudo ip route flush table "${LOCAL_TABLE}" 2>/dev/null || true
# Local tun interface cleanup
sudo ip route del "${VPN_IP}/32" 2>/dev/null || true
sudo ip link del tun0 2>/dev/null || true
# Remote cleanup — best-effort, tunnel may already be down
ssh_remote bash <<REMOTE_TEARDOWN 2>/dev/null || true
REMOTE_IF=\$(ip route show default | awk '/default/{print \$5;exit}')
# Forward-mode cleanup
sudo iptables -t nat -D POSTROUTING -o "\$REMOTE_IF" -j MASQUERADE 2>/dev/null || true
# Reverse-mode cleanup
sudo iptables -t nat -D PREROUTING -i "\$REMOTE_IF" -p tcp \
-j DNAT --to-destination ${LOCAL_TUN_IP} 2>/dev/null || true
sudo iptables -t nat -D PREROUTING -i "\$REMOTE_IF" -p udp \
-j DNAT --to-destination ${LOCAL_TUN_IP} 2>/dev/null || true
sudo iptables -t nat -D PREROUTING -i "\$REMOTE_IF" -p icmp \
-j DNAT --to-destination ${LOCAL_TUN_IP} 2>/dev/null || true
sudo iptables -t nat -D PREROUTING -i "\$REMOTE_IF" -p tcp \
--dport ${REMOTE_PORT} -j ACCEPT 2>/dev/null || true
sudo iptables -D FORWARD -i "\$REMOTE_IF" -o tun0 -j ACCEPT 2>/dev/null || true
sudo iptables -D FORWARD -i tun0 -o "\$REMOTE_IF" -j ACCEPT 2>/dev/null || true
sudo iptables -t mangle -D PREROUTING -i tun0 \
-m conntrack --ctstate ESTABLISHED,RELATED \
--ctorigsrc 0.0.0.0/0 --ctorigdst ${LOCAL_TUN_IP} \
-j MARK --set-mark ${REMOTE_TABLE} 2>/dev/null || true
sudo ip rule del to ${LOCAL_TUN_IP} table ${REMOTE_TABLE} 2>/dev/null || true
sudo ip rule del fwmark ${REMOTE_TABLE} table ${REMOTE_TABLE} 2>/dev/null || true
sudo ip route flush table ${REMOTE_TABLE} 2>/dev/null || true
sudo iptables -t nat -D POSTROUTING -o "\$REMOTE_IF" -s ${TUN_SUBNET} -j MASQUERADE 2>/dev/null || true
# Restore broad MASQUERADE for any remaining tunnel traffic
sudo iptables -t nat -C POSTROUTING -o "\$REMOTE_IF" -j MASQUERADE 2>/dev/null || \
sudo iptables -t nat -A POSTROUTING -o "\$REMOTE_IF" -j MASQUERADE
# Common remote cleanup
sudo iptables -D FORWARD -i tun0 -o "\$REMOTE_IF" -j ACCEPT 2>/dev/null || true
sudo iptables -D FORWARD -i "\$REMOTE_IF" -o tun0 \
-m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
sudo sysctl -w net.ipv4.icmp_echo_ignore_all=0 2>/dev/null || true
REMOTE_TEARDOWN
log "Done"
}
########################################
# STOP SUBCOMMAND
########################################
if [[ "$MODE" == "stop" ]]; then
# Resolve VPN_IP so teardown can remove the host route
VPN_IP=$(getent hosts "$VPN_HOST" | awk '{print $1; exit}') || true
VPN_IP="${VPN_IP:-$VPN_HOST}"
teardown
exit 0
fi
########################################
# SHARED SETUP
########################################
log "Resolving $VPN_HOST to an IP address"
VPN_IP=$(getent hosts "$VPN_HOST" | awk '{print $1; exit}')
[[ -n "$VPN_IP" ]] || die "Could not resolve $VPN_HOST to an IP address"
log " $VPN_HOST -> $VPN_IP"
log "Detecting local default-interface MTU"
DEFAULT_IF=$(ip route show default | awk '/default/{print $5; exit}')
[[ -n "$DEFAULT_IF" ]] || die "Could not determine default network interface"
RAW_MTU=$(cat "/sys/class/net/${DEFAULT_IF}/mtu" 2>/dev/null) \
|| die "Could not read MTU for interface $DEFAULT_IF"
TUNNEL_MTU=$(( RAW_MTU - 80 ))
log " Interface $DEFAULT_IF MTU: $RAW_MTU -> tun0 MTU: $TUNNEL_MTU"
log "Checking local dependencies"
for bin in ssh ssh-keygen ssh-copy-id sudo ip awk getent; do
require_bin "$bin"
done
log "Checking remote dependencies"
ssh -p "$REMOTE_PORT" "$REMOTE" bash <<'REMOTE_CHECK' || die "Remote dependency check failed"
set -e
for bin in ip awk sudo iptables sysctl; do
command -v "$bin" >/dev/null 2>&1 || { echo "Missing remote binary: $bin" >&2; exit 1; }
done
REMOTE_CHECK
log "Ensuring SSH key exists"
if [[ ! -f "$KEY" ]]; then
ssh-keygen -t ed25519 -f "$KEY" -N "" -C ssh-vpn \
|| die "SSH key generation failed"
fi
log "Capturing remote host key into known_hosts"
KNOWN_HOSTS="$HOME/.ssh/known_hosts"
touch "$KNOWN_HOSTS"
chmod 600 "$KNOWN_HOSTS"
ssh-keygen -R "[${VPN_HOST}]:${REMOTE_PORT}" -f "$KNOWN_HOSTS" 2>/dev/null || true
if [[ "$REMOTE_PORT" == "22" ]]; then
ssh-keyscan -H "$VPN_HOST" >> "$KNOWN_HOSTS" 2>/dev/null \
|| die "ssh-keyscan failed — check host reachability"
else
ssh-keyscan -H -p "$REMOTE_PORT" "$VPN_HOST" >> "$KNOWN_HOSTS" 2>/dev/null \
|| die "ssh-keyscan failed — check host reachability"
fi
log "Negotiating cipher"
CIPHER_OPT=""
for cipher in aes128-gcm@openssh.com chacha20-poly1305@openssh.com; do
if ssh -p "$REMOTE_PORT" \
-i "$KEY" \
-o StrictHostKeyChecking=yes \
-o BatchMode=yes \
-o ConnectTimeout=5 \
-o Ciphers="$cipher" \
"$REMOTE" true 2>/dev/null; then
CIPHER_OPT="$cipher"
log " Using cipher: $CIPHER_OPT"
break
fi
done
[[ -n "$CIPHER_OPT" ]] || log " Preferred ciphers unavailable — using server default"
log "Installing SSH key on remote"
ssh-copy-id -i "$KEY.pub" -p "$REMOTE_PORT" "$REMOTE" \
|| die "ssh-copy-id failed (check SSH access)"
########################################
# REMOTE SSHD + FORWARDING
########################################
log "Configuring remote sshd and ip_forward"
ssh -p "$REMOTE_PORT" "$REMOTE" bash <<EOF || die "Remote sshd/sysctl setup failed"
set -e
if grep -q '^#\?PermitTunnel' /etc/ssh/sshd_config; then
sudo sed -i 's/^#\?PermitTunnel.*/PermitTunnel yes/' /etc/ssh/sshd_config
else
echo 'PermitTunnel yes' | sudo tee -a /etc/ssh/sshd_config > /dev/null
fi
sudo systemctl reload ssh 2>/dev/null || sudo systemctl reload sshd 2>/dev/null \
|| sudo systemctl restart sshd
sudo sysctl -w net.ipv4.ip_forward=1
sudo ip tuntap add dev tun0 mode tun user ${REMOTE_USER} 2>/dev/null || true
EOF
########################################
# SIGNAL TRAP
# Registered after setup so Ctrl-C during setup doesn't attempt teardown
# before VPN_IP is resolved or the tunnel is open.
########################################
SHUTDOWN=false
handle_signal() {
SHUTDOWN=true
teardown
exit 0
}
trap teardown EXIT
trap handle_signal INT TERM
########################################
# TUNNEL LOOP
# Opens the SSH tunnel, configures local networking, then blocks.
# On SSH exit (network drop, remote reboot, etc.) waits 5 seconds
# and reconnects automatically. Exits cleanly on SIGINT/SIGTERM.
########################################
tunnel_up=false
bring_up_local() {
# Configure local tun0 and routing after the SSH tunnel opens.
# Wait up to 15 seconds for the kernel to create the tun0 interface.
local i=0
while ! ip link show tun0 >/dev/null 2>&1; do
sleep 0.5; i=$((i+1))
[[ $i -lt 30 ]] || { err "Timed out waiting for tun0"; return 1; }
done
# Backup current default route for restoration on teardown
ip route show default | head -1 > "$HOME/.ssh/.default_route_backup"
sudo ip addr add "${LOCAL_TUN_IP}/30" dev tun0 2>/dev/null || true
sudo ip link set tun0 up
sudo ip link set tun0 mtu "$TUNNEL_MTU"
# Host route for the VPN server — keeps the SSH session out of the tunnel
local gw dev
read -r _ _ gw _ dev _ < "$HOME/.ssh/.default_route_backup"
sudo ip route add "${VPN_IP}/32" via "$gw" dev "$dev" 2>/dev/null || true
if [[ "$MODE" == "forward" ]]; then
sudo ip route replace default via "$REMOTE_TUN_IP" dev tun0
fi
tunnel_up=true
log "Tunnel up (mode: $MODE)"
}
bring_down_local() {
# Remove local networking state between reconnect attempts.
tunnel_up=false
sudo ip route del default dev tun0 2>/dev/null || true
sudo ip route del "${VPN_IP}/32" 2>/dev/null || true
sudo ip link del tun0 2>/dev/null || true
}
run_tunnel() {
# Build SSH args
local ssh_args=(
-i "$KEY"
-o StrictHostKeyChecking=yes
-o UserKnownHostsFile="$KNOWN_HOSTS"
-o ExitOnForwardFailure=yes
-o ServerAliveInterval=10
-o ServerAliveCountMax=3
-o TCPKeepAlive=no
-o Compression=no
-o IPQoS=none
-w 0:0
-p "$REMOTE_PORT"
)
[[ -n "$CIPHER_OPT" ]] && ssh_args+=(-o "Ciphers=$CIPHER_OPT")
# The remote command configures tun0 and holds the session open.
# sudo is required locally to open /dev/net/tun.
sudo ssh "${ssh_args[@]}" "$REMOTE" bash <<'REMOTE_CMD' &
set -e
REMOTE_IF=$(ip route show default | awk '/default/{print $5;exit}')
[ -n "$REMOTE_IF" ] || { echo "sshvpn: no default route on remote" >&2; exit 1; }
sudo ip addr add 10.200.0.2/30 dev tun0 2>/dev/null || true
sudo ip link set tun0 up
sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -t nat -C POSTROUTING -o "$REMOTE_IF" -j MASQUERADE 2>/dev/null || \
sudo iptables -t nat -A POSTROUTING -o "$REMOTE_IF" -j MASQUERADE
sudo iptables -C FORWARD -i tun0 -o "$REMOTE_IF" -j ACCEPT 2>/dev/null || \
sudo iptables -A FORWARD -i tun0 -o "$REMOTE_IF" -j ACCEPT
sudo iptables -C FORWARD -i "$REMOTE_IF" -o tun0 -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \
sudo iptables -A FORWARD -i "$REMOTE_IF" -o tun0 -m state --state RELATED,ESTABLISHED -j ACCEPT
sudo sysctl -w net.ipv4.icmp_echo_ignore_all=1
exec sleep infinity
REMOTE_CMD
SSH_TUNNEL_PID=$!
}
########################################
# REVERSE MODE SETUP
########################################
setup_reverse() {
log "Installing remote reverse rules"
ssh_remote bash <<REMOTE_REVERSE || die "Remote reverse setup failed"
set -e
REMOTE_IF=\$(ip route show default | awk '/default/{print \$5;exit}')
[[ -n "\$REMOTE_IF" ]] || { echo "No default interface" >&2; exit 1; }
sudo ip route replace default dev tun0 table ${REMOTE_TABLE} 2>/dev/null || \
sudo ip route add default dev tun0 table ${REMOTE_TABLE}
sudo ip rule show | grep -q "to ${LOCAL_TUN_IP} lookup ${REMOTE_TABLE}" || \
sudo ip rule add to ${LOCAL_TUN_IP} table ${REMOTE_TABLE} priority 100
sudo ip rule show | grep -q "fwmark 0x${REMOTE_TABLE} lookup ${REMOTE_TABLE}" || \
sudo ip rule add fwmark ${REMOTE_TABLE} table ${REMOTE_TABLE} priority 101
# Mark only original-direction DNAT'd flows arriving from tun0.
# Reply packets (silence -> internet) must NOT be marked so they
# exit via the main table through ens3, not loop back into tun0.
sudo iptables -t mangle -C PREROUTING -i tun0 \
-m conntrack --ctstate ESTABLISHED,RELATED \
--ctorigsrc 0.0.0.0/0 --ctorigdst ${LOCAL_TUN_IP} \
-j MARK --set-mark ${REMOTE_TABLE} 2>/dev/null || \
sudo iptables -t mangle -A PREROUTING -i tun0 \
-m conntrack --ctstate ESTABLISHED,RELATED \
--ctorigsrc 0.0.0.0/0 --ctorigdst ${LOCAL_TUN_IP} \
-j MARK --set-mark ${REMOTE_TABLE}
sudo iptables -C FORWARD -i "\$REMOTE_IF" -o tun0 -j ACCEPT 2>/dev/null || \
sudo iptables -A FORWARD -i "\$REMOTE_IF" -o tun0 -j ACCEPT
sudo iptables -C FORWARD -i tun0 -o "\$REMOTE_IF" -j ACCEPT 2>/dev/null || \
sudo iptables -A FORWARD -i tun0 -o "\$REMOTE_IF" -j ACCEPT
# Protect SSH port, then DNAT everything else to local machine
sudo iptables -t nat -C PREROUTING -i "\$REMOTE_IF" -p tcp \
--dport ${REMOTE_PORT} -j ACCEPT 2>/dev/null || \
sudo iptables -t nat -I PREROUTING 1 -i "\$REMOTE_IF" -p tcp \
--dport ${REMOTE_PORT} -j ACCEPT
sudo iptables -t nat -C PREROUTING -i "\$REMOTE_IF" -p tcp \
-j DNAT --to-destination ${LOCAL_TUN_IP} 2>/dev/null || \
sudo iptables -t nat -A PREROUTING -i "\$REMOTE_IF" -p tcp \
-j DNAT --to-destination ${LOCAL_TUN_IP}
sudo iptables -t nat -C PREROUTING -i "\$REMOTE_IF" -p udp \
-j DNAT --to-destination ${LOCAL_TUN_IP} 2>/dev/null || \
sudo iptables -t nat -A PREROUTING -i "\$REMOTE_IF" -p udp \
-j DNAT --to-destination ${LOCAL_TUN_IP}
sudo iptables -t nat -C PREROUTING -i "\$REMOTE_IF" -p icmp \
-j DNAT --to-destination ${LOCAL_TUN_IP} 2>/dev/null || \
sudo iptables -t nat -A PREROUTING -i "\$REMOTE_IF" -p icmp \
-j DNAT --to-destination ${LOCAL_TUN_IP}
# Swap broad MASQUERADE for scoped — only tunnel-originated traffic gets NATted.
# Externally-forwarded traffic must leave with its original source IP.
sudo iptables -t nat -D POSTROUTING -o "\$REMOTE_IF" -j MASQUERADE 2>/dev/null || true
sudo iptables -t nat -C POSTROUTING -o "\$REMOTE_IF" -s ${TUN_SUBNET} -j MASQUERADE 2>/dev/null || \
sudo iptables -t nat -A POSTROUTING -o "\$REMOTE_IF" -s ${TUN_SUBNET} -j MASQUERADE
echo "[remote] Reverse rules installed on \$REMOTE_IF"
REMOTE_REVERSE
log "Installing local return-path routing"
sudo ip route replace default via "${REMOTE_TUN_IP}" dev tun0 \
table "${LOCAL_TABLE}" 2>/dev/null || \
sudo ip route add default via "${REMOTE_TUN_IP}" dev tun0 \
table "${LOCAL_TABLE}"
sudo ip rule show | grep -q "from ${LOCAL_TUN_IP} lookup ${LOCAL_TABLE}" || \
sudo ip rule add from "${LOCAL_TUN_IP}" table "${LOCAL_TABLE}" priority 100
}
########################################
# MAIN LOOP
########################################
log "Starting tunnel (mode: $MODE) — Ctrl-C to stop"
while true; do
run_tunnel
bring_up_local || { bring_down_local; sleep 5; continue; }
# Install reverse rules once, after first successful tunnel-up
if [[ "$MODE" == "reverse" ]] && ! ${REVERSE_DONE:-false}; then
setup_reverse
REVERSE_DONE=true
log ""
log "All traffic arriving at $VPN_IP is forwarded to ${LOCAL_TUN_IP}."
log "Local listeners must use IP_TRANSPARENT (CAP_NET_ADMIN / root)."
fi
# Block until the SSH tunnel process exits
wait "$SSH_TUNNEL_PID" || true
SSH_TUNNEL_PID=""
# Exit cleanly if a signal was received, otherwise reconnect
if $SHUTDOWN; then
break
fi
bring_down_local
log "Tunnel lost — reconnecting in 5s"
sleep 5
done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment