Connecting to an L2TP/IPsec VPN from Linux

I needed to connect a Linux client to an L2TP/IPsec VPN using shared secret (a.k.a. pre-shared key) authentication, but every online guide I could find was inaccurate and/or incomplete. Here’s a fully working solution at the time of writing.

The initial 10-minute setup is pretty tedious, but once you’ve got it working you can throw it all into a script and never worry about it again.

Walkthrough

Let’s say you want to connect to a VPN server with public IP address 68.68.32.79 and shared secret sh4r3ds3cr3t. Your user account on the VPN server is johndoe with password j0hn5p455w0rd.

  1. Install Openswan for IPsec and xl2tpd for L2TP:

    # Arch Linux
    pacaur -S openswan xl2tpd
    
    # Ubuntu
    apt-get update && apt-get install openswan xl2tpd
    
  2. Run ip route show default to determine your local IP and default network interface. They should be the 3rd and 5th items in the single line of output. Let’s say they’re 192.168.1.1 and eth0.

  3. As root, create these four files, plugging in the six variables we’ve gotten so far:

    /etc/ipsec.conf

    version 2.0
    
    config setup
      virtual_private=%v4:10.0.0.0/8,%v4:192.168.0.0/16,%v4:172.16.0.0/12
      nat_traversal=yes
      protostack=netkey
      plutoopts="--interface=eth0"
    
    conn L2TP-PSK
      authby=secret
      pfs=no
      auto=add
      keyingtries=3
      dpddelay=30
      dpdtimeout=120
      dpdaction=clear
      rekey=yes
      ikelifetime=8h
      keylife=1h
      type=transport
      left=%defaultroute
      leftnexthop=%defaultroute
      leftprotoport=17/1701
      right=68.68.32.79
    

    /etc/ipsec.secrets

    0.0.0.0 68.68.32.79: PSK "sh4r3ds3cr3t"
    

    /etc/xl2tpd/xl2tpd.conf

    [lac vpn-connection]
    lns = 68.68.32.79
    refuse chap = yes
    refuse pap = yes
    require authentication = yes
    name = vpn-server
    ppp debug = yes
    pppoptfile = /etc/ppp/options.l2tpd.client
    length bit = yes
    

    /etc/ppp/options.l2tpd.client

    ipcp-accept-local
    ipcp-accept-remote
    refuse-eap
    require-mschap-v2
    noccp
    noauth
    idle 1800
    mtu 1410
    mru 1410
    defaultroute
    usepeerdns
    debug
    connect-delay 5000
    name johndoe
    password j0hn5p455w0rd
    
  4. Start Openswan and xl2tpd. These three commands, along with all other commands in the remainder of this walkthrough, should be run as root:

    ipsec setup --start
    xl2tpd -D &
    ipsec auto --up L2TP-PSK
    
  5. Connect to the VPN:

    echo 'c vpn-connection' > /var/run/xl2tpd/l2tp-control
    
  6. Determine the name of the PPP network interface by running ip address and looking for the entry containing ppp. Let’s say it’s called ppp0. Now look inside its entry for the word “peer” and the local IP that immediately follows it. Let’s say the peer IP is 10.1.14.252. This is the VPN server’s local IP.

  7. Add a rule to route subnet traffic through the VPN. 10.0.0.0/8 is CIDR notation for the range of local IPs assigned inside your remote network. It doesn’t need to be exact. For example, you can use 10.0.0.0/8 to specify that all IPs of the form 10.*.*.* belong to the VPN even if you suspect that your remote network only assigns IPs of the form 10.1.10.* and 10.1.14.*.

    ip route add 10.0.0.0/8 via 10.1.14.252 dev ppp0
    

    You can test the new rule by pinging or SSHing the local IP of another machine inside the VPN. If that’s all you need, you can stop here.

  8. Remember the variables we found in step 2? Use them here to route all internet traffic (not just subnet traffic) through the VPN:

    ip route add 68.68.32.79 via 192.168.1.1 dev eth0
    ip route delete default via 192.168.1.1 dev eth0
    ip route add default via 10.1.14.252 dev ppp0
    

Done! You can now open your web browser and access VPN-only resources. To disconnect:

  1. Undo the routing rules:

    ip route delete default via 10.1.14.252 dev ppp0
    ip route add default via 192.168.1.1 dev eth0
    ip route delete 68.68.32.79 via 192.168.1.1 dev eth0
    
  2. Kill the VPN connection, xl2tpd, and Openswan:

    echo 'd vpn-connection' > /var/run/xl2tpd/l2tp-control
    ipsec auto --down L2TP-PSK
    killall xl2tpd
    ipsec setup --stop
    

Caveats

I don’t recommend switching from Ethernet to Wi-Fi or vice versa while connected to the VPN since that will change the default network interface (eth0 in our example) and possibly leave your routing table in a weird state until reboot.

These instructions assume that you regularly use only one L2TP/IPsec VPN. It’s straightforward to simply add a new section to the .conf files for each additional VPN, but the script below doesn’t support that use case out of the box.

I’ve only tested this on Arch Linux. Let me know if your distro needs any tweaks!

Script

Once you’ve successfully executed the steps above by hand, you can automate them all using a script like the one below. I normally run it as sudo ./vpn toggle.

#!/usr/bin/env bash
#
# Enables or disables a client connection to an L2TP/IPsec VPN. Usage: save
# this file as `vpn`, `chmod +x` it, and run `./vpn [up/down/toggle]` as root.
# Requires openswan and xl2tpd packages. Tested on Arch Linux.
#
# Based on:
#   https://bbs.archlinux.org/viewtopic.php?pid=1773313#p1773313
#   https://bbs.archlinux.org/viewtopic.php?pid=1781882#p1781882
#
# Example `ip route` output before connecting to VPN:
#   default via 192.168.1.1 dev wlp4s0 proto dhcp src 192.168.1.7 metric 303
#   10.1.14.252 dev ppp0 proto kernel scope link src 10.1.16.107
#   192.168.1.0/24 dev wlp4s0 proto dhcp scope link src 192.168.1.7 metric 303
#
# Example `ip route` output after connecting to VPN:
#   default via 10.1.14.252 dev ppp0
#   10.0.0.0/8 via 10.1.14.252 dev ppp0
#   10.1.14.252 dev ppp0 proto kernel scope link src 10.1.16.107
#   71.245.184.58 via 192.168.1.1 dev wlp4s0
#   192.168.1.0/24 dev wlp4s0 proto dhcp scope link src 192.168.1.7 metric 303

set -eu

# VPN settings (edit these!)
vpn_server_public_ip='68.68.32.79'
vpn_subnet='10.0.0.0/8'
vpn_pingee_local_ip='10.1.10.22'
vpn_shared_secret='sh4r3ds3cr3t'
vpn_username='johndoe'
vpn_password='j0hn5p455w0rd'

# Ensure that we're running as root
if [[ "$(id -u)" != 0 ]]; then
  echo 'Must run as root!'
  exit 1
fi

# Ensure that required packages are installed
if ! command -v ipsec > /dev/null; then
  echo '`ipsec` command not found! Please install Openswan.'
  exit 1
fi
if ! command -v xl2tpd > /dev/null; then
  echo '`xl2tpd` command not found! Please install xl2tpd.'
  exit 1
fi

# Handle subcommands
subcommand="${1-}"
if [[ "${subcommand}" = 'toggle' ]]; then
  if ip address | grep -q ': ppp'; then
    subcommand='down'
  else
    subcommand='up'
  fi
fi
case "${subcommand}" in
  # Connect to VPN
  up)
    # Ensure that we're not already connected
    if ip address | grep -q ': ppp'; then
      echo 'Already connected to VPN!'
      exit 1
    fi

    # Write config files
    default_network_device="$(ip route show default | cut -d' ' -f5)"
    cat > /etc/ipsec.conf << EOF
version 2.0

config setup
  virtual_private=%v4:10.0.0.0/8,%v4:192.168.0.0/16,%v4:172.16.0.0/12
  nat_traversal=yes
  protostack=netkey
  plutoopts="--interface=${default_network_device}"

conn L2TP-PSK
  authby=secret
  pfs=no
  auto=add
  keyingtries=3
  dpddelay=30
  dpdtimeout=120
  dpdaction=clear
  rekey=yes
  ikelifetime=8h
  keylife=1h
  type=transport
  left=%defaultroute
  leftnexthop=%defaultroute
  leftprotoport=17/1701
  right=${vpn_server_public_ip}
EOF
    cat > /etc/ipsec.secrets << EOF
0.0.0.0 ${vpn_server_public_ip}: PSK "${vpn_shared_secret}"
EOF
    cat > /etc/xl2tpd/xl2tpd.conf << EOF
[lac vpn-connection]
lns = ${vpn_server_public_ip}
refuse chap = yes
refuse pap = yes
require authentication = yes
name = vpn-server
ppp debug = yes
pppoptfile = /etc/ppp/options.l2tpd.client
length bit = yes
EOF
    cat > /etc/ppp/options.l2tpd.client << EOF
ipcp-accept-local
ipcp-accept-remote
refuse-eap
require-mschap-v2
noccp
noauth
idle 1800
mtu 1410
mru 1410
defaultroute
usepeerdns
debug
connect-delay 5000
name ${vpn_username}
password ${vpn_password}
EOF

    # Start Openswan
    ipsec setup --start
    ipsec setup --status | grep -q 'IPsec running'

    # Start xl2tpd
    xl2tpd -D &
    sleep 1
    ipsec auto --up L2TP-PSK
    ipsec setup --status | grep -q '1 tunnels up'

    # Connect to VPN
    echo 'c vpn-connection' > /var/run/xl2tpd/l2tp-control
    while true; do
      sleep 1
      ppp_device="$(ip address | grep -oE '^[0-9]+: ppp\w+' | cut -d' ' -f2)"
      if [[ -n "${ppp_device}" ]]; then
        break
      fi
      echo 'Waiting for ppp device...'
    done
    peer_ip="$(ip address show "${ppp_device}" | grep -oE 'peer [0-9.]+' | cut -d' ' -f2)"
    ip route add "${vpn_subnet}" via "${peer_ip}" dev "${ppp_device}"
    [[ -z "${vpn_pingee_local_ip}" ]] || ping -c 1 "${vpn_pingee_local_ip}"

    # Route all internet traffic through VPN
    local_ip="$(ip route show default | cut -d' ' -f3)"
    ip route add "${vpn_server_public_ip}" via "${local_ip}" dev "${default_network_device}"
    ip route delete default via "${local_ip}" dev "${default_network_device}"
    ip route add default via "${peer_ip}" dev "${ppp_device}"
    ;;

  # Disconnect from VPN
  down)
    # Ensure that we're already connected
    if ! ip address | grep -q ': ppp'; then
      echo 'Not connected to VPN!'
      exit 1
    fi

    # Undo routing rules
    default_network_device="$(ip route show "${vpn_server_public_ip}" | cut -d' ' -f5)"
    ppp_device="$(ip address | grep -oE '^[0-9]+: ppp\w+' | cut -d' ' -f2)"
    peer_ip="$(ip address show "${ppp_device}" | grep -oE 'peer [0-9.]+' | cut -d' ' -f2)"
    local_ip="$(ip route show "${vpn_server_public_ip}" | cut -d' ' -f3)"
    ip route delete default via "${peer_ip}" dev "${ppp_device}"
    ip route add default via "${local_ip}" dev "${default_network_device}"
    ip route delete "${vpn_server_public_ip}" via "${local_ip}" dev "${default_network_device}"

    # Kill VPN connection
    echo 'd vpn-connection' > /var/run/xl2tpd/l2tp-control
    sleep 1

    # Kill xl2tpd
    ipsec auto --down L2TP-PSK
    killall xl2tpd
    sleep 1

    # Kill Openswan
    ipsec setup --stop
    ;;

  *)
    echo 'Invalid subcommand!'
    exit 1
    ;;
esac

echo 'Success!'