| 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)
|
|
|
42 |
|
| 14 |
rodolico |
43 |
use strict;
|
|
|
44 |
use warnings;
|
|
|
45 |
|
| 16 |
rodolico |
46 |
# use libraries from the directory this script is in
|
|
|
47 |
BEGIN {
|
|
|
48 |
use FindBin;
|
|
|
49 |
use File::Spec;
|
|
|
50 |
# use libraries from the directory this script is in
|
|
|
51 |
use Cwd 'abs_path';
|
|
|
52 |
use File::Basename;
|
|
|
53 |
use lib dirname( abs_path( __FILE__ ) );
|
|
|
54 |
}
|
| 14 |
rodolico |
55 |
|
| 16 |
rodolico |
56 |
|
| 14 |
rodolico |
57 |
use JSON; # for encode_json, decode_json
|
|
|
58 |
use Data::Dumper; # for debugging
|
|
|
59 |
use opnsense; # our module to handle opnsense API calls
|
|
|
60 |
use GD::Barcode::QRcode; # for creating the QR code images
|
|
|
61 |
use MIME::Base64 qw( decode_base64 ); # for decoding the ovpn file returned from the API
|
|
|
62 |
use File::Path qw( make_path ); # for creating directories
|
| 16 |
rodolico |
63 |
use Fcntl qw(:flock); # Import locking constants
|
|
|
64 |
|
|
|
65 |
our $VERSION = '1.0.1';
|
|
|
66 |
|
|
|
67 |
# Check if running as root
|
|
|
68 |
if ($> != 0) {
|
|
|
69 |
die "Error: This script must be run as root.\n";
|
|
|
70 |
}
|
|
|
71 |
|
|
|
72 |
#-----------------------------
|
|
|
73 |
# Configuration Variables
|
|
|
74 |
#-----------------------------
|
|
|
75 |
# Location of the configuration files and directories where we will store data
|
|
|
76 |
# relative to the script directory as written
|
|
|
77 |
my $scriptDir = $FindBin::RealBin;
|
|
|
78 |
my $configFile = $scriptDir . '/routers.json';
|
|
|
79 |
my $usersFile = $scriptDir . '/users.json';
|
|
|
80 |
my $qrLocation = './qrcodes';
|
|
|
81 |
my $ovpnFileLocation = './openvpn_configs';
|
|
|
82 |
# size of the QR code modules, basically a magnification factor
|
| 14 |
rodolico |
83 |
my $moduleSize = 10;
|
| 16 |
rodolico |
84 |
# strings to add to the ovpn file if not already present
|
| 14 |
rodolico |
85 |
my $additionalOvpnStrings = "static-challenge 'Enter Auth Code' 0\nauth-nocache\n";
|
| 16 |
rodolico |
86 |
# using a lock file to ensure script does not run while another request has it running
|
|
|
87 |
my $lockFile = '/tmp/opnsense.lock';
|
|
|
88 |
my $lockRetryTime = 10; # number of seconds between subsequent tries for a lock
|
|
|
89 |
my $lockRetries = 6; # number of times we will attempt to get a lock before failing
|
| 14 |
rodolico |
90 |
|
| 16 |
rodolico |
91 |
#-----------------------------
|
|
|
92 |
# slurpFile: Reads in the entire contents of a file and returns it as a string
|
|
|
93 |
# $file - name of file to read
|
|
|
94 |
# returns the contents of the file as a string, or empty string if file does not exist
|
|
|
95 |
# since we do this a lot, make it a separate function. This is likely the most efficient way
|
|
|
96 |
# to read a file in Perl
|
|
|
97 |
#-----------------------------
|
|
|
98 |
sub slurpFile {
|
|
|
99 |
my ($file) = @_;
|
|
|
100 |
my $data = '';
|
|
|
101 |
if (-e $file) {
|
|
|
102 |
open(my $fh, '<', $file) or die "Cannot open $file: $!";
|
| 14 |
rodolico |
103 |
local $/; # slurp mode
|
| 16 |
rodolico |
104 |
$data = <$fh>;
|
| 14 |
rodolico |
105 |
close $fh;
|
|
|
106 |
}
|
|
|
107 |
return $data;
|
|
|
108 |
}
|
|
|
109 |
|
| 16 |
rodolico |
110 |
#-----------------------------
|
|
|
111 |
# loadConfig: Load router configuration from config file
|
|
|
112 |
# $file - name of file to read
|
|
|
113 |
# $entry - name of the router entry to load
|
|
|
114 |
# returns a reference to the data structure for the router, or empty hash if not found
|
|
|
115 |
#-----------------------------
|
|
|
116 |
sub loadConfig {
|
|
|
117 |
my ($file, $entry) = @_;
|
|
|
118 |
my $return = &slurpFile($file);
|
|
|
119 |
return {} unless $return && $return ne '';
|
|
|
120 |
my $data = decode_json($return);
|
|
|
121 |
return $data->{$entry} if (exists $data->{$entry});
|
|
|
122 |
return {};
|
|
|
123 |
}
|
|
|
124 |
|
|
|
125 |
#-----------------------------
|
|
|
126 |
# loadUsers: Load users data structure from file
|
|
|
127 |
# $file - name of file to read
|
|
|
128 |
# returns a reference to the data structure
|
|
|
129 |
# $file assumed to be in JSON format
|
|
|
130 |
#-----------------------------
|
|
|
131 |
sub loadUsers {
|
|
|
132 |
my ($file) = @_;
|
|
|
133 |
my $data = &slurpFile($file);
|
|
|
134 |
return {} unless $data && $data ne '';
|
|
|
135 |
my $return = decode_json($data);
|
|
|
136 |
return {} unless $return && ref($return) eq 'HASH';
|
|
|
137 |
return $return;
|
|
|
138 |
}
|
|
|
139 |
|
|
|
140 |
#-----------------------------
|
|
|
141 |
# saveUsers: Save users data structure to file
|
|
|
142 |
# $file - name of file to write
|
|
|
143 |
# $data - reference to the data to write
|
|
|
144 |
# $timeStampFile - optional name of a file to write the current timestamp to
|
|
|
145 |
# writes the data in JSON format
|
|
|
146 |
#-----------------------------
|
| 14 |
rodolico |
147 |
sub saveUsers {
|
| 16 |
rodolico |
148 |
my ($file, $data) = @_;
|
|
|
149 |
open(my $fh, '>', $file) or die "Cannot open $file: $!";
|
| 14 |
rodolico |
150 |
print $fh encode_json($data);
|
|
|
151 |
close $fh;
|
|
|
152 |
}
|
|
|
153 |
|
| 16 |
rodolico |
154 |
#-----------------------------
|
|
|
155 |
# makeQR: Creates a QR code file
|
|
|
156 |
# $routerName - name of the router (used in filename and OTP URL)
|
|
|
157 |
# $account - account name (used in filename and OTP URL)
|
|
|
158 |
# $secret - OTP secret
|
|
|
159 |
# $moduleSize - size of the QR code modules
|
|
|
160 |
# $relativeDir - directory to save the QR code file
|
|
|
161 |
# $absDir - The directory the script is in, so writing relative directories will correctly place
|
|
|
162 |
# returns the filename of the created QR code image
|
|
|
163 |
#-----------------------------
|
| 14 |
rodolico |
164 |
sub makeQR {
|
| 16 |
rodolico |
165 |
# Creates a QR code image for the user's OTP secret
|
|
|
166 |
my ($routerName, $account, $secret, $moduleSize, $relativeDir, $absDir ) = @_;
|
|
|
167 |
# build the OTP URL to include router name as issuer and the account name for display in authenticator apps
|
|
|
168 |
my $otpUrl = "otpauth://totp/$routerName:$account?secret=$secret&issuer=$routerName";
|
|
|
169 |
# generate the QR code
|
|
|
170 |
my $barcode = GD::Barcode::QRcode->new($otpUrl, { Ecc => 'M', ModuleSize => $moduleSize } );
|
| 14 |
rodolico |
171 |
my $image = $barcode->plot();
|
| 16 |
rodolico |
172 |
my $fileName = ($routerName ? $routerName . '_' : '' ) . $account . '.png';
|
|
|
173 |
# This allows us to retain the relative file path for storage, but ensuring we write the file to
|
|
|
174 |
# the correct path even if we are not running the script from the script directory.
|
|
|
175 |
open my $out, '>', "$absDir/" . $fileName or die "Cannot write file $fileName to $absDir: $!";
|
| 14 |
rodolico |
176 |
binmode $out;
|
|
|
177 |
print $out $image->png;
|
|
|
178 |
close $out;
|
| 16 |
rodolico |
179 |
return $relativeDir . '/' . $fileName;
|
| 14 |
rodolico |
180 |
}
|
|
|
181 |
|
| 16 |
rodolico |
182 |
#-----------------------------
|
|
|
183 |
# makeVPNConfigFile: Creates a VPN configuration file for the user
|
|
|
184 |
# $contents - reference to the hash containing the 'content' key with base64 encoded ovpn file
|
|
|
185 |
# $routerName - name of the router (used in filename)
|
|
|
186 |
# $userName - name of the user (used in filename)
|
|
|
187 |
# $ovpnLocation - The relative path to the output directory
|
|
|
188 |
# $absDir - The absolute path to the output directory
|
|
|
189 |
# $additionalOvpnStrings - additional strings to add to the ovpn file if not already present
|
|
|
190 |
# returns the filename of the created ovpn file
|
|
|
191 |
#-----------------------------
|
| 14 |
rodolico |
192 |
sub makeVPNConfigFile {
|
| 16 |
rodolico |
193 |
my ($contents, $routerName, $userName, $ovpnLocation, $absDir, $additionalOvpnStrings) = @_;
|
| 14 |
rodolico |
194 |
return '' unless $contents && $contents->{'content'};
|
| 16 |
rodolico |
195 |
my $fileName = ($routerName ? $routerName . '_' : '' ) . $userName . '.ovpn';
|
| 14 |
rodolico |
196 |
$contents->{'content'} = decode_base64($contents->{'content'});
|
|
|
197 |
$contents->{'content'} .= "\n";
|
|
|
198 |
$contents->{'content'} =~ s/(\r?\n)+/\n/g; # normalize line endings
|
|
|
199 |
my $comment = '';
|
|
|
200 |
if ( $contents->{'content'} !~ /# Added by loadOpnSense.pl/ ) {
|
|
|
201 |
$comment = "# Added by loadOpnSense.pl\n";
|
|
|
202 |
}
|
|
|
203 |
foreach my $string ( split( /\n/, $additionalOvpnStrings ) ) {
|
|
|
204 |
unless ( $contents->{'content'} =~ /\$string/ ) {
|
|
|
205 |
$contents->{'content'} .= "$comment$string\n";
|
|
|
206 |
$comment = ''; # only add comment once
|
|
|
207 |
}
|
|
|
208 |
}
|
| 16 |
rodolico |
209 |
# This allows us to retain the relative file path for storage, but ensuring we write the file to
|
|
|
210 |
# the correct path even if we are not running the script from the script directory.
|
|
|
211 |
open my $out, '>', "$absDir/" . $fileName or die "Cannot write file $fileName to $absDir: $!";
|
| 14 |
rodolico |
212 |
print $out $contents->{'content'};
|
|
|
213 |
close $out;
|
| 16 |
rodolico |
214 |
return $ovpnLocation . '/' . $fileName;
|
| 14 |
rodolico |
215 |
}
|
|
|
216 |
|
| 16 |
rodolico |
217 |
# cleanOldDataFiles: Deletes old data files in the specified directory matching the given pattern
|
|
|
218 |
# $directory - directory to scan for old files
|
|
|
219 |
# $pattern - regex pattern to match files to delete
|
|
|
220 |
# simply removes all files matching the pattern
|
|
|
221 |
#-----------------------------
|
|
|
222 |
sub cleanOldDataFiles {
|
|
|
223 |
my ( $directory, $pattern ) = @_;
|
|
|
224 |
opendir(my $dh, $directory) or die "Cannot open directory $directory: $!";
|
|
|
225 |
while (my $file = readdir($dh)) {
|
|
|
226 |
next if ($file =~ /^\./);
|
|
|
227 |
if ($file =~ /$pattern/) {
|
|
|
228 |
unlink "$directory/$file" or warn "Could not delete $directory/$file: $!";
|
|
|
229 |
}
|
|
|
230 |
}
|
|
|
231 |
closedir($dh);
|
|
|
232 |
}
|
| 14 |
rodolico |
233 |
|
|
|
234 |
|
| 16 |
rodolico |
235 |
#-----------------------------
|
|
|
236 |
# Main program
|
|
|
237 |
#-----------------------------
|
|
|
238 |
|
|
|
239 |
# make sure script does not run more than once at the same time
|
|
|
240 |
# Open the lock file
|
|
|
241 |
open(my $fh, '>', $lockFile) or die "Cannot open lock file $lockFile: $!";
|
|
|
242 |
# Attempt to acquire an exclusive lock. We will try $lockTimeOut times, each
|
|
|
243 |
# waiting $lockRetryTime
|
|
|
244 |
while ( $lockRetries-- ) {
|
|
|
245 |
if (flock($fh, LOCK_EX | LOCK_NB)) {
|
|
|
246 |
last;
|
|
|
247 |
} else {
|
|
|
248 |
sleep($lockRetryTime);
|
|
|
249 |
}
|
|
|
250 |
}
|
|
|
251 |
# if $lockTimeOut is zero, we could not get a lock in, so die
|
|
|
252 |
die "Another instance of the script is already running and retries exceeded!\n" unless ( $lockRetries );
|
|
|
253 |
|
| 14 |
rodolico |
254 |
# get the router name from the command line
|
|
|
255 |
my $router = shift @ARGV or die "Usage: $0 <router_name>\n";
|
|
|
256 |
# Load existing configuration or initialize a new one
|
| 16 |
rodolico |
257 |
my $config = &loadConfig($configFile, $router );
|
|
|
258 |
die "Configuration for router $router not found in $configFile\n" unless $config && ref($config) eq 'HASH';
|
| 14 |
rodolico |
259 |
|
|
|
260 |
# load the users file. We will update the entry for this router
|
| 16 |
rodolico |
261 |
my $users = &loadUsers( $usersFile );
|
| 14 |
rodolico |
262 |
|
|
|
263 |
# if there is no entry for this router, create an empty one
|
|
|
264 |
$users->{$router} = {} unless exists $users->{$router};
|
|
|
265 |
$users->{$router}->{'qrLocation'} = $qrLocation unless exists $users->{$router}->{'qrLocation'};
|
|
|
266 |
$users->{$router}->{'ovpnLocation'} = $ovpnFileLocation unless exists $users->{$router}->{'ovpnLocation'};
|
|
|
267 |
$users->{$router}->{'users'} = {}; # clean out the users list, we will reload it
|
| 16 |
rodolico |
268 |
# and get the location on the file system in case we are not running the script from the script directory
|
|
|
269 |
$users->{$router}->{'qrLocationFileystem'} = abs_path( $scriptDir . '/' . $qrLocation )
|
|
|
270 |
unless exists $users->{$router}->{'qrLocationFileystem'};
|
|
|
271 |
$users->{$router}->{'ovpnLocationFileSystem'} = abs_path( $scriptDir . '/' . $ovpnFileLocation )
|
|
|
272 |
unless exists $users->{$router}->{'ovpnLocationFileSystem'};
|
| 14 |
rodolico |
273 |
|
| 16 |
rodolico |
274 |
|
| 14 |
rodolico |
275 |
# ensure the directories exist and give them full access
|
| 16 |
rodolico |
276 |
make_path( $users->{$router}->{'qrLocationFileystem'}, 0777 ) unless -d $users->{$router}->{'qrLocationFileystem'};
|
|
|
277 |
make_path( $users->{$router}->{'ovpnLocationFileSystem'}, 0777 ) unless -d $users->{$router}->{'ovpnLocationFileSystem'};
|
| 14 |
rodolico |
278 |
|
| 16 |
rodolico |
279 |
|
|
|
280 |
|
|
|
281 |
# instead of actually going through and cleaning files no longer in the users list,
|
|
|
282 |
# just delete all files for this router and recreate them
|
|
|
283 |
&cleanOldDataFiles( $users->{$router}->{'qrLocationFileystem'}, qr/^\Q$router\E_.*\.png$/ );
|
|
|
284 |
&cleanOldDataFiles( $users->{$router}->{'ovpnLocationFileSystem'}, qr/^\Q$router\E_.*\.ovpn$/ );
|
|
|
285 |
|
| 14 |
rodolico |
286 |
# this does most of the work, all the API calls are handled in the module
|
|
|
287 |
# create the opnsense object
|
| 16 |
rodolico |
288 |
my $opnsense = opnsense->new(
|
| 14 |
rodolico |
289 |
url => $config->{'url'},
|
|
|
290 |
apiKey => $config->{'apiKey'},
|
|
|
291 |
apiSecret => $config->{'apiSecret'},
|
|
|
292 |
ovpnIndex => $config->{'ovpnIndex'},
|
| 16 |
rodolico |
293 |
localPort => $config->{'localPort'},
|
| 14 |
rodolico |
294 |
template => $config->{'template'},
|
|
|
295 |
hostname => $config->{'hostname'},
|
|
|
296 |
);
|
|
|
297 |
|
|
|
298 |
# get the VPN users. This is a hashref keyed by cert name, value is username
|
| 16 |
rodolico |
299 |
my $vpnCerts = $opnsense->getVpnUsers();
|
| 14 |
rodolico |
300 |
|
|
|
301 |
# convert the cert-"user" to username->certs, array ref of certs as not sure if multiple certs per user allowed
|
|
|
302 |
foreach my $cert ( keys %$vpnCerts ) {
|
|
|
303 |
push @{$users->{$router}->{'users'}->{$vpnCerts->{$cert}}->{'certs'}}, $cert;
|
|
|
304 |
}
|
|
|
305 |
|
|
|
306 |
# these are all of the users on the system
|
| 16 |
rodolico |
307 |
my $allUsers = $opnsense->getAllUsers();
|
| 14 |
rodolico |
308 |
|
|
|
309 |
# for each user in the system, if they are in the vpnUsers list, copy otp_seed, cert, and password
|
|
|
310 |
# we'll also delete any users who are disabled
|
|
|
311 |
my @keys = ('otp_seed', 'cert', 'password' ); # these are the keys we want to copy
|
|
|
312 |
foreach my $user ( keys %$allUsers ) {
|
|
|
313 |
next unless exists $users->{$router}->{'users'}->{$user}; # skip users who do not have vpn certs
|
|
|
314 |
if ( $allUsers->{$user}->{'disabled'} ) { # skip users who are disabled
|
|
|
315 |
delete $users->{$router}->{'users'}->{$user};
|
|
|
316 |
next;
|
|
|
317 |
}
|
|
|
318 |
foreach my $key ( @keys ) {
|
|
|
319 |
$users->{$router}->{'users'}->{$user}->{$key} = ref($allUsers->{$user}->{$key}) ? '' : $allUsers->{$user}->{$key};
|
|
|
320 |
}
|
|
|
321 |
}
|
|
|
322 |
|
|
|
323 |
# create the QR code file and VPN configuration for each user
|
|
|
324 |
foreach my $entry ( keys %{$users->{$router}->{'users'}} ) {
|
|
|
325 |
my $account = $entry;
|
| 16 |
rodolico |
326 |
my $secret = $users->{$router}->{'users'}->{$entry}->{'otp_seed'} || '';
|
|
|
327 |
# Create the QR code file and store the filename in the data structure
|
|
|
328 |
# If there is no secret, do not create a QR code
|
| 14 |
rodolico |
329 |
$users->{$router}->{'users'}->{$entry}->{'qrFile'} =
|
| 16 |
rodolico |
330 |
$secret && $secret ne '' ?
|
|
|
331 |
&makeQR(
|
|
|
332 |
$router,
|
|
|
333 |
$account,
|
|
|
334 |
$secret,
|
|
|
335 |
$moduleSize,
|
|
|
336 |
$users->{$router}->{'qrLocation'},
|
|
|
337 |
$users->{$router}->{'qrLocationFileystem'}
|
|
|
338 |
) :
|
|
|
339 |
'';
|
|
|
340 |
|
|
|
341 |
# Create the VPN configuration file, if it exists. If multiple certs, only create the first one
|
|
|
342 |
# warn user if there are multiple certs
|
| 14 |
rodolico |
343 |
if ( scalar(@{$users->{$router}->{'users'}->{$entry}->{'certs'}}) > 1 ) {
|
|
|
344 |
warn "$entry has multiple certs, using first one only\n";
|
|
|
345 |
}
|
| 16 |
rodolico |
346 |
my $cert = $opnsense->getVpnConfig( $users->{$router}->{'users'}->{$entry}->{'certs'}->[0] );
|
| 14 |
rodolico |
347 |
if ( !$cert || ref($cert) ne 'HASH' || !exists $cert->{'content'} ) {
|
|
|
348 |
warn "Could not get VPN configuration for $entry, cert $users->{$router}->{'users'}->{$entry}->{'certs'}->[0]\n";
|
|
|
349 |
next;
|
|
|
350 |
}
|
|
|
351 |
$users->{$router}->{'users'}->{$entry}->{'ovpnFile'} =
|
| 16 |
rodolico |
352 |
&makeVPNConfigFile(
|
|
|
353 |
$cert,
|
|
|
354 |
$router,
|
|
|
355 |
$entry,
|
|
|
356 |
$users->{$router}->{'ovpnLocation'},
|
|
|
357 |
$users->{$router}->{'ovpnLocationFileSystem'},
|
|
|
358 |
$additionalOvpnStrings );
|
| 14 |
rodolico |
359 |
}
|
| 16 |
rodolico |
360 |
# update the timestamp for the current router
|
|
|
361 |
$users->{$router}->{'lastUpdate'} = time;
|
|
|
362 |
# save the users file
|
|
|
363 |
&saveUsers( $usersFile, $users );
|
| 14 |
rodolico |
364 |
|
| 16 |
rodolico |
365 |
# Remove the lock file so the script can run again
|
|
|
366 |
close($fh);
|
|
|
367 |
unlink($lockFile) or warn "Could not unlink lock file: $!";
|
|
|
368 |
|
|
|
369 |
1;
|