Raspberry Pi OPNsense Captive Portal Voucher Generation

When I received my new HP/Aruba iAP-305-RW access points I started to think about introducing a wireless guest network. Not a network with a pre-shared key, but something more secure and flexible. The HP/Aruba AP’s have the option for captive portal, but it doesn’t have a good integration with ACME/Let’s Encrypt certificates. My OPNsense firewall has very good integration with ACME/Let’s Encypt, and has the option of deploying a Captive Portal.

Configuring the Captive Portal on the OPNsense firewall is pretty straightforward. It’s well documented, and is up-and-running in minutes. The main challenge was creating a way to supply the credentials to the users. The default option is to generate voucher codes and print them. Not really an option, since I loose those pieces of paper before I even printed them.

The newer OPNsense software has a decent API, which also includes API options for captive portal. This opened up an option including a Raspberry Pi.

Captive Portal Voucher API calls

Some quick testing in Postman I discovered that the use of the API is pretty easy. Afterthat I created a Python3 script that created vouchers on command and printing the created usernames and passwords on screen.

Time to define the project parameters;

  • Guest Portal accessible through https -> ACME/Let’s Encrypt on OPNsense

  • Automatic redirect on the client to the login page.

  • Easy usage for the guests.

  • Easy username & password usage (preferably QR-code).

  • auto-expiration of the accounts (definable).

  • Traffic throughput limitation (limited to 5Mbps up- and download).

This could all be implemented on a (wireless connected) Raspberry Pi with a display/LCD HAT attached. And I happen to have a PiFace Controle & Display lying around. This gives me 2 lines of 16 characters with several input buttons/switches.

PiFace Controle & Display HAT for Raspberry Pi 3B

The 16 characters per line limits the size of the Guest Portal accounts and the associated passwords. I decided to configure the voucher generation to 6 character for both username and password.

Guest Portal Voucher Settings

This would result in (both lines having 16 characters):

username: 1q2w3e
password: aZsXdC

This should not be to complex for the end-user to enter into the portal webpage.

The created Guest Voucher Server is used in the configuration of the Captive Portal in OPNsense.

The rulebase for the guestnetwork is also pretty straight-forward. Just allow access to non-RFC1918 addresses (a.k.a. the Internet) and allow the redirect port for the portal (port 8000), and explicitly deny access to the firewall management interfaces (just in case).

User traffic is limited by the wireless environment (which can also be done through the firewall).

The API key used for the API access is part of the OPNsense (admin) useraccount. When creating an API key, the browser downloads a file with the API key and the associated secret. These are to used in the scripting part for generating voucher usernames and passwords.

The API key is part of an admin user account in OPNsense

Everything should be in place to generate accounts for guest users and allowing them access to the Internet. The only thing missing is the scripting and Raspberry Pi configuration part to actually create the vouchers.

The basic Python3 script I created is shown below.

createVoucher.py:
#!/usr/bin/env python3

import requests
import toml
import urllib3
import pifacecad
import pifacecommon
from time import sleep

urllib3.disable_warnings()

# Load the config from file
config = toml.load("settings.ini")

opn_host = config['opnsense_info']['opn_host']
opn_port = config['opnsense_info']['opn_port']

api_key = config['authentication']['api_key']
api_secret = config['authentication']['api_secret']
verify_ssl = config['opnsense_info']['verify_ssl']

voucher_count = config['voucher_info']['voucher_count']
validity = config['voucher_info']['validity']
expiry_time = config['voucher_info']['expiry_time']
voucher_provider = config['voucher_info']['voucher_provider']
voucher_group = config['voucher_info']['voucher_group']
delete_expired = config['voucher_info']['delete_expired']

banner = f"Get your 'free'\nWiFi access."

voucher_dict = {
    "count": voucher_count,
    "validity": validity,
    "expirytime": expiry_time,
    "vouchergroup": voucher_group
}

cad = pifacecad.PiFaceCAD()
cad.lcd.backlight_off()
cad.lcd.cursor_off()
cad.lcd.blink_off()

opn_session = requests.Session()
opn_session.auth = (api_key, api_secret)
opn_session.verify = verify_ssl

uri = f"https:///api/captiveportal/voucher"


def createvoucher(self):
    # Iterate over all providers and groups and (optionally) delete expired vouchers
    # Get all voucher providers
    url = f"/listProviders"
    r = opn_session.get(url)

    # convert string to actual list (the response is string which looks like a list)
    providers = r.text.replace('"', '').strip('][').split(',')

    for provider in providers:
        # Get the voucher groups per voucher provider
        url = f""
        r = opn_session.get(url)

        # convert string to actual list
        groups = r.text.replace('"', '').strip('][').split(',')

        # Iterate over the list
        for group in groups:
            # If there's no group, there's nothing to delete
            if group != '' and delete_expired:
                url = f""
                r = opn_session.post(url)

    # create a new voucher
    voucher_dict = {
        "count": voucher_count,
        "validity": validity,
        "expirytime": expiry_time,
        "vouchergroup": voucher_group
    }
    url = f""
    r = opn_session.post(url, data=voucher_dict)
    results = r.json()

    cad.lcd.backlight_on()
    cad.lcd.clear()
    for account in results:
        cad.lcd.write(f"username: \npassword: ")
    return


def clear(self):
    cad.lcd.clear()
    cad.lcd.backlight_off()
    cad.lcd.write(banner)


def main():
    cad.lcd.backlight_off()
    cad.lcd.write(banner)
    listener = pifacecad.SwitchEventListener(chip=cad)
    # position 1 is the switch id (starting at zero)
    # position 2 is the hardware action, and
    # position 3 is the function being called
    listener.register(0, pifacecad.IODIR_ON, createvoucher)
    listener.register(4, pifacecad.IODIR_ON, clear)
    listener.activate()


if __name__ == "__main__":
    main()

Note that the Python3 Pyface library is no longer available through pip. The following commands can be used to donwload and install these:

pip3 install git+https://github.com/piface/pifacecommon.git
pip3 install git+https://github.com/piface/pifacecad.git
pip3 install lirc

With the voucher settings (the variable voucher_dict) I can change the duration of the actual voucher. With each creation I also do a purge of all expired vouchers, so the OPNsense interface stays nice and crisp 😊.

All the settings are taken from a settings.ini file, but these can be integrated in the script as well (if you want).

settings.ini:
[opnsense_info]
opn_host = "192.168.0.254"
opn_port = 4443  # admin gui listening port
verify_ssl = false

[authentication]
api_key = "JH/l40LcS6qGrIZJ******************YO4JRJsazDnlUvElNtvmBEl"
api_secret = "GfLUrEYeSr2J******************0zfadlj9BxjGCblXaew0N84l"

[voucher_info]
voucher_count = 1
validity = 3600  # in seconds
expiry_time = 3600  # time of expiration after creation of the voucher (also in seconds)
voucher_provider = "Guest Voucher Server"
voucher_group = "1hourpass"
delete_expired = true

The following images show the result. Press the most left button (0) for a new username and password. Press the most right button (4) to clear the display and show a default text.

Assignment of the input switches of the PiFace Control & Display HAT.

Demo

The video below shows the booting of the service and the generation of some codes (most left button)and the reset of the display (most right button).

I visualized the boot process with some text. This is not shown in the python code. It has no real function.

A listing of the created Guest Portal vouchers.

To-Do / Wishlist

It would be more user-friendly if there’s a way to use a QR-code for entering the credentials. This would mean that the username and password are embedded in the URL accessing the portal. There’s a ‘patch’ available on github that should allow this, but this only viable if I have another display attached to the Raspberry Pi which can actually display QR-codes. Until that time we test-drive this solution.

Posted on July 19, 2023 and filed under Programming, Raspberry Pi, Security, Gadgets.