Subversion Repositories web_pages

Rev

Rev 20 | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
14 rodolico 1
#! /usr/bin/env perl
16 rodolico 2
 
3
# Copyright (c) 2025, Daily Data, Inc.
4
# All rights reserved.
5
#
6
# Redistribution and use in source and binary forms, with or without modification,
7
# are permitted provided that the following conditions are met:
8
#
9
# 1. Redistributions of source code must retain the above copyright notice, this list
10
#    of conditions and the following disclaimer.
11
# 2. Redistributions in binary form must reproduce the above copyright notice, this
12
#    list of conditions and the following disclaimer in the documentation and/or other
13
#    materials provided with the distribution.
14
# 3. Neither the name of Daily Data, Inc. nor the names of its contributors may be
15
#    used to endorse or promote products derived from this software without specific
16
#    prior written permission.
17
#
18
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
19
# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
20
# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
21
# SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
22
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
23
# TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
24
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
27
# DAMAGE.
28
#
29
# Script which will read information from an OPNsense router via its API
30
# and create/update user OTP secrets, QR codes, and OpenVPN configuration files
31
# for each user with a VPN certificate.
32
#
33
# Change History:
34
#  v1.0 2025-10-01 - Initial version RWR
35
#     Initial Release
36
#  v1.0.1 2025-10-01 RWR
37
#     Added a lock file to not allow the script to run if it is already running. If we can not
38
#       get an exclusive lock on $lockFile in $lockRetryTime*$lockRetries seconds, script will abort
39
#     Added both the full path to the data directories as well as the relative, to help
40
#       the web site (relative) and the script (full path, even if we're running from a different)
41
#       location to work easily (less code)
20 rodolico 42
#  v1.1.0 2026-01-04 RWR
43
#     Removed hardcoded additionalOvpnStrings variable
44
#     Added support for formats hash in router configuration
45
#     Modified makeVPNConfigFile to accept filename template with ROUTER and USER placeholders
46
#     Added support for multiple OVPN files per user based on formats configuration
47
#     Formats structure allows specifying filename and additionalStrings per format
48
#     Expands \n in additionalStrings to actual newlines in config files
21 rodolico 49
#  v1.1.1 2026-06-15 RWR
50
#     Decodes base64 OVPN content only once per user, not per format
51
#     Fixed bug in makeVPNConfigFile where content was being overwritten
52
#     Cleans out old QR code and OVPN files for the router before recreating them
16 rodolico 53
 
14 rodolico 54
use strict;
55
use warnings;
56
 
16 rodolico 57
# use libraries from the directory this script is in
58
BEGIN {
59
   use FindBin;
60
   use File::Spec;
61
   # use libraries from the directory this script is in
62
   use Cwd 'abs_path';
63
   use File::Basename;
64
   use lib dirname( abs_path( __FILE__ ) );
65
}
14 rodolico 66
 
16 rodolico 67
 
14 rodolico 68
use JSON; # for encode_json, decode_json
69
use Data::Dumper; # for debugging
70
use opnsense; # our module to handle opnsense API calls
71
use GD::Barcode::QRcode; # for creating the QR code images
72
use MIME::Base64 qw( decode_base64 ); # for decoding the ovpn file returned from the API
73
use File::Path qw( make_path ); # for creating directories
16 rodolico 74
use Fcntl qw(:flock); # Import locking constants
75
 
21 rodolico 76
our $VERSION = '1.1.1';
16 rodolico 77
 
78
# Check if running as root
79
if ($> != 0) {
80
   die "Error: This script must be run as root.\n";
81
}
82
 
83
#-----------------------------
84
# Configuration Variables
85
#-----------------------------
86
# Location of the configuration files and directories where we will store data
87
# relative to the script directory as written
88
my $scriptDir = $FindBin::RealBin;
89
my $configFile = $scriptDir . '/routers.json';
90
my $usersFile = $scriptDir . '/users.json';
91
my $qrLocation =  './qrcodes';
92
my $ovpnFileLocation =  './openvpn_configs';
93
# size of the QR code modules, basically a magnification factor
14 rodolico 94
my $moduleSize = 10;
16 rodolico 95
# using a lock file to ensure script does not run while another request has it running
96
my $lockFile = '/tmp/opnsense.lock';
97
my $lockRetryTime = 10; # number of seconds between subsequent tries for a lock
98
my $lockRetries = 6; # number of times we will attempt to get a lock before failing
14 rodolico 99
 
16 rodolico 100
#-----------------------------
101
# slurpFile: Reads in the entire contents of a file and returns it as a string
102
# $file - name of file to read
103
# returns the contents of the file as a string, or empty string if file does not exist
104
# since we do this a lot, make it a separate function. This is likely the most efficient way
105
# to read a file in Perl
106
#-----------------------------
107
sub slurpFile {
108
   my ($file) = @_;
109
   my $data = '';
110
   if (-e $file) {
111
      open(my $fh, '<', $file) or die "Cannot open $file: $!";
14 rodolico 112
      local $/;  # slurp mode
16 rodolico 113
      $data = <$fh>;
14 rodolico 114
      close $fh;
115
   }
116
   return $data;
117
}
118
 
16 rodolico 119
#-----------------------------
120
# loadConfig: Load router configuration from config file
121
# $file - name of file to read
122
# $entry - name of the router entry to load
123
# returns a reference to the data structure for the router, or empty hash if not found
124
#-----------------------------
125
sub loadConfig {
126
   my ($file, $entry) = @_;
127
   my $return = &slurpFile($file);
128
   return {} unless $return && $return ne '';
129
   my $data = decode_json($return);
130
   return $data->{$entry} if (exists $data->{$entry});
131
   return {};
132
}
133
 
134
#-----------------------------
135
# loadUsers: Load users data structure from file
136
# $file - name of file to read
137
# returns a reference to the data structure
138
# $file assumed to be in JSON format
139
#-----------------------------
140
sub loadUsers {
141
   my ($file) = @_;
142
   my $data = &slurpFile($file);
143
   return {} unless $data && $data ne '';
144
   my $return = decode_json($data);
145
   return {} unless $return && ref($return) eq 'HASH';
146
   return $return;
147
}
148
 
149
#-----------------------------
150
# saveUsers: Save users data structure to file
151
# $file - name of file to write
152
# $data - reference to the data to write
153
# $timeStampFile - optional name of a file to write the current timestamp to
154
# writes the data in JSON format
155
#-----------------------------
14 rodolico 156
sub saveUsers {
16 rodolico 157
   my ($file, $data) = @_;
158
   open(my $fh, '>', $file) or die "Cannot open $file: $!";
14 rodolico 159
   print $fh encode_json($data);
160
   close $fh;
161
}
162
 
16 rodolico 163
#-----------------------------
164
# makeQR: Creates a QR code file
165
# $routerName - name of the router (used in filename and OTP URL)
166
# $account - account name (used in filename and OTP URL)
167
# $secret - OTP secret
168
# $moduleSize - size of the QR code modules
169
# $relativeDir - directory to save the QR code file
170
# $absDir - The directory the script is in, so writing relative directories will correctly place
171
# returns the filename of the created QR code image
172
#-----------------------------
14 rodolico 173
sub makeQR {
16 rodolico 174
   # Creates a QR code image for the user's OTP secret
175
   my ($routerName, $account, $secret, $moduleSize, $relativeDir, $absDir ) = @_;
176
   # build the OTP URL to include router name as issuer and the account name for display in authenticator apps
177
   my $otpUrl = "otpauth://totp/$routerName:$account?secret=$secret&issuer=$routerName";
178
   # generate the QR code
179
   my $barcode = GD::Barcode::QRcode->new($otpUrl, { Ecc => 'M', ModuleSize => $moduleSize } );
14 rodolico 180
   my $image = $barcode->plot();
16 rodolico 181
   my $fileName = ($routerName ? $routerName . '_' : '' ) . $account . '.png'; 
182
   # This allows us to retain the relative file path for storage, but ensuring we write the file to 
183
   # the correct path even if we are not running the script from the script directory.
184
   open my $out, '>', "$absDir/" . $fileName or die "Cannot write file $fileName to $absDir: $!";
14 rodolico 185
   binmode $out;
186
   print $out $image->png;
187
   close $out;
16 rodolico 188
   return $relativeDir . '/' . $fileName;
14 rodolico 189
}
190
 
16 rodolico 191
#-----------------------------
192
# makeVPNConfigFile: Creates a VPN configuration file for the user
193
# $contents - reference to the hash containing the 'content' key with base64 encoded ovpn file
20 rodolico 194
# $routerName - name of the router (used in filename template replacement)
195
# $userName - name of the user (used in filename template replacement)
16 rodolico 196
# $ovpnLocation - The relative path to the output directory
197
# $absDir - The absolute path to the output directory
20 rodolico 198
# $filenameTemplate - template for filename with ROUTER and USER placeholders
199
# $additionalOvpnStrings - additional strings to add to the ovpn file, \n will be expanded to newlines
16 rodolico 200
# returns the filename of the created ovpn file
201
#-----------------------------
14 rodolico 202
sub makeVPNConfigFile {
20 rodolico 203
   my ($contents, $routerName, $userName, $ovpnLocation, $absDir, $filenameTemplate, $additionalOvpnStrings) = @_;
14 rodolico 204
   return '' unless $contents && $contents->{'content'};
20 rodolico 205
   # Replace ROUTER and USER in the filename template
206
   my $fileName = $filenameTemplate;
207
   $fileName =~ s/ROUTER/$routerName/g;
208
   $fileName =~ s/USER/$userName/g;
21 rodolico 209
   # modified to use a separate variable for the file contents since it was overwritten previously
210
   my $fileContents = $contents->{'content'};
211
   $fileContents .= "\n";
212
   $fileContents =~ s/(\r?\n)+/\n/g; # normalize line endings
14 rodolico 213
   my $comment = '';
21 rodolico 214
   if ( $fileContents !~ /# Added by opensense-totp-ovpn-export/ ) {
215
      $comment = "# Added by opensense-totp-ovpn-export\n";
14 rodolico 216
   }
20 rodolico 217
   # Expand \n to actual newlines in additionalOvpnStrings
218
   $additionalOvpnStrings =~ s/\\n/\n/g if $additionalOvpnStrings;
219
   foreach my $string ( split( /\n/, $additionalOvpnStrings || '' ) ) {
220
      next if $string eq '';
21 rodolico 221
      unless ( $fileContents =~ /\Q$string\E/ ) {
222
         $fileContents .= "$comment$string\n";
14 rodolico 223
         $comment = ''; # only add comment once
224
      }
225
   }
16 rodolico 226
   # This allows us to retain the relative file path for storage, but ensuring we write the file to 
227
   # the correct path even if we are not running the script from the script directory.
228
   open my $out, '>', "$absDir/" . $fileName or die "Cannot write file $fileName to $absDir: $!";
21 rodolico 229
   print $out $fileContents;
14 rodolico 230
   close $out;
16 rodolico 231
   return $ovpnLocation . '/' . $fileName;
14 rodolico 232
}
233
 
16 rodolico 234
# cleanOldDataFiles: Deletes old data files in the specified directory matching the given pattern
235
# $directory - directory to scan for old files
236
# $pattern - regex pattern to match files to delete
237
# simply removes all files matching the pattern
238
#-----------------------------
239
sub cleanOldDataFiles {
240
   my ( $directory, $pattern ) = @_;
241
   opendir(my $dh, $directory) or die "Cannot open directory $directory: $!";
242
   while (my $file = readdir($dh)) {
243
      next if ($file =~ /^\./);
244
      if ($file =~ /$pattern/) {
245
         unlink "$directory/$file" or warn "Could not delete $directory/$file: $!";
246
      }
247
   }
248
   closedir($dh);
249
}
14 rodolico 250
 
251
 
16 rodolico 252
#-----------------------------
253
# Main program
254
#-----------------------------
255
 
256
# make sure script does not run more than once at the same time
257
# Open the lock file
258
open(my $fh, '>', $lockFile) or die "Cannot open lock file $lockFile: $!";
259
# Attempt to acquire an exclusive lock. We will try $lockTimeOut times, each
260
# waiting $lockRetryTime
261
while ( $lockRetries-- ) {
262
   if (flock($fh, LOCK_EX | LOCK_NB)) {
263
      last;
264
   } else {
265
      sleep($lockRetryTime);
266
   }
267
}
268
# if $lockTimeOut is zero, we could not get a lock in, so die
269
die "Another instance of the script is already running and retries exceeded!\n" unless ( $lockRetries );
270
 
14 rodolico 271
# get the router name from the command line
272
my $router = shift @ARGV or die "Usage: $0 <router_name>\n";
273
# Load existing configuration or initialize a new one
16 rodolico 274
my $config = &loadConfig($configFile, $router );
275
die "Configuration for router $router not found in $configFile\n" unless $config && ref($config) eq 'HASH';
20 rodolico 276
# die Dumper( $config ) . "\n";
14 rodolico 277
 
278
# load the users file. We will update the entry for this router
16 rodolico 279
my $users = &loadUsers( $usersFile );
14 rodolico 280
 
281
# if there is no entry for this router, create an empty one
282
$users->{$router} = {} unless exists $users->{$router};
283
$users->{$router}->{'qrLocation'} = $qrLocation unless exists $users->{$router}->{'qrLocation'};
284
$users->{$router}->{'ovpnLocation'} = $ovpnFileLocation unless exists $users->{$router}->{'ovpnLocation'};
285
$users->{$router}->{'users'} = {}; # clean out the users list, we will reload it
16 rodolico 286
# and get the location on the file system in case we are not running the script from the script directory
287
$users->{$router}->{'qrLocationFileystem'} = abs_path( $scriptDir . '/' . $qrLocation )
288
   unless exists $users->{$router}->{'qrLocationFileystem'};
289
$users->{$router}->{'ovpnLocationFileSystem'} = abs_path( $scriptDir . '/' . $ovpnFileLocation )
290
   unless exists $users->{$router}->{'ovpnLocationFileSystem'};
18 rodolico 291
# finally, copy the download token if it exists
292
$users->{$router}->{'downloadToken'} = $config->{'downloadToken'}
293
   if exists $config->{'downloadToken'};
14 rodolico 294
 
18 rodolico 295
#die Dumper( $users ) . "\n";
16 rodolico 296
 
18 rodolico 297
 
14 rodolico 298
# ensure the directories exist and give them full access
16 rodolico 299
make_path( $users->{$router}->{'qrLocationFileystem'}, 0777 ) unless -d $users->{$router}->{'qrLocationFileystem'};
300
make_path( $users->{$router}->{'ovpnLocationFileSystem'}, 0777 ) unless -d $users->{$router}->{'ovpnLocationFileSystem'};
14 rodolico 301
 
16 rodolico 302
# instead of actually going through and cleaning files no longer in the users list,
303
# just delete all files for this router and recreate them
304
&cleanOldDataFiles( $users->{$router}->{'qrLocationFileystem'}, qr/^\Q$router\E_.*\.png$/ );
305
&cleanOldDataFiles( $users->{$router}->{'ovpnLocationFileSystem'}, qr/^\Q$router\E_.*\.ovpn$/ );
306
 
14 rodolico 307
# this does most of the work, all the API calls are handled in the module
308
# create the opnsense object
16 rodolico 309
my $opnsense = opnsense->new(
14 rodolico 310
   url    => $config->{'url'},
311
   apiKey    => $config->{'apiKey'},
312
   apiSecret => $config->{'apiSecret'},
313
   ovpnIndex  => $config->{'ovpnIndex'},
16 rodolico 314
   localPort => $config->{'localPort'},
14 rodolico 315
   template  => $config->{'template'},
316
   hostname  => $config->{'hostname'}, 
317
);
318
 
319
# get the VPN users. This is a hashref keyed by cert name, value is username
16 rodolico 320
my $vpnCerts = $opnsense->getVpnUsers();
17 rodolico 321
#die Dumper($vpnCerts);
14 rodolico 322
# convert the cert-"user" to username->certs, array ref of certs as not sure if multiple certs per user allowed
323
foreach my $cert ( keys %$vpnCerts ) {
324
   push @{$users->{$router}->{'users'}->{$vpnCerts->{$cert}}->{'certs'}}, $cert;
325
}
326
 
327
# these are all of the users on the system
16 rodolico 328
my $allUsers = $opnsense->getAllUsers();
14 rodolico 329
 
330
# for each user in the system, if they are in the vpnUsers list, copy otp_seed, cert, and password
331
# we'll also delete any users who are disabled
332
my @keys = ('otp_seed', 'cert', 'password' ); # these are the keys we want to copy
333
foreach my $user ( keys %$allUsers ) {
334
   next unless exists $users->{$router}->{'users'}->{$user}; # skip users who do not have vpn certs
335
   if ( $allUsers->{$user}->{'disabled'} ) { # skip users who are disabled
336
      delete $users->{$router}->{'users'}->{$user};
337
      next;
338
   }
339
   foreach my $key ( @keys ) {
340
      $users->{$router}->{'users'}->{$user}->{$key} = ref($allUsers->{$user}->{$key}) ? '' : $allUsers->{$user}->{$key};
341
   }
342
}
343
 
344
# create the QR code file and VPN configuration for each user
345
foreach my $entry ( keys %{$users->{$router}->{'users'}} ) {
346
   my $account = $entry;
16 rodolico 347
   my $secret = $users->{$router}->{'users'}->{$entry}->{'otp_seed'} || '';
348
   # Create the QR code file and store the filename in the data structure
349
   # If there is no secret, do not create a QR code
14 rodolico 350
   $users->{$router}->{'users'}->{$entry}->{'qrFile'} = 
16 rodolico 351
      $secret && $secret ne '' ?
352
      &makeQR(
353
            $router,
354
            $account,
355
            $secret,
356
            $moduleSize,
357
            $users->{$router}->{'qrLocation'},
358
            $users->{$router}->{'qrLocationFileystem'}
359
            ) :
360
      '';
361
 
20 rodolico 362
   # Create the VPN configuration file(s), if they exist. If multiple certs, only use the first one
16 rodolico 363
   # warn user if there are multiple certs
14 rodolico 364
   if ( scalar(@{$users->{$router}->{'users'}->{$entry}->{'certs'}}) > 1 ) {
365
      warn "$entry has multiple certs, using first one only\n";
366
   }
16 rodolico 367
   my $cert = $opnsense->getVpnConfig( $users->{$router}->{'users'}->{$entry}->{'certs'}->[0] );
14 rodolico 368
   if ( !$cert || ref($cert) ne 'HASH' || !exists $cert->{'content'} ) {
369
      warn "Could not get VPN configuration for $entry, cert $users->{$router}->{'users'}->{$entry}->{'certs'}->[0]\n";
370
      next;
371
   }
20 rodolico 372
 
373
   # Process formats to create multiple OVPN files if formats are defined
374
   my @ovpnFiles = ();
375
   my $formats = $config->{'formats'};
376
   # die Dumper($config) . "\n";
377
   # die Dumper($formats) . "\n";
378
   if ( $formats && ref($formats) eq 'HASH' && keys %$formats ) {
379
      # Iterate through each format
21 rodolico 380
      $cert->{'content'} = decode_base64( $cert->{'content'} ); # decode once for all formats
20 rodolico 381
      foreach my $formatName ( keys %$formats ) {
382
         my $format = $formats->{$formatName};
383
         next unless ref($format) eq 'HASH';
384
         my $filename = $format->{'filename'} || '';
385
         my $additionalStrings = $format->{'additionalStrings'} || '';
386
         next if $filename eq '';
387
         my $ovpnFile = &makeVPNConfigFile(
388
            $cert,
389
            $router,
390
            $entry,
391
            $users->{$router}->{'ovpnLocation'},
392
            $users->{$router}->{'ovpnLocationFileSystem'},
393
            $filename,
394
            $additionalStrings
395
         );
396
         push @ovpnFiles, $ovpnFile if $ovpnFile;
397
      }
398
   }
399
 
400
   # If no formats defined or no files created, create a default file
401
   if ( scalar(@ovpnFiles) == 0 ) {
402
      my $defaultFilename = $router . '_' . $entry . '.ovpn';
403
      my $ovpnFile = &makeVPNConfigFile(
16 rodolico 404
         $cert,
405
         $router,
406
         $entry,
407
         $users->{$router}->{'ovpnLocation'},
408
         $users->{$router}->{'ovpnLocationFileSystem'},
20 rodolico 409
         $defaultFilename,
410
         "static-challenge 'Enter Auth Code' 0\nauth-nocache"
411
      );
412
      push @ovpnFiles, $ovpnFile if $ovpnFile;
413
   }
414
 
415
   # Store as array if multiple files, or single string if only one
416
   $users->{$router}->{'users'}->{$entry}->{'ovpnFile'} = 
417
      scalar(@ovpnFiles) > 1 ? \@ovpnFiles : $ovpnFiles[0];
14 rodolico 418
}
16 rodolico 419
# update the timestamp for the current router
420
$users->{$router}->{'lastUpdate'} = time;
421
# save the users file
422
&saveUsers( $usersFile, $users );
14 rodolico 423
 
16 rodolico 424
# Remove the lock file so the script can run again
425
close($fh);
426
unlink($lockFile) or warn "Could not unlink lock file: $!";
427
 
428
1;