Subversion Repositories web_pages

Rev

Rev 16 | Blame | Compare with Previous | Last modification | View Log | Download | RSS feed

#! /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 <router_name>\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;