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:
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,
/statusto pull VPN information/client/<name>/configto 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_configuses 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.