Brings up a TUN interface via ssh -w and routes traffic from specific VM source IPs
through it using policy routing + conntrack marks. Inbound traffic to those VMs is
unaffected (CONNMARK ensures only VM-initiated connections are tunnelled).
Optionally, also forwards connections arriving on the remote side (host/proto/port) back through the tunnel to a specific VM on the gateway's LAN — a DNAT+SNAT port-forward anchored at the remote end. A forward may be a single port or an inclusive port range, which is always mapped 1:1 onto the same ports on the VM.
Multi-gateway safe: several gateways may tunnel into the same remote host at
once. All remote-side state is namespaced by REMOTE_TUN (unique on the remote) and
local state by LOCAL_TUN (unique on the gateway), so gateways never clobber each
other. Rules live in dedicated iptables chains that are flushed on every
(re)connect, so a changed config or a restart can never leave a stale rule shadowing
the current one. Reverse forwards whose (proto,host,port-range) overlaps an
existing DNAT to a different destination are skipped with a loud CONFLICT warning
instead of silently appending a dead DNAT.
[VM 192.168.1.x] → [gateway tunL: 10.99.x.1] ══ SSH TUN ══ [remote tunR: 10.99.x.2]
↑
inbound to remote_host:port ─┘ (reverse forward → VM_IP:VM_PORT)
Remote host — add to /etc/ssh/sshd_config and restart sshd:
PermitTunnel yes
Passwordless root SSH from gateway to remote:
ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519
ssh-copy-id -i /root/.ssh/id_ed25519.pub root@TARGET[Unit]
Description=SSH TUN tunnel to %I
After=network-online.target
Wants=network-online.target
[Service]
User=root
EnvironmentFile=/etc/default/ssh-tun@%i
ExecStart=/usr/local/bin/ssh-tun %i
RestartSec=10
Restart=always
[Install]
WantedBy=multi-user.targetTARGET=example-host
SSH_KEY=/root/.ssh/id_ed25519
# LOCAL_TUN — unique on THIS gateway (keys route table + local iptables chain)
# REMOTE_TUN — unique on the REMOTE host across ALL gateways connecting to it (0-63):
# keys the tunnel /30 and the remote iptables chains, so two gateways
# with different REMOTE_TUN never collide on the remote.
LOCAL_TUN=0
REMOTE_TUN=0
# --- Optional overrides (all default from the tun numbers; leave unset) -------
# ROUTE_TABLE=100 # = 100 + LOCAL_TUN, must be <=252
# LOCAL_TUN_IP=10.99.0.1 # /30 auto-carved from 10.99.0.0/16 by REMOTE_TUN
# REMOTE_TUN_IP=10.99.0.2
# comma-separated IPs/subnets routed through the tunnel (outbound: VM -> internet via remote)
VM_SOURCES=192.168.1.100,192.168.1.101,192.168.2.0/24
# comma-separated reverse port forwards (inbound: remote_host:ports -> VM_IP:VM_PORTS)
# format: proto:listen_host:listen_ports:vm_ip[:vm_ports]
# - listen_host: remote IP to match, or "*"/empty for any.
# With MULTIPLE gateways on one remote, a bare "*" on a shared public port
# (80/443) is rivalrous — only ONE gateway can own it; the rest are skipped
# with a CONFLICT warning. Give each gateway its own listen_host (distinct
# public IP), or front 80/443 with an SNI/Host L7 proxy.
# - listen_ports: a single port (2222) or an inclusive RANGE written with a dash
# (8000-8010). Ranges are contiguous only — "," is the entry separator, so a
# comma list of ports is not expressible; use several entries instead.
# - vm_ports: defaults to listen_ports. For a RANGE it MUST stay the default:
# the kernel preserves the original port when it already falls inside the DNAT
# range, so a range maps 1:1 onto the same port numbers; a *shifted* range
# (8000-8010 -> 9000-9010) is not expressible with iptables DNAT and is
# rejected at startup. Only a single port may be remapped (2222 -> 22).
PORT_FORWARDS=tcp:*:2222:192.168.1.100:22,tcp:*:8000-8010:192.168.1.100,udp::5300:192.168.1.101#!/bin/bash
# ssh-tun — L3 TUN tunnel over SSH with per-source routing + reverse port forwards.
# Multi-gateway safe. Uniqueness scopes:
# LOCAL_TUN — unique on THIS gateway (keys route table + local chain)
# REMOTE_TUN — unique on the REMOTE host across ALL gateways (keys /30 + remote chains)
# All remote-side state is namespaced by REMOTE_TUN so gateways never clobber each other.
set -u
source /etc/default/ssh-tun@"${1:?instance name required}"
: "${LOCAL_TUN:?LOCAL_TUN required (unique on this gateway)}"
: "${REMOTE_TUN:?REMOTE_TUN required (unique on the remote host, 0-63)}"
# --- Derived (all overridable in the env file) --------------------------------
ROUTE_TABLE="${ROUTE_TABLE:-$((100 + LOCAL_TUN))}" # local-only; must be <=252
MARK="${MARK:-$ROUTE_TABLE}"
# One /30 per REMOTE_TUN carved out of 10.99.0.0/16 (shared by both ends).
_o3=$(( REMOTE_TUN / 64 )); _o4=$(( (REMOTE_TUN % 64) * 4 ))
LOCAL_TUN_IP="${LOCAL_TUN_IP:-10.99.${_o3}.$((_o4 + 1))}"
REMOTE_TUN_IP="${REMOTE_TUN_IP:-10.99.${_o3}.$((_o4 + 2))}"
TUN_DEV="tun${LOCAL_TUN}"
RTUN="tun${REMOTE_TUN}"
# iptables chain names (chain limit 28 chars). Remote keyed by REMOTE_TUN
# (globally unique on remote); local keyed by LOCAL_TUN (unique on this gateway).
CH="STUN${REMOTE_TUN}" # remote nat PREROUTING (DNAT)
CHP="${CH}P" # remote nat POSTROUTING (SNAT)
CHF="${CH}F" # remote filter FORWARD
CHL="STUNL${LOCAL_TUN}" # local filter FORWARD (port-forwards)
SSH_OPTS=(
-o ServerAliveInterval=30
-o ServerAliveCountMax=3
-o ExitOnForwardFailure=yes
-o StrictHostKeyChecking=accept-new
-o ConnectTimeout=10
-i "${SSH_KEY}"
)
# --- Forward parsing ----------------------------------------------------------
# proto:listen_host:listen_ports:vm_ip[:vm_ports]; ports are "N" or "N-M".
# Sets: P HOST VM LPORTS (as written) LO HI (listen range, numeric)
# DPORTS/VPORTS — iptables match syntax ("22" or "8000:8010")
# DNAT_VPORTS — --to-destination syntax ("22" or "8000-8010")
# Returns 1 (with a reason on stderr) if the entry is malformed.
parse_fwd() {
IFS=':' read -r P HOST LPORTS VM VPORTS_IN <<< "$1"
if [ -z "${P}" ] || [ -z "${LPORTS}" ] || [ -z "${VM:-}" ]; then
echo "ssh-tun: bad forward '$1' (want proto:listen_host:listen_ports:vm_ip[:vm_ports])" >&2
return 1
fi
[ "${HOST}" = "*" ] && HOST=""
LO="${LPORTS%%-*}"; HI="${LPORTS##*-}"
VPORTS_IN="${VPORTS_IN:-${LPORTS}}"
VLO="${VPORTS_IN%%-*}"; VHI="${VPORTS_IN##*-}"
for _n in "${LO}" "${HI}" "${VLO}" "${VHI}"; do
case "${_n}" in
''|*[!0-9]*) echo "ssh-tun: bad port(s) in forward '$1'" >&2; return 1 ;;
esac
done
if [ "${LO}" -gt "${HI}" ] || [ "${VLO}" -gt "${VHI}" ]; then
echo "ssh-tun: reversed port range in forward '$1'" >&2
return 1
fi
# A range can only be forwarded onto the SAME ports: iptables DNAT keeps the
# original port when it is inside the range, but cannot shift a whole range.
if [ "${LO}" != "${HI}" ] && [ "${VPORTS_IN}" != "${LPORTS}" ]; then
echo "ssh-tun: '$1': a port RANGE maps 1:1 onto the same ports — drop the vm_ports field" >&2
return 1
fi
[ "${LO}" = "${HI}" ] && DPORTS="${LO}" || DPORTS="${LO}:${HI}"
if [ "${VLO}" = "${VHI}" ]; then
VPORTS="${VLO}"; DNAT_VPORTS="${VLO}"
else
VPORTS="${VLO}:${VHI}"; DNAT_VPORTS="${VLO}-${VHI}"
fi
}
# --- Remote-side helpers (inserted verbatim into the ssh command block) -------
# Conflict check: any existing DNAT for the same proto (and, when we ask for a
# specific listen_host, the same -d) whose destination port RANGE overlaps ours
# and whose --to-destination differs. Overlap, not string equality, so
# 8000-8010 and 8005 recognise each other.
REMOTE_HELPERS=$(cat <<'EOSH'
_stun_conflict() { # proto listen_host("" = any) lo hi our_to_destination
iptables -t nat -S 2>/dev/null | awk -v proto="$1" -v host="$2" \
-v lo="$3" -v hi="$4" -v dest="$5" '
/-j DNAT/ {
p = ""; d = ""; dp = ""; to = ""
for (i = 1; i < NF; i++) {
if ($i == "-p") p = $(i+1)
else if ($i == "-d") { d = $(i+1); sub(/\/.*/, "", d) }
else if ($i == "--dport") dp = $(i+1)
else if ($i == "--to-destination") to = $(i+1)
}
if (p != proto || dp == "" || to == dest) next
n = split(dp, r, ":")
rlo = r[1] + 0; rhi = (n > 1 ? r[2] + 0 : rlo)
if (rhi < lo + 0 || rlo > hi + 0) next # port ranges disjoint
if (host != "" && d != host) next # different listen address
print
}'
}
EOSH
)
# --- Build the remote command block (values expanded locally) -----------------
REMOTE_FWD_CMDS=""
IFS=',' read -ra FWDS <<< "${PORT_FORWARDS:-}"
for fwd in "${FWDS[@]}"; do
fwd="${fwd// /}"; [ -z "${fwd}" ] && continue
parse_fwd "${fwd}" || continue
dmatch=""
[ -n "${HOST}" ] && dmatch="-d ${HOST}"
REMOTE_FWD_CMDS+="
conflict=\$(_stun_conflict '${P}' '${HOST}' '${LO}' '${HI}' '${VM}:${DNAT_VPORTS}')
if [ -n \"\$conflict\" ]; then
echo \"ssh-tun[${CH}]: CONFLICT ${P}/${HOST:-*}:${LPORTS} already forwarded elsewhere, skipping:\" >&2
echo \"\$conflict\" >&2
else
ip route replace ${VM}/32 dev ${RTUN}
iptables -t nat -A ${CH} -p ${P} ${dmatch} --dport ${DPORTS} -j DNAT --to-destination ${VM}:${DNAT_VPORTS}
iptables -t nat -A ${CHP} -p ${P} -d ${VM} --dport ${VPORTS} -o ${RTUN} -j SNAT --to-source ${REMOTE_TUN_IP}
iptables -A ${CHF} -i ${RTUN} -p ${P} -d ${VM} --dport ${VPORTS} -j ACCEPT
iptables -A ${CHF} -o ${RTUN} -p ${P} -s ${VM} --sport ${VPORTS} -j ACCEPT
fi
"
done
# --- Teardown: local rules + (best-effort) remote chain cleanup ---------------
teardown() {
trap '' EXIT TERM INT
IFS=',' read -ra SOURCES <<< "${VM_SOURCES:-}"
for src in "${SOURCES[@]}"; do
src="${src// /}"; [ -z "${src}" ] && continue
iptables -t mangle -D PREROUTING -s "${src}" -m conntrack --ctstate NEW -j MARK --set-mark "${MARK}" 2>/dev/null || true
iptables -t mangle -D POSTROUTING -s "${src}" -m mark --mark "${MARK}" -j CONNMARK --save-mark 2>/dev/null || true
iptables -t mangle -D PREROUTING -s "${src}" -m conntrack --ctstate ESTABLISHED,RELATED -j CONNMARK --restore-mark 2>/dev/null || true
done
ip rule del fwmark "${MARK}" table "${ROUTE_TABLE}" priority 100 2>/dev/null || true
ip route flush table "${ROUTE_TABLE}" 2>/dev/null || true
iptables -D FORWARD -m mark --mark "${MARK}" -o "${TUN_DEV}" -j ACCEPT 2>/dev/null || true
iptables -t nat -D POSTROUTING -o "${TUN_DEV}" -j MASQUERADE 2>/dev/null || true
iptables -D FORWARD -j "${CHL}" 2>/dev/null || true
iptables -F "${CHL}" 2>/dev/null || true
iptables -X "${CHL}" 2>/dev/null || true
# remote: drop our own chains (independent short ssh; best-effort — next
# start's -F self-heals if the remote is unreachable now).
timeout 20 ssh "${SSH_OPTS[@]}" root@"${TARGET}" "
iptables -t nat -D POSTROUTING -s ${LOCAL_TUN_IP}/30 ! -o ${RTUN} -j MASQUERADE 2>/dev/null
iptables -t nat -D PREROUTING -j ${CH} 2>/dev/null; iptables -t nat -F ${CH} 2>/dev/null; iptables -t nat -X ${CH} 2>/dev/null
iptables -t nat -D POSTROUTING -j ${CHP} 2>/dev/null; iptables -t nat -F ${CHP} 2>/dev/null; iptables -t nat -X ${CHP} 2>/dev/null
iptables -D FORWARD -j ${CHF} 2>/dev/null; iptables -F ${CHF} 2>/dev/null; iptables -X ${CHF} 2>/dev/null
" 2>/dev/null || true
kill "$SSH_PID" 2>/dev/null
wait "$SSH_PID" 2>/dev/null
}
trap teardown EXIT TERM INT
# --- Bring up the tunnel; remote block re-applied idempotently on connect -----
ssh -w "${LOCAL_TUN}:${REMOTE_TUN}" "${SSH_OPTS[@]}" root@"${TARGET}" "
sysctl -qw net.ipv4.ip_forward=1
ip link set ${RTUN} up
ip addr replace ${REMOTE_TUN_IP}/30 dev ${RTUN}
iptables -t nat -C POSTROUTING -s ${LOCAL_TUN_IP}/30 ! -o ${RTUN} -j MASQUERADE 2>/dev/null \
|| iptables -t nat -A POSTROUTING -s ${LOCAL_TUN_IP}/30 ! -o ${RTUN} -j MASQUERADE
# Per-tunnel chains: flush on every (re)connect so stale rules from an old
# PORT_FORWARDS config can never shadow the current one.
iptables -t nat -N ${CH} 2>/dev/null || true; iptables -t nat -F ${CH}
iptables -t nat -N ${CHP} 2>/dev/null || true; iptables -t nat -F ${CHP}
iptables -N ${CHF} 2>/dev/null || true; iptables -F ${CHF}
iptables -t nat -C PREROUTING -j ${CH} 2>/dev/null || iptables -t nat -A PREROUTING -j ${CH}
iptables -t nat -C POSTROUTING -j ${CHP} 2>/dev/null || iptables -t nat -A POSTROUTING -j ${CHP}
iptables -C FORWARD -j ${CHF} 2>/dev/null || iptables -A FORWARD -j ${CHF}
${REMOTE_HELPERS}
${REMOTE_FWD_CMDS}
sleep infinity
" &
SSH_PID=$!
# ssh -w creates the tun device asynchronously
for i in $(seq 1 30); do
ip link show "${TUN_DEV}" &>/dev/null && break
[ "$i" -eq 30 ] && { echo "ERROR: ${TUN_DEV} did not appear after 30s"; exit 1; }
sleep 1
done
ip link set "${TUN_DEV}" up
ip addr replace "${LOCAL_TUN_IP}/30" dev "${TUN_DEV}"
ip route replace default via "${REMOTE_TUN_IP}" dev "${TUN_DEV}" table "${ROUTE_TABLE}"
ip rule show | grep -q "fwmark ${MARK} lookup ${ROUTE_TABLE}" \
|| ip rule add fwmark "${MARK}" table "${ROUTE_TABLE}" priority 100
iptables -t nat -C POSTROUTING -o "${TUN_DEV}" -j MASQUERADE 2>/dev/null \
|| iptables -t nat -A POSTROUTING -o "${TUN_DEV}" -j MASQUERADE
iptables -C FORWARD -m mark --mark "${MARK}" -o "${TUN_DEV}" -j ACCEPT 2>/dev/null \
|| iptables -A FORWARD -m mark --mark "${MARK}" -o "${TUN_DEV}" -j ACCEPT
sysctl -qw net.ipv4.ip_forward=1
# Mark only NEW connections initiated by the VM; replies to inbound connections
# carry connmark=0 and are not redirected, preventing asymmetric routing.
IFS=',' read -ra SOURCES <<< "${VM_SOURCES:-}"
for src in "${SOURCES[@]}"; do
src="${src// /}"; [ -z "${src}" ] && continue
iptables -t mangle -C PREROUTING -s "${src}" -m conntrack --ctstate NEW -j MARK --set-mark "${MARK}" 2>/dev/null \
|| iptables -t mangle -A PREROUTING -s "${src}" -m conntrack --ctstate NEW -j MARK --set-mark "${MARK}"
iptables -t mangle -C POSTROUTING -s "${src}" -m mark --mark "${MARK}" -j CONNMARK --save-mark 2>/dev/null \
|| iptables -t mangle -A POSTROUTING -s "${src}" -m mark --mark "${MARK}" -j CONNMARK --save-mark
iptables -t mangle -C PREROUTING -s "${src}" -m conntrack --ctstate ESTABLISHED,RELATED -j CONNMARK --restore-mark 2>/dev/null \
|| iptables -t mangle -A PREROUTING -s "${src}" -m conntrack --ctstate ESTABLISHED,RELATED -j CONNMARK --restore-mark
done
# Local port-forward FORWARD accepts, namespaced + flushed on start.
iptables -N ${CHL} 2>/dev/null || true; iptables -F ${CHL}
iptables -C FORWARD -j ${CHL} 2>/dev/null || iptables -A FORWARD -j ${CHL}
IFS=',' read -ra FWDS <<< "${PORT_FORWARDS:-}"
for fwd in "${FWDS[@]}"; do
fwd="${fwd// /}"; [ -z "${fwd}" ] && continue
parse_fwd "${fwd}" || continue
iptables -A ${CHL} -i "${TUN_DEV}" -p "${P}" -d "${VM}" --dport "${VPORTS}" -j ACCEPT
iptables -A ${CHL} -o "${TUN_DEV}" -p "${P}" -s "${VM}" --sport "${VPORTS}" -j ACCEPT
done
echo "ssh-tun@${1}: ${TUN_DEV}<->${RTUN} up, ${LOCAL_TUN_IP} <-> ${REMOTE_TUN_IP}, sources: ${VM_SOURCES:-none}"
[ -n "${PORT_FORWARDS:-}" ] && echo "ssh-tun@${1}: reverse forwards: ${PORT_FORWARDS}"
wait "$SSH_PID"cp etc/systemd/system/ssh-tun@.service /etc/systemd/system/
cp usr/local/bin/ssh-tun /usr/local/bin/ && chmod +x /usr/local/bin/ssh-tun
cp etc/default/ssh-tun@example /etc/default/ssh-tun@my-host
# edit /etc/default/ssh-tun@my-host — set LOCAL_TUN / REMOTE_TUN
systemctl daemon-reload
systemctl enable --now ssh-tun@my-host.serviceMultiple tunnels / gateways — each needs a unique LOCAL_TUN (on its gateway) and a
unique REMOTE_TUN (across all gateways landing on the same remote):
systemctl enable --now ssh-tun@host-a ssh-tun@host-b- Remote-side rules live in per-tunnel chains
STUN<REMOTE_TUN>(nat PREROUTING / DNAT),STUN<REMOTE_TUN>P(nat POSTROUTING / SNAT),STUN<REMOTE_TUN>F(FORWARD). They are flushed (-F) on every (re)connect, so a restart or a changedPORT_FORWARDScan never leave a stale rule shadowing the new one (the original failure mode of writing straight intoPREROUTING). - Local-side port-forward accepts live in
STUNL<LOCAL_TUN>, flushed on start. teardown()removes the local rules and — via a short independent ssh — the remote chains + base MASQUERADE onsystemctl stop. If the remote is unreachable at stop time, the next start's-Fself-heals it.- Port conflicts: if two tunnels/gateways forward overlapping
(proto[,host], ports)to different destinations, the later one is skipped with aCONFLICTline on stderr (visible injournalctl -u ssh-tun@<name>) rather than appending a dead DNAT that a first-match rule would silently override. Overlap is compared numerically, so8000-8010and a lone8005do see each other. Entries of onePORT_FORWARDSlist are applied in order, so an entry overlapping an earlier one in the same list is skipped too. - Malformed entries (non-numeric or reversed ports, a range with an explicit
vm_ports) are reported on stderr and skipped; the rest of the list still applies.
For each PORT_FORWARDS entry (proto:listen_host:listen_ports:vm_ip[:vm_ports],
where ports are N or N-M):
- Remote side (inside the
sshcommand block, re-applied idempotently on every (re)connect, into chainSTUN<REMOTE_TUN>*):- conflict check: skip if an overlapping port range is already forwarded elsewhere.
ip route replace <vm_ip>/32 dev tun<REMOTE_TUN>— packets for the VM leave via the tunnel.DNAT:listen_host:listen_ports → vm_ip:vm_ports(--dport N:Mfor a range,--to-destination ip:N-M).SNATfor packets leaving the tun towardvm_ip:vm_ports, source →REMOTE_TUN_IP. This makes the VM's reply route back through the tunnel; conntrack un-SNATs/un-DNATs the return traffic automatically.FORWARDACCEPT rules both directions (needed if the remote's forward policy is DROP).
- Gateway side (
STUNL<LOCAL_TUN>):FORWARDACCEPT fortunL → vm_ip:vm_portsand the reverse, since the packet still crosses the gateway's forward chain to reach the LAN.
iptables -j DNAT --to-destination ip:8000-8010 does not rewrite the port when the
original destination port already lies inside the range and the resulting tuple is free
— get_unique_tuple() in nf_nat_core.c short-circuits on l4proto_in_range(). So
:8007 arrives at the VM as :8007. There is no way to express a shifted range with
the iptables DNAT target (NF_NAT_RANGE_PROTO_OFFSET is nftables-only), which is why
vm_ports may only be given for a single port.