Skip to content

About

Full-system VPN over a single SSH connection, supervised by systemd.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

ssh-tunnel

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.

What it is not

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.

Requirements

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-point in its sshd config — ssh-tunnel.sh vps-fix sets 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.

How it works

The tunnel

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.

Routing

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:

  1. ExecStartPre=ssh-tunnel.sh netup runs before every connection attempt.
  2. cmd_run re-pins once more immediately before it calls ssh, in case the link moved in between.
  3. The NetworkManager dispatcher hook calls ssh-tunnel.sh repin on every interface up or dhcp4-change.

netup and run both refuse to dial if the pin is not in effect, so the service retries instead of connecting into itself.

Supervision

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.

The kill switch

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_HOST on VPS_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.

Persistent tun device

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.

Stale sessions on the VPS

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 with REAP_ON_DIAL=0.
  • ssh-tunnel.sh vps-fix sets ClientAliveInterval 15 and ClientAliveCountMax 3 on the VPS, so it drops vanished clients in about 45 seconds by itself. Recommended — it stops the problem happening at all.

Setup

1. Prepare the VPS

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.

2. Configure

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.

3. Install

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.

4. Start and check

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.

5. Harden 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.

6. Enable at boot

Only after everything works:

sudo systemctl enable ssh-tunnel-killswitch.service
sudo systemctl enable ssh-tunnel.service

Commands

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.

Configuration

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.

Troubleshooting

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

Pros and cons

Pros

  • 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.sh restores them.
  • One config file. The firewall and the tunnel read the same file, so they cannot disagree about which host is allowed.

Cons

  • TCP inside TCP. ssh -w carries 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 -w with PermitTunnel needs 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_HOST must 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_HOST means no traffic at all. Install with local console access.
  • Linux and systemd only. The auto-repin also assumes NetworkManager.

Files

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

Uninstall

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

About

Full-system VPN over a single SSH connection, supervised by systemd.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages