Subversion Repositories web_pages

Rev

Blame | Last modification | View Log | Download | RSS feed

#! /usr/bin/env perl

#    Copyright (c) 2025, Daily Data, Inc.
#
#    Redistribution and use in source and binary forms, with or without modification, 
#    are permitted provided that the following conditions are met:
#
#        Redistributions of source code must retain the above copyright notice, this
#          list of conditions and the following disclaimer.
#        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.
#
#    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 OWNER 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 will read configuration file exported from opnSense 
# (System | Configuration | Backups | Download Configuration)
# and parse the users configuration. It will generate a png image of the QR code for the otp key,
# saving it on disk as routername_username.png, and store store the 
# routername/username/password/otp code and qr file name to a tab separated file named users.csv 
# ($csvFile hard coded below)
# if processing a router which already exists, will clean up first by removing all existing entries
# and qr files for that router.
#
# requires modules XML::Simple GD::Barcode::QRcode, which can be installed in Debian derivatives
# with 
# apt install libxml-simple-perl libgd-barcode-perl
# or via cpan with
# cpan XML::Simple GD::Barcode::QRcode
#
# Tested with
#    OPNSense 25.1.12
#    Microsoft Authenticator 6.2509.5952
#    FreeOTP v2.0.5
#
# Version 1.0.0  RWR 2025-09-21
#    initial release

use strict;
use warnings;
use XML::Simple;
use GD::Barcode::QRcode;

our $VERSION = '1.0.0';

my $csvFile = 'users.csv';

# 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;

#
# 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 ) = @_;
   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 = ($routerName ? $routerName . '_' : '' ) . $account . '.png';
   open my $out, '>', "$outputFile" or die "Cannot write file $outputFile: $!";
   binmode $out;
   print $out $image->png;
   close $out;
   return $outputFile;
}

# loads the CSV file into an array, removing any entry for $routerName and 
# removing all associated image files
sub loadCSV {
   my ( $filename, $routerName) = @_;
   my @data;
   if ( -e $filename ) { # if the file exists
      open DATA, "<$filename" or die "Could not read $filename: $!\n";
      my $line = <DATA>; # first line is the header
      chomp $line;
      push @data, $line; # preserve header
      while ( $line = <DATA> ) {
         chomp $line;
         # break apart so we can process fast
         my ($router, $name, $otp, $password, $filename ) = split( "\t", $line );
         # if this is the router we are working on, remove the filename associated
         if ( $router eq $routerName ) {
            if ( -e $filename ) {
               unlink( $filename );
            } else {
               warn "Can not unlink $filename, not found\n";
            }
         } else { # nope, not the current router, so just preserve the line
            push @data, $line;
         }
      }
   } else { # file doesn't exist, so just create empty list with header
      warn "$filename does not exist, creating\n";
      push @data, "router\tname\totp\tpassword\tfilename";
   }
   return \@data;
}


# Load the XML file into 
my $xmlFileName = shift;
my $routerName = shift;
die "Usage: $0 configfilename [routername]\n" unless $xmlFileName && $routerName;
#warn "Not using separate router names\n" unless $routerName;
my $xml = XML::Simple->new();

# Parse the XML file into a hash
my $data = $xml->XMLin($xmlFileName, ForceArray => 0, KeyAttr => []);
# we only want the user data, which is an array ref under system->user
my $users = $data->{'system'}->{'user'};

# if there is a router name, prepend to the csv file name
# $csvFile = $routerName . '_' . $csvFile if $routerName;

my $output = &loadCSV( $csvFile, $routerName );

# process each element in the array. Each array element is a hashref containing the information we need
foreach my $user ( @$users ) {
   if ( ref( $user->{'otp_seed'} ) ) { # otp_seed is a ref to an empty hash if no key has been defined
      # that is not an error, as some accounts do not have access
      warn "$user->{name} has no otp seed\n";
   } else { # this is a user with an otp seed
      # create the QR File and get the name of it into $filename
      my $filename = makeQR( $routerName, $user->{'name'}, $user->{'otp_seed'}, $issuer, $moduleSize );
      # add a row to the CSV
      push @$output,  join( "\t", ( $routerName, $user->{'name'}, $user->{'otp_seed'}, $user->{'password'}, $filename ) );
   }
}
# store data in a tab separated value file
open USERS,">$csvFile" or die "Could not create $csvFile: $!\n";
# put in a header so we can find things easily
print USERS join( "\n", @$output );
close USERS;

#use Data::Dumper;
# print Dumper( $users );

Generated by GNU Enscript 1.6.5.90.