Full-system VPN over a single SSH connection, supervised by systemd.
All traffic from a Linux machine — including Docker containers — leaves through one SSH session to a VPS you control. From the outside, the machine only ever speaks SSH on one port to one address. Nothing else leaves it.
A fail-closed firewall (kill switch) is armed at boot, before the network starts. If the tunnel is down, the machine has no internet. It does not fall back to the plain link.
No extra VPN software is needed. Only OpenSSH, iptables, and iproute2.
This is not WireGuard or OpenVPN. It uses OpenSSH's -w tun forwarding, which
carries IP packets inside a TCP session. Read Pros and cons
before you choose it. It is a good fit when SSH is the only protocol you can
use, and a poor fit on a lossy or mobile link.
Client (the machine being protected):
- Linux with systemd
- NetworkManager (used for the auto-repin hook; the rest works without it)
iptables,iproute2,openssh-client- root access
VPS (the exit node):
- A Linux server with a public IP
- SSH login as root, with key authentication (no password prompt)
PermitTunnel point-to-pointin its sshd config —ssh-tunnel.sh vps-fixsets this for you
The VPS IP must be a fixed, raw IPv4 address. The kill switch is armed before the network is up and blocks DNS, so a hostname cannot be resolved at that point.
ssh -w 0:0 creates a real point-to-point IP link, tun0, inside one SSH
session. The client gets 10.77.0.2, the VPS gets 10.77.0.1.
On connect, the client tells the VPS to bring up its end, enable IP forwarding,
and NAT the tunnel subnet out to the internet. That part is idempotent and runs
on every reconnect. Set REMOTE_SETUP_NAT=0 if you configured the VPS by hand.
The default route is replaced by two halves, 0.0.0.0/1 and 128.0.0.0/1,
both pointing at tun0. Two halves beat a real 0.0.0.0/0 on prefix length,
so the physical default route stays in the table untouched and can be restored
without guessing what it was.
One host route is the exception:
<VPS_IP>/32 via <physical gateway> dev <physical interface>
This is the route pin. Without it, the VPS address falls into the tunnel's own split default, and ssh tries to dial the VPS through the tunnel it is building. Nothing connects.
The pin is attached to the physical interface, so the kernel deletes it every time that interface goes away. Three things rebuild it:
ExecStartPre=ssh-tunnel.sh netupruns before every connection attempt.cmd_runre-pins once more immediately before it callsssh, in case the link moved in between.- The NetworkManager dispatcher hook calls
ssh-tunnel.sh repinon every interfaceupordhcp4-change.
netup and run both refuse to dial if the pin is not in effect, so the
service retries instead of connecting into itself.
ssh runs in the foreground and systemd owns it. When the session dies, ssh
exits, systemd restarts the unit, and ExecStartPre re-runs. The restart is
the retry.
This is why there is no autossh. An in-process retry loop never lets the unit restart, so the route pin would never be rebuilt.
StartLimitIntervalSec=0 disables the default rate limit. Otherwise systemd
gives up after 5 attempts and leaves the machine offline behind the kill switch
with nothing retrying.
A separate unit, ssh-tunnel-killswitch.service, runs before
network-pre.target and sets INPUT and OUTPUT policy to DROP.
Rules live in two private chains, KS_OUT and KS_IN, jumped to from the top
of OUTPUT and INPUT. Built-in chains are never flushed. FORWARD and the
nat table are never touched, so Docker's own rules stay intact.
Allowed over a physical interface:
- loopback
- established and related connections
- DHCP (so the machine can get a lease and reach the VPS at boot)
- the Docker bridge networks listed in
DOCKER_NETS - TCP to
VPS_HOSTonVPS_SSH_PORT— the only hole
Everything on tun0 is allowed. Port 53 leaving by any route other than tun0
is dropped explicitly, as a DNS leak backstop.
ssh-tunnel.service declares Requires=ssh-tunnel-killswitch.service, so the
tunnel cannot start unless the firewall is armed first.
The policy is restored to ACCEPT in exactly one place, reachable only through
ssh-tunnel.sh open, which asks for confirmation. Nothing on the automatic
path can widen the firewall. repin touches routing only and runs no iptables
command at all.
tun0 is created with ip tuntap add (IFF_PERSIST), so it outlives the ssh
process. Its address, MTU, routes, and DNS settings survive a reconnect; ssh
just re-attaches.
While no ssh process is attached, the routes still point at tun0. Traffic is
dropped there instead of falling back to the physical link. Failure is closed
by construction.
sshd on the VPS opens /dev/net/tun for the session carrying ssh -w. If the
client's uplink dies without a clean close, the VPS may not notice for hours.
While that dead session holds the device, every new tunnel request is refused:
channel 0: open failed: connect failed: open failed
Two defenses:
- Before every dial, the client opens one short control connection and ends any
sshd still holding
/dev/net/tun. That connection carries no tunnel itself, so it cannot kill its own session. Disable withREAP_ON_DIAL=0. ssh-tunnel.sh vps-fixsetsClientAliveInterval 15andClientAliveCountMax 3on the VPS, so it drops vanished clients in about 45 seconds by itself. Recommended — it stops the problem happening at all.
Make sure you can log in as root with a key, without a password prompt:
ssh root@<VPS_IP>
The service runs as root, so the key must be one root can read on the client.
git clone <this repo>
cd vpn-autossh
cp ssh-tunnel.env.example ssh-tunnel.env
$EDITOR ssh-tunnel.env
At minimum set VPS_HOST to the raw IP of your VPS. ssh-tunnel.env is
git-ignored, so your address is never committed.
Do this with local console access, not over SSH. If VPS_HOST is wrong, the
kill switch will cut you off.
sudo ./install.sh
The installer backs up every file it replaces, plus your current iptables rules
and routing table, to /root/ssh-tunnel-backup-<timestamp>/. It starts
nothing.
sudo systemctl restart NetworkManager # loads the dispatcher hook
sudo systemctl start ssh-tunnel.service
sudo ssh-tunnel.sh status
status prints the tunnel state, the route pin, the firewall policy, and your
egress IP. The egress IP should be the VPS.
sudo ssh-tunnel.sh vps-fix
This edits the VPS sshd config over SSH. It keeps a backup, validates with
sshd -t, rolls back if the test fails, and reloads rather than restarts sshd,
so the session you run it over is not dropped.
Only after everything works:
sudo systemctl enable ssh-tunnel-killswitch.service
sudo systemctl enable ssh-tunnel.service
All commands need root.
| Command | Does |
|---|---|
ssh-tunnel.sh status |
tunnel state, route pin, firewall policy, egress IP |
ssh-tunnel.sh start / stop / restart |
wrappers around systemctl |
ssh-tunnel.sh repin |
rebuild the VPS host route. Routing only, never the firewall |
ssh-tunnel.sh reap |
end a stale sshd on the VPS that is holding /dev/net/tun |
ssh-tunnel.sh vps-fix |
set PermitTunnel and ClientAlive* on the VPS. Asks first |
ssh-tunnel.sh netdown |
remove routes, DNS, and tun0. Kill switch stays armed |
ssh-tunnel.sh open |
remove the kill switch and restore normal networking. Asks first |
ssh-tunnel.sh arm |
apply the kill switch only. Used by the boot unit |
ssh-tunnel.sh netup |
prepare firewall, device, routes, DNS. Used by ExecStartPre |
ssh-tunnel.sh run |
dial the VPS in the foreground. Used by ExecStart |
Logs:
journalctl -u ssh-tunnel.service -f
stop leaves you fail-closed on purpose. To get normal networking back:
sudo ssh-tunnel.sh netdown # routes, DNS, device down; firewall stays
sudo ssh-tunnel.sh open # remove the kill switch too
open does not disable the boot unit, so the kill switch re-arms on the next
boot. Run sudo systemctl disable ssh-tunnel-killswitch.service if you want it
gone for good.
All settings live in /etc/default/ssh-tunnel, installed from your local
ssh-tunnel.env. Both the kill switch unit and the tunnel unit read that one
file, so the two can never disagree about which VPS to allow.
| Variable | Default | Meaning |
|---|---|---|
VPS_HOST |
— | Raw IPv4 of the VPS. Required. Not a hostname |
VPS_USER |
root |
SSH user on the VPS |
VPS_SSH_PORT |
22 |
SSH port. The only open port in the kill switch |
SSH_IDENTITY |
empty | Explicit key path. Empty means normal ssh key discovery |
TUN_DEV |
tun0 |
Tunnel device name |
LOCAL_TUN_IP |
10.77.0.2 |
Client end of the point-to-point link |
REMOTE_TUN_IP |
10.77.0.1 |
VPS end |
TUN_PREFIX |
30 |
Prefix length of the link |
TUN_SUBNET |
10.77.0.0/30 |
Subnet the VPS NATs |
TUN_MTU |
1350 |
Lower it if large transfers stall |
SSH_ALIVE_INTERVAL |
15 |
Keepalive probe interval, seconds |
SSH_ALIVE_COUNTMAX |
6 |
Missed probes before ssh gives up. 15 x 6 = 90 s |
TUN_DNS |
1.1.1.1 1.0.0.1 |
DNS servers used through the tunnel |
DOCKER_NETS |
172.17-20.0.0/16 |
Docker bridge networks the firewall allows |
DOCKER_NETS6 |
empty | IPv6 equivalent |
REMOTE_SETUP_NAT |
1 |
Let the client configure forwarding and NAT on the VPS |
REAP_ON_DIAL |
1 |
Clear a stale far-end session before each dial |
REMOTE_EXEC_CONNECT_TIMEOUT |
5 |
Connect budget for that control connection |
REMOTE_EXEC_TIMEOUT |
20 |
Total budget for it |
Raise SSH_ALIVE_COUNTMAX if a mobile link drops during handovers. Lower it
for faster failover on a clean link.
Start with sudo ssh-tunnel.sh status.
VPS route pin: BROKEN — the pin is gone and ssh would dial into its own
tunnel. Run sudo ssh-tunnel.sh repin. If it keeps breaking, the dispatcher
hook is not running; check systemctl restart NetworkManager and the file
permissions on /etc/NetworkManager/dispatcher.d/90-ssh-tunnel (must be 0755
root:root — NetworkManager refuses group- or world-writable scripts).
Everything looks healthy but no traffic passes — the far end is stale. Run
sudo ssh-tunnel.sh reap, then restart the service. Run vps-fix so it stops
happening.
channel 0: open failed — either PermitTunnel is not set on the VPS, or
a dead session still holds the device. vps-fix covers both.
ndisc: solicit: failure sending router solicitation — the kill switch is
blocking IPv6 router solicitation. That is correct; only IPv4 is tunnelled, so
IPv6 must not escape. To silence it, disable IPv6 on the connection instead of
opening a hole:
sudo nmcli connection modify "<connection name>" ipv6.method disabled
- Only SSH is needed. No VPN daemon on either side, no extra port, no new attack surface. Works where every port except 22 is blocked.
- Looks like SSH. To an observer the machine has a single long-lived SSH session. There is no VPN protocol fingerprint.
- Fail-closed by default. The firewall is armed before the network starts and stays armed if the tunnel dies. There is no window where traffic uses the plain link.
- Self-healing. The route pin is rebuilt on every reconnect and within a second of an interface returning. Recovery does not need a human.
- Docker included. Container traffic is tunnelled too, and Docker's own iptables rules are never flushed.
- Reversible. The installer backs up files, firewall, and routes.
rollback.shrestores them. - One config file. The firewall and the tunnel read the same file, so they cannot disagree about which host is allowed.
- TCP inside TCP.
ssh -wcarries IP packets over a TCP session. When the outer connection loses packets, the inner and outer TCP both retransmit and throughput collapses. This is the main weakness. WireGuard over UDP does not have it. - Bad on lossy or mobile links. A phone hotspot or tether is the worst case. The tunnel recovers quickly from a drop, but the transport is a poor fit while it is up.
- Lower throughput than a real VPN. Encryption plus the TCP-in-TCP overhead costs more than WireGuard or OpenVPN over UDP.
- IPv4 only. IPv6 is blocked, not tunnelled.
- Needs root on both ends. The client runs as root, and
ssh -wwithPermitTunnelneeds a privileged session on the VPS. Restricting that is a separate job. - Single point of failure. One VPS, one address, one session. There is no failover to a second server.
- Static IP required.
VPS_HOSTmust be a raw IP, because DNS is blocked before the tunnel exists. A VPS that changes address needs a config edit. - Locking you out is easy. A wrong
VPS_HOSTmeans no traffic at all. Install with local console access. - Linux and systemd only. The auto-repin also assumes NetworkManager.
| File | Installs to |
|---|---|
ssh-tunnel.sh |
/usr/local/sbin/ssh-tunnel.sh (0700 root) |
ssh-tunnel.env.example |
template — copy to ssh-tunnel.env and edit |
ssh-tunnel.env |
/etc/default/ssh-tunnel (0600 root) — local only, git-ignored |
ssh-tunnel.service |
/etc/systemd/system/ssh-tunnel.service |
ssh-tunnel-killswitch.service |
/etc/systemd/system/ — arms the firewall at boot |
99-unmanaged-tun.conf |
/etc/NetworkManager/conf.d/ — keeps NM off tun0 |
90-ssh-tunnel |
/etc/NetworkManager/dispatcher.d/ (0755 root) — auto-repin hook |
install.sh |
backs up, then installs |
rollback.sh |
restores a backup |
sudo BACKUP_DIR=/root/ssh-tunnel-backup-<timestamp> ./rollback.sh
This stops and removes the service, deletes tun0, restores the saved files
and iptables rules, and restarts NetworkManager. Routes are not restored
automatically. Compare them yourself:
diff <(ip route show) /root/ssh-tunnel-backup-<timestamp>/routes.txt