OpenVPN Server: Assigning Static IP Addresses Via A Script
Tags: openvpn
Published: 2022-06-30 15:02:03

---
title: OpenVPN Server: Assigning Static IP Addresses Via A Script
published: true
date: 2022-06-29 19:48:50 UTC
tags: OpenVPN
canonical_url: https://www.euantorano.co.uk/posts/2022/06/29/openvpn-static-ip-assignment-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:

```sql
CREATE TABLE clients (
  id SERIAL PRIMARY KEY,
  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](https://openvpn.net/community-resources/reference-manual-for-openvpn-2-4/) and more specifically the [Scripting and environment variables section](https://openvpn.net/community-resources/reference-manual-for-openvpn-2-4/#scripting-and-environmental-variables). 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:

- If the script exits with any non-zero exit code, the client’s connection attempt will fail.
  - The failure results in a hard crash when [running OpenVPN Connect v3 on Windows in service daemon mode](https://openvpn.net/vpn-server-resources/use-openvpn-connect-v3-on-windows-in-service-daemon-mode/), and the service recovery options in Windows do not recover the service. Due to this, my script always returns exit code 0, even in case of error - this is unintuitive, but necessary.
- The maximum length of a configuration value in the OpenVPN server configuration file is 256 characters, so passing a Data Source Name on the command line is a no-go. In this case, it’s best to write a wrapper script to call your actual script.
- Any output from the script to stderr/stdout will be written straight out by the OpenVPN server - this means if you are on a Linux server with systemd running your OpenVPN server, logs from your script can be found in journald!

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/resolve-vpn-ip.sh` and made executable:

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

DSN="postgresql://..."

/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:

```go
package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"net"
	"os"
	"os/signal"
	"syscall"

	"github.com/jackc/pgx/v4"
)

func main() {
	log.SetFlags(log.LstdFlags)

	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 {
		flags.PrintDefaults()

		os.Exit(0)
	}

	remainingArgs := flags.Args()

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

		os.Exit(0)
	}

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

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

		os.Exit(0)
	}

	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)

		os.Exit(0)
	}

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

	if err != nil {
		log.Printf(
			"failed to open config file for client: %s; file: %s; error: %s\n",
			commonName,
			remainingArgs[0],
			err,
		)

		os.Exit(0)
	}

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

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

		os.Exit(0)
	}
}

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)
	}(conn)

	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)
	default:
		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:

```bash
client-connect "/usr/local/bin/resolve-vpn-ip.sh"
```

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