The UMBC System Administration and Software Development (SAD) Club runs a pfSense VM with OpenVPN installed. For our internal splash page, I thought it would be kind of cool if it automatically determined, based on the request’s IP address, your username.

┌────────────┐  "who is 192.168.5.2?"
│   pfSense  │◄────────────┐
│192.168.1.1 │             │
└────┬──┬────┴───────────┐ │
     │||│    "it's nbura"│ │
     │||│                ▼ │
┌────┴──┴────┐       ┌─────┴──────┐
│nbura on VPN├──────►│ welcome    │
│192.168.5.2 │       │ page       │
└────────────┘◄──────┴────────────┘
           "welcome, nbura"

Serving VPN Data from pfSense

I’d need to get the IP→username associations out of pfSense somehow. My first thought was that pfSense must have some sort of API, but turns out there’s only 3rd party solutions. Disregarding that, I’d have to write up some sort of simple service to run on the pfSense box.

For starters, I enabled pfSense SSH to allow for easier development, then logged in as root. (Don’t judge…)

The OpenVPN Socket

On pfSense (and in general, I believe), OpenVPN actually runs a UNIX socket which functions as a simple management interface. Super cool. It lives in /var/etc/openvpn/server2/sock. (Note that server2 is for my particular VPN of interest, yours may be server1 or otherwise.)

I used good old netcat (nc -U) to access it, though there may be better alternatives. On the TTY, this will give you an interactive interface. Type help to get all the commands.

: ls -la /var/etc/openvpn/server2/sock
srwxrwxrwx  1 root  wheel  0 Aug 22 22:34 /var/etc/openvpn/server2/sock

: cd /var/etc/openvpn/server2/

: nc -U sock
>INFO:OpenVPN Management Interface Version 3 -- type 'help' for more info
status
OpenVPN CLIENT LIST
Updated,2021-09-26 17:08:20
Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
nbura,REDACTED,7484004,19903436,REDACTED
ROUTING TABLE
Virtual Address,Common Name,Real Address,Last Ref
192.168.5.2,nbura,REDACTED,REDACTED
GLOBAL STATS
Max bcast/mcast queue length,0
END

You can script it by just echoing commands into nc -U; again, I’m sure there’s better ways, but this worked. Trouble is, the socket stays open. My awful solution was just to time it out. (Note: nc -w doesn’t take decimal numbers, and nc -W doesn’t appear to exist on FreeBSD’s nc.)

: echo status | timeout 0.05 nc -U /var/etc/openvpn/server2/sock
>INFO:OpenVPN Management Interface Version 3 -- type 'help' for more info
OpenVPN CLIENT LIST
...

Publishing with socat

I’ve heard great praise for socat, but this was my first time actually trying it out. To my understanding, it basically functions as a multipurpose “relay” for connecting network sockets to and from other sockets, shells, commands, etc.

A basic socat setup to publish the VPN status to anyone on port 3100 looks as follows:

socat tcp-listen:3100,reuseaddr,fork system:"echo status | timeout 0.05 nc -U /var/etc/openvpn/server2/sock"

And you receive on the other end with:

$ nc -W 2 pfsense 3100
>INFO:OpenVPN Management Interface Version 3 -- type 'help' for more info
OpenVPN CLIENT LIST
...

The -W 2 causes it to quit after 2 packets have been received, so you get a nice, simple command that just outputs the status table. Awesome.

NOTE: socat is explicitly used for connecting sockets together. One absolutely could just connect TCP-LISTEN port 3100 directly to a UNIX-CONNECT for the OpenVPN socket. However, I’d prefer to restrict this port to just running one single command, and any other user input should not be accepted; hence the weird approach with system and nc and all.

Anyways, while there’s nothing super crazy about this status data and it is restricted to the internal network, it still publishes users' personal IPs, which we’d prefer not to share among all users. So some security would be nice - albeit really sketchy security.

Securing socat

The solution was pretty simple. Where hunter2 is our password:

socat tcp-listen:3100,reuseaddr,fork system:"head -n 1 | grep -q '^hunter2' && echo status | timeout 0.05 nc -U /var/etc/openvpn/server2/sock"

The grep regex will verify that hunter2 is the first string on the first line. To access on the other end:

$ nc -w 1 -W 2 pfsense 3100
$ echo hunter2 | nc -w 1 -W 2 pfsense 3100
>INFO:OpenVPN Management Interface Version 3 -- type 'help' for more info
OpenVPN CLIENT LIST
...
END
$ echo aaahunter2 | nc -w 1 -W 2 pfsense 3100
$

Note the addition of the -w 1 to make the command time out after 1 second if authentication fails. For more info on this… creative… setup, see this Gist.

Making a FreeBSD Service

This socat service, which we’ll call catclients, should obviously run by itself on boot.

⚠ WARNING: Despite a bit of research, I honestly wasn’t able to find much solid info on FreeBSD services. This solution is sloppy, cobbled together from various snippets, and I can’t recommend following it.

We create a file, /etc/rc.d/catclients:

#!/bin/sh

# PROVIDE: catclients
# REQUIRE: LOGIN

. /etc/rc.subr

name="catclients"
rcvar=`set_rcvar`

command=/usr/local/bin/socat
thepassword="hunter2_insert_password_here"
command_args="tcp-listen:3100,reuseaddr,fork system:\"head -n 1 | grep -q '^$thepassword' && echo status | timeout 0.05 nc -U /var/etc/openvpn/server2/sock\""
start_cmd="/usr/sbin/daemon $command $command_args"

load_rc_config $name
run_rc_command "$1"

Start (service catclients start) and enable it. We should be good to go.

Fetching the Data

With the server setup complete on pfSense’s end, it’s time to set up the client on the hello box. For simplicity’s sake, we’ll just keep using netcat rather than set up the socket ourselves.

Some shell pipelining helps easily strip down the OpenVPN output.

$ echo hunter2 | nc -w 2 pfsense 3100
>INFO:OpenVPN Management Interface Version 3 -- type 'help' for more info
OpenVPN CLIENT LIST
Updated,2021-09-26 19:19:40
Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
nbura,REDACTED,14404523,32804708,2021-09-26 02:10:16
ROUTING TABLE
Virtual Address,Common Name,Real Address,Last Ref
192.168.5.2,nbura,REDACTED,2021-09-26 19:19:40
GLOBAL STATS
Max bcast/mcast queue length,0
END

$ echo hunter2 | nc -w 2 pfsense 3100 | grep -A 9999 ROUTING
ROUTING TABLE
Virtual Address,Common Name,Real Address,Last Ref
192.168.5.2,nbura,REDACTED,2021-09-26 19:19:48
GLOBAL STATS
Max bcast/mcast queue length,0
END

$ echo hunter2 | nc -w 2 pfsense 3100 | grep -A 9999 ROUTING | grep -B 9999 GLOBAL
ROUTING TABLE
Virtual Address,Common Name,Real Address,Last Ref
192.168.5.2,nbura,REDACTED,2021-09-26 19:19:57
GLOBAL STATS

$ echo hunter2 | nc -w 2 pfsense 3100 | grep -A 9999 ROUTING | grep -B 9999 GLOBAL | tail -n +3
192.168.5.2,nbura,REDACTED,2021-09-26 19:20:03
GLOBAL STATS

$ echo hunter2 | nc -w 2 pfsense 3100 | grep -A 9999 ROUTING | grep -B 9999 GLOBAL | tail -n +3 | head -n -1
192.168.5.2,nbura,REDACTED,2021-09-26 19:20:07

This is represented in the Elixir backend code as:

{output, _} =
  System.shell(
    "echo #{catclients_pw}" <>
      " | nc -w 2 pfsense 3100" <>
      " | grep -A 9999 ROUTING" <>
      " | grep -B 9999 GLOBAL" <>
      " | tail -n +3" <>
      " | head -n -1"
  )

And finally, we get the row with their IP address and parse out the username:

# determine IP of incoming request
user_ip = to_string(:inet_parse.ntoa(conn.remote_ip))

# parse as CSV and take the row with the matching ip
row =
  output
  |> CommaParser.parse_string(skip_headers: false)
  |> Enum.filter(fn row -> Enum.at(row, 0) == user_ip end)
  |> Enum.at(0)

# print the name of the connecting user
username = Enum.at(row, 1)
Logger.info(username)

With that, we’re done! The username is printed onto the console when they load the webpage.

Finally, in the real app, we make an LDAP query on their username to determine their full name, password status, groups, and email.