Euan's Blog

OpenVPN Server: Assigning Static IP Addresses Via A Script

I recently had to set up an OpenVPN server where each client needed to be assigned a static IP address. The IP addresses were stored in a database table, alongside the client's name and some other data. Rather than using static configuration via a client specific configuration file using the client-config-dir directive, I went looking for a way to do it dynamically. I eventually settled on using a client-connect script to assign the IP straight from the database, though found the documentation rather lacking.

I have a database table called clients which is used for a couple of other tasks and essentially has the following structure:

CREATE TABLE clients (
  name VARCHAR(255) NOT NULL,
  ip inet NOT NULL,
  CONSTRAINT clients_name_unique UNIQUE (name),
  CONSTRAINT clients_ip_unique UNIQUE (ip)

When a client connects, I want to look up their IP address based upon their X509 Common Name and push it to the client.

When using the client-config-dir option, this would be done by creating a file within the configured directory named the same as the Common Name for the client and using ifconfig-push IP_ADDRESS SUBNET_MASK. This is a very well documented approach, including being mentioned in the comments within the main server config file. The problem is that maintaining these files adds an additional chore when I already maintain the database table.

An alternative approach is to use one of the many possible scripts that OpenVPN Server can be configured to run when handling various events - in particular the client-connect script.

While exploring options, I found several questions on the StackExchange network from people looking for example scripts, and I eventually found the Reference manual for OpenVPN 2.4 and more specifically the Scripting and environment variables section. While this gets you part of the way there by explaining which environment variables are available for each type of script, it doesn't do much to explain how you can actually influence the configuration of a connection itself.

It turns out that the OpenVPN server actually passes an argument to the configured script which is a path to a file that represents configuration for the client. So the flow basically ends up being:

  1. Get the common_name environment variable if it is set.
  2. Look up the IP address for the client using the value of the common_name.
  3. If an IP address is found, open the file specified in the first argument to the program and write the text ifconfig-push IP_ADDRESS SUBNET_MASK to the file.

There are a couple of notes to this approach though:

The path I ended up taking was a relatively simple piece of Go, with a bash script to call it. The bash script is saved to /usr/local/bin/ and made executable:

#!/usr/bin/env bash
set -Eeuo pipefail


/usr/local/bin/resolve-vpn-ip -db="$DSN" "$@"

The actual /usr/local/bin/resolve-vpn-ip program is a Go binary, as I re-use some common code from other systems also talking to this same database - it basically boils down to the following:

package main

import (


func main() {

	flags := flag.NewFlagSet("resolve-vpn-ip", flag.ExitOnError)
	dsnFlag := flags.String("db", "", "the database connection string to use to connect to the database")

	if err := flags.Parse(os.Args[1:]); err != nil {


	remainingArgs := flags.Args()

	if len(remainingArgs) < 1 {
		log.Println("no arguments after flags, OpenVPN did not pass in a configuration file")


	commonName, found := os.LookupEnv("common_name")

	if !found || commonName == "" {
		log.Println("common_name environment variable is not set or is empty")


	log.Printf("getting IP address for client: %s\n", commonName)

	ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)

	ip, err := getIpForClient(ctx, *dsnFlag, commonName)

	if err != nil {
		log.Printf("error getting IP address for client: %s; error: %s\n", commonName, err)


	f, err := os.OpenFile(remainingArgs[0], os.O_APPEND|os.O_WRONLY|os.O_CREATE, os.ModePerm)

	if err != nil {
			"failed to open config file for client: %s; file: %s; error: %s\n",


	defer func(toClose *os.File) {
		_ = toClose.Close()

	if _, err = fmt.Fprintf(f, "ifconfig-push %s\n", ip.String()); err != nil {
			"failed to write to config file for client: %s; file: %s; error: %s\n",


func getIpForClient(ctx context.Context, dsn, commonName string) (*net.IP, error) {
	conn, err := pgx.Connect(ctx, dsn)

	if err != nil {
		return nil, err

	defer func(c *pgx.Conn) {
		_ = conn.Close(ctx)

	var ip net.IP

	err = conn.QueryRow(ctx, `SELECT ip FROM clients WHERE name = $1;`, commonName).Scan(&ip)

	switch err {
	case nil:
		return &ip, nil
	case pgx.ErrNoRows:
		return nil, fmt.Errorf("no rows found for client: %s", commonName)
		return nil, fmt.Errorf("error getting ip for client: %s; error: %w", commonName, err)

In the OpenVPN server config file I can then add something like the following to configure the server to execute my script:

client-connect "/usr/local/bin/"

And I can tail my logs at any time via journald by running a command like journalctl -f /usr/local/bin/resolve-vpn-ip.