#! /usr/bin/env perl # Copyright (c) 2025, Daily Data, Inc. # All rights reserved. # # Redistribution and use in source and binary forms, with or without modification, # are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, this list # of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above copyright notice, this # list of conditions and the following disclaimer in the documentation and/or other # materials provided with the distribution. # 3. Neither the name of Daily Data, Inc. nor the names of its contributors may be # used to endorse or promote products derived from this software without specific # prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT # SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH # DAMAGE. # # Script which will read information from an OPNsense router via its API # and create/update user OTP secrets, QR codes, and OpenVPN configuration files # for each user with a VPN certificate. # # Change History: # v1.0 2025-10-01 - Initial version RWR # Initial Release # v1.0.1 2025-10-01 RWR # Added a lock file to not allow the script to run if it is already running. If we can not # get an exclusive lock on $lockFile in $lockRetryTime*$lockRetries seconds, script will abort # Added both the full path to the data directories as well as the relative, to help # the web site (relative) and the script (full path, even if we're running from a different) # location to work easily (less code) use strict; use warnings; # use libraries from the directory this script is in BEGIN { use FindBin; use File::Spec; # use libraries from the directory this script is in use Cwd 'abs_path'; use File::Basename; use lib dirname( abs_path( __FILE__ ) ); } use JSON; # for encode_json, decode_json use Data::Dumper; # for debugging use opnsense; # our module to handle opnsense API calls use GD::Barcode::QRcode; # for creating the QR code images use MIME::Base64 qw( decode_base64 ); # for decoding the ovpn file returned from the API use File::Path qw( make_path ); # for creating directories use Fcntl qw(:flock); # Import locking constants our $VERSION = '1.0.1'; # Check if running as root if ($> != 0) { die "Error: This script must be run as root.\n"; } #----------------------------- # Configuration Variables #----------------------------- # Location of the configuration files and directories where we will store data # relative to the script directory as written my $scriptDir = $FindBin::RealBin; my $configFile = $scriptDir . '/routers.json'; my $usersFile = $scriptDir . '/users.json'; my $qrLocation = './qrcodes'; my $ovpnFileLocation = './openvpn_configs'; # size of the QR code modules, basically a magnification factor my $moduleSize = 10; # strings to add to the ovpn file if not already present my $additionalOvpnStrings = "static-challenge 'Enter Auth Code' 0\nauth-nocache\n"; # using a lock file to ensure script does not run while another request has it running my $lockFile = '/tmp/opnsense.lock'; my $lockRetryTime = 10; # number of seconds between subsequent tries for a lock my $lockRetries = 6; # number of times we will attempt to get a lock before failing #----------------------------- # slurpFile: Reads in the entire contents of a file and returns it as a string # $file - name of file to read # returns the contents of the file as a string, or empty string if file does not exist # since we do this a lot, make it a separate function. This is likely the most efficient way # to read a file in Perl #----------------------------- sub slurpFile { my ($file) = @_; my $data = ''; if (-e $file) { open(my $fh, '<', $file) or die "Cannot open $file: $!"; local $/; # slurp mode $data = <$fh>; close $fh; } return $data; } #----------------------------- # loadConfig: Load router configuration from config file # $file - name of file to read # $entry - name of the router entry to load # returns a reference to the data structure for the router, or empty hash if not found #----------------------------- sub loadConfig { my ($file, $entry) = @_; my $return = &slurpFile($file); return {} unless $return && $return ne ''; my $data = decode_json($return); return $data->{$entry} if (exists $data->{$entry}); return {}; } #----------------------------- # loadUsers: Load users data structure from file # $file - name of file to read # returns a reference to the data structure # $file assumed to be in JSON format #----------------------------- sub loadUsers { my ($file) = @_; my $data = &slurpFile($file); return {} unless $data && $data ne ''; my $return = decode_json($data); return {} unless $return && ref($return) eq 'HASH'; return $return; } #----------------------------- # saveUsers: Save users data structure to file # $file - name of file to write # $data - reference to the data to write # $timeStampFile - optional name of a file to write the current timestamp to # writes the data in JSON format #----------------------------- sub saveUsers { my ($file, $data) = @_; open(my $fh, '>', $file) or die "Cannot open $file: $!"; print $fh encode_json($data); close $fh; } #----------------------------- # makeQR: Creates a QR code file # $routerName - name of the router (used in filename and OTP URL) # $account - account name (used in filename and OTP URL) # $secret - OTP secret # $moduleSize - size of the QR code modules # $relativeDir - directory to save the QR code file # $absDir - The directory the script is in, so writing relative directories will correctly place # returns the filename of the created QR code image #----------------------------- sub makeQR { # Creates a QR code image for the user's OTP secret my ($routerName, $account, $secret, $moduleSize, $relativeDir, $absDir ) = @_; # build the OTP URL to include router name as issuer and the account name for display in authenticator apps my $otpUrl = "otpauth://totp/$routerName:$account?secret=$secret&issuer=$routerName"; # generate the QR code my $barcode = GD::Barcode::QRcode->new($otpUrl, { Ecc => 'M', ModuleSize => $moduleSize } ); my $image = $barcode->plot(); my $fileName = ($routerName ? $routerName . '_' : '' ) . $account . '.png'; # This allows us to retain the relative file path for storage, but ensuring we write the file to # the correct path even if we are not running the script from the script directory. open my $out, '>', "$absDir/" . $fileName or die "Cannot write file $fileName to $absDir: $!"; binmode $out; print $out $image->png; close $out; return $relativeDir . '/' . $fileName; } #----------------------------- # makeVPNConfigFile: Creates a VPN configuration file for the user # $contents - reference to the hash containing the 'content' key with base64 encoded ovpn file # $routerName - name of the router (used in filename) # $userName - name of the user (used in filename) # $ovpnLocation - The relative path to the output directory # $absDir - The absolute path to the output directory # $additionalOvpnStrings - additional strings to add to the ovpn file if not already present # returns the filename of the created ovpn file #----------------------------- sub makeVPNConfigFile { my ($contents, $routerName, $userName, $ovpnLocation, $absDir, $additionalOvpnStrings) = @_; return '' unless $contents && $contents->{'content'}; my $fileName = ($routerName ? $routerName . '_' : '' ) . $userName . '.ovpn'; $contents->{'content'} = decode_base64($contents->{'content'}); $contents->{'content'} .= "\n"; $contents->{'content'} =~ s/(\r?\n)+/\n/g; # normalize line endings my $comment = ''; if ( $contents->{'content'} !~ /# Added by loadOpnSense.pl/ ) { $comment = "# Added by loadOpnSense.pl\n"; } foreach my $string ( split( /\n/, $additionalOvpnStrings ) ) { unless ( $contents->{'content'} =~ /\$string/ ) { $contents->{'content'} .= "$comment$string\n"; $comment = ''; # only add comment once } } # This allows us to retain the relative file path for storage, but ensuring we write the file to # the correct path even if we are not running the script from the script directory. open my $out, '>', "$absDir/" . $fileName or die "Cannot write file $fileName to $absDir: $!"; print $out $contents->{'content'}; close $out; return $ovpnLocation . '/' . $fileName; } # cleanOldDataFiles: Deletes old data files in the specified directory matching the given pattern # $directory - directory to scan for old files # $pattern - regex pattern to match files to delete # simply removes all files matching the pattern #----------------------------- sub cleanOldDataFiles { my ( $directory, $pattern ) = @_; opendir(my $dh, $directory) or die "Cannot open directory $directory: $!"; while (my $file = readdir($dh)) { next if ($file =~ /^\./); if ($file =~ /$pattern/) { unlink "$directory/$file" or warn "Could not delete $directory/$file: $!"; } } closedir($dh); } #----------------------------- # Main program #----------------------------- # make sure script does not run more than once at the same time # Open the lock file open(my $fh, '>', $lockFile) or die "Cannot open lock file $lockFile: $!"; # Attempt to acquire an exclusive lock. We will try $lockTimeOut times, each # waiting $lockRetryTime while ( $lockRetries-- ) { if (flock($fh, LOCK_EX | LOCK_NB)) { last; } else { sleep($lockRetryTime); } } # if $lockTimeOut is zero, we could not get a lock in, so die die "Another instance of the script is already running and retries exceeded!\n" unless ( $lockRetries ); # get the router name from the command line my $router = shift @ARGV or die "Usage: $0 \n"; # Load existing configuration or initialize a new one my $config = &loadConfig($configFile, $router ); die "Configuration for router $router not found in $configFile\n" unless $config && ref($config) eq 'HASH'; # load the users file. We will update the entry for this router my $users = &loadUsers( $usersFile ); # if there is no entry for this router, create an empty one $users->{$router} = {} unless exists $users->{$router}; $users->{$router}->{'qrLocation'} = $qrLocation unless exists $users->{$router}->{'qrLocation'}; $users->{$router}->{'ovpnLocation'} = $ovpnFileLocation unless exists $users->{$router}->{'ovpnLocation'}; $users->{$router}->{'users'} = {}; # clean out the users list, we will reload it # and get the location on the file system in case we are not running the script from the script directory $users->{$router}->{'qrLocationFileystem'} = abs_path( $scriptDir . '/' . $qrLocation ) unless exists $users->{$router}->{'qrLocationFileystem'}; $users->{$router}->{'ovpnLocationFileSystem'} = abs_path( $scriptDir . '/' . $ovpnFileLocation ) unless exists $users->{$router}->{'ovpnLocationFileSystem'}; # ensure the directories exist and give them full access make_path( $users->{$router}->{'qrLocationFileystem'}, 0777 ) unless -d $users->{$router}->{'qrLocationFileystem'}; make_path( $users->{$router}->{'ovpnLocationFileSystem'}, 0777 ) unless -d $users->{$router}->{'ovpnLocationFileSystem'}; # instead of actually going through and cleaning files no longer in the users list, # just delete all files for this router and recreate them &cleanOldDataFiles( $users->{$router}->{'qrLocationFileystem'}, qr/^\Q$router\E_.*\.png$/ ); &cleanOldDataFiles( $users->{$router}->{'ovpnLocationFileSystem'}, qr/^\Q$router\E_.*\.ovpn$/ ); # this does most of the work, all the API calls are handled in the module # create the opnsense object my $opnsense = opnsense->new( url => $config->{'url'}, apiKey => $config->{'apiKey'}, apiSecret => $config->{'apiSecret'}, ovpnIndex => $config->{'ovpnIndex'}, localPort => $config->{'localPort'}, template => $config->{'template'}, hostname => $config->{'hostname'}, ); # get the VPN users. This is a hashref keyed by cert name, value is username my $vpnCerts = $opnsense->getVpnUsers(); #die Dumper($vpnCerts); # convert the cert-"user" to username->certs, array ref of certs as not sure if multiple certs per user allowed foreach my $cert ( keys %$vpnCerts ) { push @{$users->{$router}->{'users'}->{$vpnCerts->{$cert}}->{'certs'}}, $cert; } # these are all of the users on the system my $allUsers = $opnsense->getAllUsers(); # for each user in the system, if they are in the vpnUsers list, copy otp_seed, cert, and password # we'll also delete any users who are disabled my @keys = ('otp_seed', 'cert', 'password' ); # these are the keys we want to copy foreach my $user ( keys %$allUsers ) { next unless exists $users->{$router}->{'users'}->{$user}; # skip users who do not have vpn certs if ( $allUsers->{$user}->{'disabled'} ) { # skip users who are disabled delete $users->{$router}->{'users'}->{$user}; next; } foreach my $key ( @keys ) { $users->{$router}->{'users'}->{$user}->{$key} = ref($allUsers->{$user}->{$key}) ? '' : $allUsers->{$user}->{$key}; } } # create the QR code file and VPN configuration for each user foreach my $entry ( keys %{$users->{$router}->{'users'}} ) { my $account = $entry; my $secret = $users->{$router}->{'users'}->{$entry}->{'otp_seed'} || ''; # Create the QR code file and store the filename in the data structure # If there is no secret, do not create a QR code $users->{$router}->{'users'}->{$entry}->{'qrFile'} = $secret && $secret ne '' ? &makeQR( $router, $account, $secret, $moduleSize, $users->{$router}->{'qrLocation'}, $users->{$router}->{'qrLocationFileystem'} ) : ''; # Create the VPN configuration file, if it exists. If multiple certs, only create the first one # warn user if there are multiple certs if ( scalar(@{$users->{$router}->{'users'}->{$entry}->{'certs'}}) > 1 ) { warn "$entry has multiple certs, using first one only\n"; } my $cert = $opnsense->getVpnConfig( $users->{$router}->{'users'}->{$entry}->{'certs'}->[0] ); if ( !$cert || ref($cert) ne 'HASH' || !exists $cert->{'content'} ) { warn "Could not get VPN configuration for $entry, cert $users->{$router}->{'users'}->{$entry}->{'certs'}->[0]\n"; next; } $users->{$router}->{'users'}->{$entry}->{'ovpnFile'} = &makeVPNConfigFile( $cert, $router, $entry, $users->{$router}->{'ovpnLocation'}, $users->{$router}->{'ovpnLocationFileSystem'}, $additionalOvpnStrings ); } # update the timestamp for the current router $users->{$router}->{'lastUpdate'} = time; # save the users file &saveUsers( $usersFile, $users ); # Remove the lock file so the script can run again close($fh); unlink($lockFile) or warn "Could not unlink lock file: $!"; 1;