User Tools

Site Tools


VPN over SSH

  • Server: GNU/Linux (Ubuntu 12.04) –
  • Client: MacOS/BSD (El Capitan, v10.11.5) –

Create and configure a tunnelled connection between client and server, via tun0 interfaces:

  1. Go install on the Mac, it is needed by ssh
  2. SSH into the server and edit /etc/ssh/sshd_config to include
    PermitRootLogin yes
    PermitTunnel yes
  3. Restart SSHd on the server (service ssh reload does not appear to be sufficient)
    sudo service ssh stop; sudo service ssh start
  4. Log out of server (need to reconnect to make use of config changes)
  5. As root on the client machine, SSH into the root account on the server with tun devices enabled via -w option
    ssh -w 0:0

    (0:0 specifies that both local and remote ends will create tun0 interfaces)

  6. Within the resulting root shell on the server, configure the new tun0 network interface:
    ifconfig tun0 inet dstaddr
    ifconfig tun0
    ping  # Check tun0 has an IP address
    ping  # Should fail, as we've not yet configured the client's tun0
  7. In a root shell on the client, configure the new tun0 network interface:
    ifconfig tun0 inet
    ifconfig tun0
    ping  # Check tun0 has an IP address
    ping  # Check we can communicate with remote end (server) via tun0
  8. Back in the root shell on the server, repeat ping and this time it should respond
  9. The tunnel is now configured. It will remain so until the SSH session is closed.

Configure IPv4 (ICMP+TCP+UDP) forwarding and Network Address Translation (NAT):

  1. In the root shell on the server (only needs to be done one per boot):
    # Prepare networking stack for use by forced commands in
    # /root/.ssh/authorized_keys that creates a point-to-point network (via tun0)
    # between (this host) and (remote end).
    # We then want to enabling forwarding of IPv4 traffic, i.e. we want to act as a
    # router. We enable this in the kernel, and then ensure traffic originating
    # from the remote side of the point-to-point link is accepted, and any
    # responses are likewise accepted
    echo 1 > /proc/sys/net/ipv4/ip_forward
    /sbin/iptables -F
    /sbin/iptables -A FORWARD ! --source --destination \
            -m state --state RELATED,ESTABLISHED -j ACCEPT
    /sbin/iptables -A FORWARD --source ! --destination -j ACCEPT
    # Any traffic originating from the remote side should go through Network
    # Address Translation (NAT), so responses from (e.g.) DNS servers are sent to
    # this host, so *we* can forward it to the remote end. This is the MASQUERADE
    # rule.
    /sbin/iptables -t nat -F
    /sbin/iptables -t nat -A POSTROUTING ! --destination  -j MASQUERADE
    # Monitor packets
    watch -n0.5 -d ifconfig tun0
  2. As root on the client:
    route add -interface tun0

    (you can undo this by repeating the command with delete in place of add)

  3. IPv4 forwarding via tun0 is in effect.

Note: The changes made to the server persist after the SSH session has ended.

To get name resolution working, you need to configure the client to use a DNS server at the remote end, e.g.

  1. Discover the DNS nameservers used by the server:
    cat /etc/resolv.conf
  2. Add these to the client system:
    networksetup -setdnsservers Wi-Fi

    This step must be manually undone (e.g. after closing the SSH session) by running

     networksetup -setdnsservers Wi-Fi Empty

You may also want to add your remote system's DNS search domains, e.g.:

networksetup -setsearchdomains Wi-Fi local

Again, this must be manually undone after you close the VPN connection:

sudo networksetup -setsearchdomains Wi-Fi Empty

Most useful guides:

More thorough networking (Ethernet layer, instead of link layer):

Automating via SSH configuration files

All commands here are run as root on the client system
  1. As root on your client system, generate a new SSH keypair to use for VPN.
    ssh-keygen -f ~/.ssh/id_rsa_vpn -N ''
  2. Install new public key into remote system, and prefix with a ForeCommand which is run whenever this key is used to authenticate:
    ( \
      printf 'tunnel="0",command="ifconfig tun0 inet dstaddr" ' ; \
      cat ~/.ssh/ \
    ) | ssh tee -a .ssh/authorized_keys
  3. Configure client via ~/.ssh/config. Add the following to the end of .ssh/config (create it if it does not exist) and replace $SERVER with your server's hostname:
    Host vpn
      Hostname $SERVER
      User root
      # Remote's .ssh/authorised_keys entry for this identity is prefixed with:
      # tunnel="0",command="ifconfig tun0 inet dstaddr" ssh-rsa
      IdentityFile ~root/.ssh/id_rsa_vpn
      Tunnel yes
      TunnelDevice 0:0
      PermitLocalCommand yes
      LocalCommand ~root/.ssh/ %h %T
      # Disable connection sharing, otherwise closing VPN may not actually reset
      # network settings because (cf. LocalCommand) continues to wait
      # for the `ssh` process to exit (which it may not if another session is
      # active)
      ControlPath none
      # Disable use of ssh-agent, as it seems to prevent our preferred identity
      # (cf. IdentityFile) being applied, which in turn means we don't trigger the
      # ForceCommand of the remote's authorized_keys file
      IdentityAgent none
  4. Create a new script on your client machine at ~root/.ssh/ which configures your Mac to route traffic headed to your server via the current gateway, and then change the default gateway (that applies to all other traffic) to go via the new SSH tun device at, then wait for the ssh process to exit before returning settings to normal:
    # .ssh/config: LocalCommand %h %T
    ifconfig $TUNNEL_DEVICE inet
    ROUTE=$(route get $REMOTE_HOST)
    GATEWAY=$(sed -ne 's/^ *gateway: //p' <<<"$ROUTE")
    INTERFACE=$(sed -ne 's/^ *interface: //p' <<<"$ROUTE")
    route add $REMOTE_HOST $GATEWAY
    route add 10/8 $GATEWAY
    route change default
    while kill -0 $WAIT_PID >/dev/null 2>&1; do sleep 0.5; done
    # The route gets deleted when the SSH tunnel closes gracefully and tun0 disappears
    route change default $GATEWAY
    route add default $GATEWAY
    route delete 10/8 $GATEWAY
    route delete $REMOTE_HOST $GATEWAY
    ) &

    This script is unlikely to work on other OS

  5. Make the new script executable:
    chmod a+x ~root/.ssh/
  6. Test it by running
    ssh vpn

Sample session showing the output from the commands above:

# ssh-keygen -f ~/.ssh/id_rsa_vpn -N ''
Generating public/private rsa key pair.
Your identification has been saved in /var/root/.ssh/id_rsa_vpn.
Your public key has been saved in /var/root/.ssh/
The key fingerprint is:
SHA256:4c8jh23lnMr7ZEmiDCCenKEEo6ROBDIku3XCmKLqqcw root@roberts-mbp
The key's randomart image is:
+---[RSA 2048]----+
|X+               |
|OB               |
|Bo* o   .        |
|** B . . .       |
|+.=   . S . o    |
|.      o * * o   |
|.       = B B    |
|+ .      = =     |
|oE        +o.    |

# ( \
#   printf 'tunnel="0",command="ifconfig tun0 inet dstaddr" ' ; \
#   cat ~/.ssh/ \
# ) | ssh tee -a .ssh/authorized_keys
tunnel="0",command="ifconfig tun0 inet dstaddr" ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC+pPee+HqiExk28lwKGcjoAMnkWRVKoQsn8b+90ST3HteZq1oCKtig49YOtlXDZGma0vR/y9Xbelk26xJfZO32BR3GCPou6XYSU67qwC8wK256H0LfTUlquUufklmKd3BaKamAtXU0JwhVxQCFH0hToG6dgc0FLelqs1r8u6cPni1wTxaId6epHrYCBrKvP+fwYz0S0K3e2opcqZUTwMyPYwu280UxQr2HYvzykdoJeiJtsKgneFRxhX7gnlKCYoia0fToKHel24GfUFfqipFrJbsm8LDYuVh5KVgx1J1Hx19Fu0LM3IIqoXQESob91TjTx1bq41iIMZ0n0td5gDVj root@roberts-mbp

# ssh vpn
add host gateway
add net 10: gateway
change net default: gateway

# Nothing further appears to happen. VPN is up and running! Try `traceroute
#` in another terminal to verify that the traffic is going via your
# server and not its default route.

# When all done, press ^C to kill the VPN and restore default settings. Your
# prompt will return first, and *then* the clean-up code will execute and
# print:
route: writing to routing socket: not in table
change net default: gateway not in table
add net default: gateway
delete net 10: gateway
delete host gateway

systemd service

Configure a remote host, which lives behind a firewall, to maintain an SSH connection to my home network that provides a reverse tunnel back into `sshd` on the remote host.


Description=SSH-based VPN

# Disable rate-limiting, which may result in "giving up"

ExecStartPre=/bin/sh -c "echo 1 > /proc/sys/net/ipv4/ip_forward"
ExecStartPre=/sbin/iptables -A FORWARD ! --source --destination \
        -m state --state RELATED,ESTABLISHED -j ACCEPT
ExecStartPre=/sbin/iptables -A FORWARD --source ! --destination -j ACCEPT
ExecStartPre=/sbin/iptables -t nat -A POSTROUTING ! --destination  -j MASQUERADE

ExecStart=/usr/bin/ssh -R10022:localhost:22 -N -oServerAliveInterval=15 -oExitOnForwardFailure=yes


KCP Tunnelling via Fast Reverse Proxy (frp)

Fast Reverse Proxy (frp) is a pair of standalone executables which can be used to expose services behind NAT. I like it because:

  • It supports KCP protocol.
    • KCP reduces latency on lossy links by implementing error correction instead of just the error detection of TCP. This is achieved by using more bandwidth to send erasure codes which can be used to recover lost packets (instead of requesting they be retransmitted which add round-trip delays). Similar to how CD-ROMs cope with scratches.
    • This is especially useful on cellular / mobile networks (3G, 4G, etc)
  • It can NAT-bust, even when both parties are behind NAT.
    • This does require a public, non-NAT, server both parties can initiate contact with

Download and unpack Fast Reverse Proxy (frp) which contains two executables: frpc and frps.

I drive them with this bash script, which I call frp:

# FRP: Fast Reverse Proxy (cf.
# XTCP: Creates a direct connection between hosts which are behind NAT gateways
# by getting both to contact a public server they can both access, and then
# (ab)using a UDP connection directly to one another. Because UDP is stateless,
# both sides can send a packet to the other and thereby get their NAT gateway
# to set up a session (temporary port forward) for any return traffic. Voila,
# you now have holes in both NAT gateways and the clients (frpc) can talk to
# each other directly.


case $1 in

            ./frpc xtcp 

            # Public server details and auth token

            # Proxy entry to publish for other hosts (also behind NAT) to
            # access, thus making us act as a server.
            #    Service to connect incoming tunnelled connections to

            ./frpc xtcp 

            # Public server details and auth token

            # Proxy entry (published by another client) within the server we want to
            # use, and server secret key
            #   As a visitor, we are trying to access something published by another
            #   client (also behind NAT) with --role=server. "bind" parameters dictate
            #   where to put the listening end of our P2P tunnel: apps on our network
            #   will connect to this to be tunnelled through FRP and it's NAT hole.
            #   Also note that KCP protocol runs over UDP, but very few applications
            #   support that directly, so the both ends of the KCP tunnel convert
            #   to/from TCP.

        echo "Please provide exactly one argument: public-server, ssh-server, or ssh-client"
        exit 1


cd $(dirname $0)
exec "${CMD[@]}"

and if you like, here's a systemd file for it, frp.service:

Description=Fast Reliable Proxy Server

ExecStart=/opt/frp/frp public-server


To install it:

  sudo ln -s $(readlink -f frp.service) /etc/systemd/system/
  sudo systemctl daemon-reload  # To notice the new symbolic link
  sudo systemctl enable frp.service  # So it starts automatically on boot
  sudo systemctl start frp.service  # .. one off start it now
vpn.txt · Last modified: 2021/11/01 14:08 by robm