Rev 16 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download | RSS feed
#! /usr/bin/env perl
use strict;
use warnings;
use lib '.'; # our libraries are in the current directory
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
# Configuration file path
my $config_file = 'config.json';
# Users file path. This is shared between loadOpnSense.pl and index.php
my $users_file = 'users.json';
# Constants for creation of QR image
# this is used in URL created
my $issuer = "OPNsense";
# The size of individual "pixels" in graphics
my $moduleSize = 10;
# location to place the QR images
my $qrLocation = './qrcodes'; # cwd/qrcodes
# location to place the ovpn files
my $ovpnFileLocation = './openvpn_configs'; # cwd/openvpn
# following strings will be added to ovpn files if they don't exist
my $additionalOvpnStrings = "static-challenge 'Enter Auth Code' 0\nauth-nocache\n";
# load the configuration for the specified router from the config file
# returns a hash ref of the configuration for the specified entry (router name)
# if file does not exist or entry not found, returns empty hash ref
# file - name of file to read (default config.json if not specified)
# entry - name of the router to load configuration for
sub loadConfig {
my ($file, $entry) = @_;
if ( -e $file ) {
open my $fh, '<', $file or die "Could not open '$file' $!";
local $/;
my $json_text = <$fh>;
close $fh;
my $config = decode_json($json_text);
return $config->{$entry} if (exists $config->{$entry});
}
return {};
}
# load the users data structure from the specified file
sub loadUsers {
my ( $file ) = @_;
my $data = {};
if ( -e $file ) {
open( my $fh, '<', $file ) or die "Cannot open $file: $!";
local $/; # slurp mode
my $json_text = <$fh>;
close $fh;
$data = decode_json($json_text);
}
return $data;
}
# save the users data structure to the specified file
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 file with a name of ($routername_)$account.png
# where $routername is prepended if it is set and $account is the user name
# an underscore separates the routername and the account (if routername is set)
# the contents of the qr are made up of the constants 'otpauth://totp/' followed by $issuer
# and the parameters secret (otp code) and issuer again.
sub makeQR {
my ( $routerName, $account, $secret, $issuer, $moduleSize, $qrLocation ) = @_;
my $otp_url = "otpauth://totp/$issuer:$account?secret=$secret&issuer=$issuer";
# Ecc (Error Correction Code) can be one of Low, Medium, Quartile, and High
# ModuleSize increases the actual size of each "pixel", enlarging for easier reading
my $barcode = GD::Barcode::QRcode->new($otp_url, { Ecc => 'M', ModuleSize => $moduleSize } );
my $image = $barcode->plot();
# Save the image to a file. If there is a router name, prepend it to the filename with an underscore
# allows the same user to be on two different routers
my $outputFile = $qrLocation . '/' . ($routerName ? $routerName . '_' : '' ) . $account . '.png';
open my $out, '>', "$outputFile" or die "Cannot write file $outputFile: $!";
binmode $out;
print $out $image->png;
close $out;
return $outputFile;
}
#
# makeVPNConfigFile
#
# Creates a configuration file for the user based on the template provided
# The template is read in and the following substitutions are made:
sub makeVPNConfigFile {
my ( $contents, $routername, $username, $ovpnLocation, $additionalOvpnStrings ) = @_;
# if contents is empty or does not have a content key, return empty string
return '' unless $contents && $contents->{'content'};
my $outputFileName = $ovpnLocation . '/' . ($routername ? $routername . '_' : '' ) . $username . '.ovpn';
$contents->{'content'} = decode_base64($contents->{'content'});
$contents->{'content'} .= "\n";
$contents->{'content'} =~ s/(\r?\n)+/\n/g; # normalize line endings
# add any additional strings if they are not already present
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
}
}
open my $out, '>', "$outputFileName" or die "Cannot write file $outputFileName: $!";
print $out $contents->{'content'};
close $out;
return $outputFileName;
}
### Main program
# 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($config_file, $router );
die "Configuration for router $router not found in $config_file\n" unless $config && ref($config) eq 'HASH';
# load the users file. We will update the entry for this router
my $users = &loadUsers( $users_file );
# 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
# ensure the directories exist and give them full access
make_path( $users->{$router}->{'qrLocation'}, 0777 ) unless -d $users->{$router}->{'qrLocation'};
make_path( $users->{$router}->{'ovpnLocation'}, 0777 ) unless -d $users->{$router}->{'ovpnLocation'};
# this does most of the work, all the API calls are handled in the module
# create the opnsense object
my $opnsense = new opnsense(
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->get_vpn_users();
# 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->get_all_users();
# 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'};
$users->{$router}->{'users'}->{$entry}->{'qrFile'} =
&makeQR($router, $account, $secret, $issuer, $moduleSize, $users->{$router}->{'qrLocation'});
if ( scalar(@{$users->{$router}->{'users'}->{$entry}->{'certs'}}) > 1 ) {
warn "$entry has multiple certs, using first one only\n";
}
my $cert = $opnsense->get_vpn_config( $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'}, $additionalOvpnStrings );
}
# save the users file
&saveUsers( $users_file, $users );