The lazy way to configure OpenVPN Server on a Ubiquiti EdgeRouter Lite

I have a Ubiquiti EdgeRouter Lite that I use as a staging platform for systems in production. Because I have had to reconfigure the VPN so many times on this device, I created a simple Python tool to run through the entire process for me. Instructions for installing/using the script are detailed below; if you would like to read a tutorial on how to do everything manually, check out Configure OpenVPN with X.509 – Ubiquiti EdgeRouter Lite

As noted by @dazo in the comments, this setup is meant for a lab environment. Hosting your CA on the VPN server is considered bad practice, and the keys generated by the EdgeRouter will be cryptographically sub-par to that of a standard machine

Installation

  1. Add Debian Repos
  2. Install Python’s package manager
    sudo apt-get install python-setuptools
  3. Now we install Pexpect
    sudo easy_install pexpect
  4. Download this script into any directory on the router. Mine is located at /config/auth/erl_vpn_configure.py.
  5. Open the script in your favorite text editor. Edit between the # BEGIN EDITING and # END EDITING blocks in order to suit your needs.

Usage

  • To run a complete OpenVPN server setup, including CA generation, simply run the script with python /path/to/script

OR

  1. Enter the python interpreter by typing python into your shell prompt.
  2. Create a new object to manipulate your server
    ca = ca_obj()

Use the below table as a function reference. Use a function with the following syntax ca.function(parameter1, parameter2, ..)

Function Name Description Parameters
complete_setup Perform complete OpenVPN server setup
setup_erl_server Perform Vyatta/ERL OpenVPN configuration
setup_client_certs Generate client certificates, uses clients defined as CLIENTS variable in config
gen_ca Generate CA Cert str filename Filename of new cacert
new_req New cert signing request dic req_opts Request options
sign_req Sign cert request
bool    strip_pass  Strip PEM pass?
str     passwd      PEM Password
gen_dhp Generate Diffie-Helman Parameters
strip_pem_pass Strip PEM pass from key file
str passwd      PEM passphrase
str file_out

Samples

  1. Completely setup CA Cert and OpenVPN server
    ca = ca_obj
    ca.complete_setup()

More Coming Soon

The Script

#!/usr/bin/env python
# -*- coding: utf-8 -*-
##
#    VPN Configure-O-Matic
#         
#    Quickly sets up a VPN server, complete with client/cert config.
#       Specifically created for EdgeRouter Lite;
#       Probably works on any Linux distro..
#       Client configuration can be performed via ssh
#    
#    @author     David Lasley <dave@dlasley.net>
#    @package    toolbox


import pexpect
import subprocess
import os
from threading import Thread
from itertools import chain

'''
    BEGIN EDITING
'''

CA_PASS = 'CA_PASS' #< CA Cert pass
HOST_PASS = 'HOST_PASS' #< Host key pass
CLIENT_PASS = 'CLIENT_PASS' #< Client key pass

''' Where the CA scripts are located on the server..
    probably won't need to change this  '''
SSL_DIR = '/usr/lib/ssl/misc/'

''' Where to save all of the certs/keys on the server..
    probably won't need to change this  '''
SAVE_DIR = '/config/auth/'

''' Default cert configuration options.
    These can be overridden in the individual configs.  '''
DEFAULT_CERT = {
    'country':'US',
    'state':'Nevada',
    'city':'Las Vegas',
    'company':'LasLabs',
    'ou':'',
    'email':'dave@domain.com',
    'pass':'',
    'opt_company':'',
}

#   CA cert configuration overrides, always set 'cn'
CA_CERT = { 
    'cn':'router.domain.com',
}

#   Host cert configuration overrides, always set 'cn'
HOST_CERT = {
    'cn':'vpn.domain.com',
}

''' Client SSH users. {} to skip client configuration.
    Set key-file to file path of private key file if applicable '''
CLIENT_CREDS = {'uname':'username', 'passwd':'password', 'key-file':None} 

#   Host cert configuration overrides, always set 'cn'
CLIENTS = [
    {'cn':'atl-vps-01.domain.com', 'city':'Atlanta', 'state':'Georgia',
        'remote-creds':CLIENT_CREDS},
    {'cn':'los-vps-01.domain.com', 'city':'Los Angeles', 'state':'California',
        'remote-creds':CLIENT_CREDS},
    {'cn':'chi-vps-01.domain.com', 'city':'Chicago', 'state':'Illinois',
        'remote-creds':CLIENT_CREDS},
    {'cn':'dlasley.vpn.domain.com',
        'remote-creds':CLIENT_CREDS}
]

# Diffie-helman filename, probably won't need to change
DHP_PATH = os.path.join(SAVE_DIR, 'dhp.pem') 
DHP_BITS = 1024 #< Diffie-Helman Param Bits, probably won't need to change

TUNNEL_SETTINGS = { #< VPN Tunnel settings
    'vtun0':{
        'mode':'server',
        'server subnet':'192.168.68.0/24',
        #'local-port':'1195',
        #   Routes to LAN/WLAN, pushed to client
        'server push-route':['192.168.69.0/24',],
        #   Clients with static IPs
        'server client':['atl-vps-01.domain.com ip 192.168.68.100',
                            'chi-vps-01.domain.com ip 192.168.68.101',
                            'los-vps-01.domain.com ip 192.168.68.102', ],
        #   TLS Keys/Certs, probably won't need to change
        'tls ca-cert-file':os.path.join(SAVE_DIR, 'demoCA', 'cacert.pem'),
        'tls cert-file':'%s.pem' % os.path.join(SAVE_DIR, HOST_CERT['cn']),
        'tls key-file':'%s.key' % os.path.join(SAVE_DIR, HOST_CERT['cn']),
        'tls dh-file':DHP_PATH,
    }
}

#
#   END EDITING
#


CERT_MAP = [
    ['Country Name .*:','country'],
    ['State or Province Name .*:','state'],
    ['Locality Name .*:','city'],
    ['Organization Name .*:','company'],
    ['Organizational Unit Name .*:','ou'],
    ['Common Name .*:','cn'],
    ['Email Address .*:','email'],
    ['A challenge password .*:','pass'],
    ['An optional company name .*','opt_company'],
]


class erl_obj(object):
    VYATTA_SHELL_API = '/bin/cli-shell-api'
    VYATTA_SBIN = '/opt/vyatta/sbin/'
    def __init__(self, strip_host_pass=True, strip_client_passes=True, ):
        '''  Init '''
        self.ca_sh = os.path.join(SSL_DIR, 'CA.sh')
        self.strip_host_pass = strip_host_pass
        self.strip_client_passes = strip_client_passes
        self.log_file = open('/var/log/erl_vpn.log', 'w')
        if not os.path.isdir(SAVE_DIR):
            os.mkdir(SAVE_DIR)
        os.chdir(SAVE_DIR)
    
    def complete_setup(self, ):
        ''' Perform complete OpenVPN server setup   '''
        democa = os.path.join(SAVE_DIR, 'demoCA')
        if os.path.isdir(democa):
            os.rmdir(democa)
        #   Diffie-Helman Thread
        dhp_thread = Thread(target=self.gen_dhp)
        dhp_thread.daemon = False
        dhp_thread.start()
        #   Create CA
        self.gen_ca()
        #   Host cert/key
        req_opts = dict(chain(DEFAULT_CERT.items(), HOST_CERT.items()))
        self.new_req(HOST_PASS, req_opts)
        self.sign_req(HOST_CERT['cn'], self.strip_host_pass, HOST_PASS)
        #   Client certs/keys
        self.setup_client_certs()
        #   Wait for Diffie-Helman
        dhp_thread.join()
        #   Configure the server
        self.setup_erl_server()
        
    def setup_erl_server(self, ):
        ''' Perform Vyatta/ERL server configuration    '''
        SET = os.path.join(self.VYATTA_SBIN, 'my_set')
        COMMIT = os.path.join(self.VYATTA_SBIN, 'my_commit')
        SAVE = os.path.join(self.VYATTA_SBIN, 'vyatta-save-config.pl')
        
        commands = [
            'session_env=$(%s getSessionEnv $PPID)' % self.VYATTA_SHELL_API,
            'eval $session_env',
            '%s setupSession' % self.VYATTA_SHELL_API,
        ]
        
        for tun_name, tun_settings in TUNNEL_SETTINGS.iteritems():
            tun_node = 'interfaces openvpn %s' % tun_name
            for setting_name, setting in tun_settings.iteritems():
                if type(setting) == list: #< Allow iteration
                    for _setting in setting:
                        commands.append('%s %s %s %s' % (
                            SET, tun_node, setting_name, _setting))
                else:
                    commands.append('%s %s %s %s' % (
                        SET, tun_node, setting_name, setting))
                
        commands.append(COMMIT)
        commands.append(SAVE)
        
        self.log_file.write( ' && '.join(commands) + '\r\n' ) 
        
        subprocess.call([' && '.join(commands)], shell=True)
        return
    
    def setup_erl_client(self, config):
        '''
            Perform Vyatta/ERL remote client config
            @param  dict    config  Client configuration
        '''
        try:
            if config['remote-creds']['uname']:
                if config['remote-creds'].get('key-file'): #< Key auth
                    cmd = 'ssh -i "%s" %s@%s' % (
                        config['remote-creds']['key-file'],
                        config['remote-creds']['uname'],
                        config.get('ip') or config['cn'] )
                elif config['remote-creds'].get('passwd'): #< Password
                    cmd = 'ssh -i %s:%s@%s' % (
                        config['remote-creds']['uname'],
                        config['remote-creds']['passwd'],
                        config.get('ip') or config['cn'] )
                
                console = pexpect.spawn
                
        except KeyError: #< No remote creds
            return False
        
    def setup_client_certs(self, ):
        ''' Generate client certificates    '''
        for client in CLIENTS:
            req_opts = dict(chain(DEFAULT_CERT.items(), client.items()))
            self.new_req(CLIENT_PASS, req_opts)
            self.sign_req(client['cn'], self.strip_client_passes, CLIENT_PASS)
        return
    
    def gen_ca(self, filename='', ):
        ''' Generate CA Cert
            @param  str filename    Filename of new cacert
        '''
        ca_opts = dict(chain(DEFAULT_CERT.items(), CA_CERT.items()))
        console = pexpect.spawn('%s -newca' % self.ca_sh, logfile=self.log_file)
        console.expect([pexpect.EOF, 'CA certificate filename .*'])
        console.sendline(filename)
        self._generic_request(console, CA_PASS, ca_opts)
        console.expect([pexpect.EOF, 'Enter pass phrase for .*:'])
        console.sendline(CA_PASS)
        console.expect([pexpect.EOF, 'Data Base Updated'])
        return
    
    def new_req(self, pem_pass, req_opts):
        ''' New cert signing request
            @param  str pem_pass    PEM Pass for cert
            @param  dic req_opts    Request options
        '''
        console = pexpect.spawn('%s -newreq' % self.ca_sh, logfile=self.log_file)
        self._generic_request(console, pem_pass, req_opts, True)
        return
        
    def sign_req(self, cn, strip_pass=True, passwd='12345'):
        ''' Sign cert request
            @param  str     cn          CN of the certificate, will be the new filenames
            @param  bool    strip_pass  Strip PEM pass?
            @param  str     passwd      PEM Password
        '''
        console = pexpect.spawn('%s -sign' % self.ca_sh, logfile=self.log_file)
        console.expect([pexpect.EOF, 'Enter pass phrase for .*:'])
        console.sendline(CA_PASS)
        console.expect([pexpect.EOF, 'Sign the certificate\? .*:'])
        console.sendline('y')
        console.expect([pexpect.EOF, '1 out of 1 certificate requests certified, .*'])
        console.sendline('y')
        console.expect([pexpect.EOF, 'Signed certificate is .*'])
        
        os.rename(os.path.join(SAVE_DIR, 'newcert.pem'),
                  os.path.join(SAVE_DIR, '%s.pem' % cn))
        
        key_file = os.path.join(SAVE_DIR, '%s.key' % cn)
        os.rename(os.path.join(SAVE_DIR, 'newkey.pem'), key_file)
        
        if strip_pass:
            self.strip_pem_pass(key_file, passwd)
        return
    
    def gen_dhp(self, ):
        ''' Generate Diffie-Helman Parameters... This should be threaded    '''
        subprocess.check_call(['openssl', 'dhparam', '-out', DHP_PATH, '-2', str(DHP_BITS)])
        return
    
    def strip_pem_pass(self, file_in, passwd, file_out=None):
        ''' Strip PEM pass from key file
            @param  str file_in
            @param  str passwd      PEM passphrase
            @param  str file_out
        '''
        if not file_out:
            file_out = file_in
        console = pexpect.spawn('openssl rsa -in %s -out %s' % (file_in, file_out), logfile=self.log_file)
        console.expect([pexpect.EOF, 'Enter pass phrase for .*:'])
        console.sendline(passwd)
        console.expect([pexpect.EOF, 'writing RSA key'])
        return

    def _generic_request(self, console, pem_pass, req_opts, wait_for_it=False):
        ''' Generic cert request
            @param  pexpect console     Pexpect console to manipulate
            @param  str     pem_pass    PEM Pass for cert
            @param  dic     req_opts    Request options
        '''
        console.expect([pexpect.EOF, 'Enter PEM pass phrase:'])
        console.sendline(pem_pass)
        console.expect([pexpect.EOF, 'Verifying - Enter PEM pass phrase:'])
        console.sendline(pem_pass)
        for map_part in CERT_MAP:
            console.expect([pexpect.EOF, map_part[0]])
            console.sendline(req_opts[map_part[1]])
        if wait_for_it:
            console.expect([pexpect.EOF, 'Request is in .* newkey\.pem'])
        return
    
if __name__ == '__main__':
    erl_obj = erl_obj()
    erl_obj.complete_setup()
0