Greg's Blog
  • All posts

Openvpn Manager - Sun, Jul 10, 2022

An open source web app to add users and monitor an OpenVPN instance

What to do

OpenVPN is an open-source VPN software commonly used in universities, workplaces or even homelabs. I always liked using it and it actually taught me a lot regarding certificates (it used to be shipped with easy-rsa), networking, routing etc… At the time I started to need to use it at scale for my lab I couldn’t find any tool with a web interface. I seems everybody just loved to make their own script to add new users and well… so did I.

Still, I wanted something web based so I could eventually delegate the task of adding new users to personnel unfamiliar with a command line. Additionally, I wanted a small project to start learning vue and “modern” web and thought it was the perfect project to get started.

The project’s outline is easy enough, have an OpenVPN instance, build an API around that, make a front end connecting to that API:

┌ │ │ │ └ ─ ─ ─ ─ ─ ─ ─ O R ─ ─ p e ─ ─ e s ─ ─ n o ─ ─ V u ─ ─ P r ─ ─ N c ─ ─ e ─ ─ s ─ ─ ─ ─ ─ ┐ │ │ ├ ┘ ◄ ─ ─ ─ ─ ─ ─ ► ┌ ┤ │ │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ A ─ ─ P ─ ─ I ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ├ │ │ ┘ ─ ◄ ─ ─ ─ ─ ► ─ ┌ │ │ ┤ └ ─ F ─ ─ r ─ ─ o ─ ─ n ─ ─ t ─ ─ - ─ ─ e ─ ─ n ─ ─ d ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ │ │ ┘

The project is available here

Building the API

Having an API is standard for this sort of project and really is the best way to have a hassle-free front-end implementation. Vue projects connecting to a REST API of some sort are legion and its best for a first project.

Interacting with OpenVPN

First off we need to interact with OpenVPN, that means getting data from the server and pushing data such as users to it.

Pulling

Thankfully, OpenVPN does have an easy way of providing data. The server config file allows for a status directive to provide an automatically updated file with server status and connected client information information.

status openvpn-status.log

The status file found at /etc/openvpn/openpvpn-status.log has the format:

OpenVPN CLIENT LIST
Updated,Fri May 20 11:01:20 2022
Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
<REDACTED_NAME>,<REDACTED_IP>:9363,1682579,1602370,Mon May 16 14:17:36 2022
ROUTING TABLE
Virtual Address,Common Name,Real Address,Last Ref
10.8.0.10,<REDACTED_NAME>,<REDACTED_IP>:53516,Fri May 20 11:01:19 2022
GLOBAL STATS
Max bcast/mcast queue length,59
END

So we can parse that easily and update the status of the VPN accordingly. The file is updated automatically by openvpn every 30 seconds approximately. The parsing is done simply with in the api:

def get_status():
    with open(OPENVPN_STATUS_FILE, 'r') as fh:
        fh.readline() # Skip "OpenVPN CLIENT LIST"
        last_updated = fh.readline().strip().split(',')[1] # Updated, date
        fh.readline() #Skip header Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
        clients = {}
        addr = []
        # Clients
        while True:
            cmp = fh.readline().strip().split(',')
            if len(cmp) < 5:
                break
            clients[cmp[1]] = {
                'name': cmp[0], 'addr': cmp[1], 
                'bytes_received': cmp[2], 'bytes_sent': cmp[3], 
                'connected_since': cmp[4]
            }
        # Addresses
        fh.readline() #Skip Addr header
        while True:
            cmp = fh.readline().strip().split(',')
            if len(cmp) < 4:
                break
            if not cmp[2] in clients:
                clients[cmp[2]] = {
                    'name': 'Unknown', 'addr': 'Unknown', 
                    'bytes_received': 0, 'bytes_sent': 0, 
                    'connected_since': "Unknown"
                }
            if not 'virtual' in clients[cmp[2]]:
                    clients[cmp[2]]['virtual'] = []
                    clients[cmp[2]]['name']    = cmp[1]
            clients[cmp[2]]['virtual'].append(cmp[0])
    return jsonify({'last_updated': last_updated, 'clients': list(clients.values())})

Adding users

The other implemented feature is the ability to add new users to the vpn from the interface. To do this, there are two different ways: call the easy-rsa executable to create the certificate or create them directly in the API with another library. We went with the second option.

The function below creates a user certificate, signs it and put all the required files in a dedicated folder under CLIENT_CONFIG_DIR. This will be used later to generate the .ovpn file for the clients. It then appends the client to the openvpn database to allow connection.

def create_client(C, ST, L, O, OU, CN, name, emailAddress, keySize=2048, maxUsage=FIFTY_YEARS):
    log.fine(f'Creating client {CN}')
    # 0. Check args and client existence
    DISSALOW_CN = [' ', '&', '.', '/', '!', ':', '$']
    for c in DISSALOW_CN:
        if c in CN:
            raise ServerException({'error': '"' + c +'" is not allowed in a CN'})
    for elem in (C, ST, L, O, OU, CN, name, emailAddress):
        if elem == None or elem == '':
            raise ServerException({'error': 'An argument is not set'})

    client_config_dir = join(CLIENT_CONFIG_DIR, CN)

    if isdir(client_config_dir):
        raise ServerException({'error': f'The client already exists, please ensure "{CN}" is deleted'})

    # 1. Create client
    os.makedirs(client_config_dir, exist_ok=True)
    log.success(f'Created client config dir {client_config_dir}')
    # Create client Key pair
    log.fine(f'Creating a {keySize} bits keypair for {CN}')
    key_pair = EVP.RSA.gen_key(keySize, 65537) # e = 65537, padding is handled by openvpn
    key_pair.save_key(join(client_config_dir, CN + '.key'), cipher=None) # save keypair
    client_public_key = EVP.PKey()
    client_public_key.assign_rsa(key_pair)

    # create client CSR from params
    client_cert_request = X509.X509_Name() # X509.Request()
    client_cert_request.C = C
    client_cert_request.ST = ST
    client_cert_request.L = L
    client_cert_request.O = O
    client_cert_request.OU = OU
    client_cert_request.CN = CN
    client_cert_request.name = name
    client_cert_request.emailAddress = emailAddress

    # Sign the client certificate with the Server CA
    log.fine(f'Loading server CA')
    # Load CA cert & key
    ca_cert = X509.load_cert(join(SERVER_CONFIG_KEY_DIR, 'ca.crt'))
    ca_key  = EVP.RSA.load_key(join(SERVER_CONFIG_KEY_DIR, 'ca.key'))
    ca_sk   = EVP.PKey()
    ca_sk.assign_rsa(ca_key)
    
    # Create new certificate
    client_crt = X509.X509()

    # Set expiracy
    t = int(time.time()) + time.timezone
    now = ASN1.ASN1_TIME()
    expire = ASN1.ASN1_TIME()
    now.set_time(t)
    expire.set_time(t + maxUsage)

    client_crt.set_not_before(now)
    client_crt.set_not_after(expire)
    client_crt.set_issuer(ca_cert.get_subject())
    client_crt.set_pubkey(client_public_key)
    client_crt.set_subject(client_cert_request) # .get_subject()
    client_crt.sign(ca_sk, 'sha1') # sign cert with CA key and SHA1 (we should update to SHA256)
    client_crt.save(join(client_config_dir, CN + '.crt')) # Save cert
    log.success(f'Signed {CN} certificate with server CA')

    # Setup directories
    shutil.copy(CLIENT_BASE_CONFIG, join(client_config_dir, CN + '.conf'))
    os.symlink(join(SERVER_CONFIG_KEY_DIR, 'ca.crt'), join(client_config_dir, 'ca.crt'))
    log.success(f'Linked CA in client folder')

    ## Commit cert to database
    with open(join(SERVER_CONFIG_KEY_DIR, 'serial'), 'r') as ser:
        serial = ser.read().strip()
    log.fine(f'Client serial is {serial}')
    with open(join(SERVER_CONFIG_KEY_DIR, 'index.txt'), 'a+') as db:
        db.write('\t'.join(
            [
                'V',
                datetime.datetime.fromtimestamp(t + maxUsage).strftime("%y%m%d%H%m%SZ"),
                f'{serial}',
                'unknown',
                f'/C={C}/ST={ST}/L={L}/O={O}/OU={OU}/CN={CN}/name={name}/emailAddress={emailAddress}\n'
            ]
        ))
    shutil.copy(join(client_config_dir, CN + '.crt'), join(SERVER_CONFIG_KEY_DIR, serial + '.crt'))
    
    # Increment serial number
    serial = '{0:x}'.format(int(serial, base=16) + 1).capitalize().zfill(2)
    with open(join(SERVER_CONFIG_KEY_DIR, 'serial'), 'w') as ser:
        ser.write(serial)

The backbone of the API consists of these two functions and are tied to the API, because they don’t required additional threads and use the filesystem to operate, they are very straightforward to use in the API safely.

API Outline

The API only has a few routes,

  • /status to pull VPN information
  • /client/<name>/config to get a client .ovpn config, the configuration is built on the fly by appening the different generated files in the supported OpenVPN format
  • /download/<token> using a generated token so a person can download their configuration
  • /client/auto_find_config uses the JWT payload from the lab website to extract the common name and create an account automatically.

Should I rewrite this software, I’d opt for FastAPI to get the API documentation.

Front-end

Front end was made using vuejs and was a test case usage. The vuejs documentation is enough to get started and use an existing API. Further work include a better UI.

Back to Home


© WIN32GG 2022

Linkedin GitHub