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.