Last active
April 1, 2026 14:37
-
-
Save xen0bit/2eef4ce5641c9a6a12be6d26edcb1dcb to your computer and use it in GitHub Desktop.
sshvpn
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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