#! /usr/bin/env perl # Copyright (c) 2025, Daily Data, Inc. # All rights reserved. # # Redistribution and use in source and binary forms, with or without modification, # are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, this list # of conditions and the following disclaimer. # 2. 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. # 3. Neither the name of Daily Data, Inc. nor the names of its contributors may be # used to endorse or promote products derived from this software without specific # prior written permission. # # 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 HOLDER 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. # # This script defines the routers that can be managed by loadOpnSense.pl # It allows adding, editing, and removing router configurations stored in a JSON file. # # Change History: # v1.0.0 2025-10-01 RWR # Initial version # v1.1.0 2026-01-04 RWR # Added formats key to router configuration # Added editFormats subroutine to manage formats hash structure # Formats contain arrays of hashes with filename and additionalStrings keys use strict; use warnings; # use libraries from the directory this script is in BEGIN { use FindBin; use File::Spec; # use libraries from the directory this script is in use Cwd 'abs_path'; use File::Basename; use lib dirname( abs_path( __FILE__ ) ); } use JSON qw( decode_json encode_json ); use Data::Dumper; use opnsense; our $VERSION = "1.1.0"; # Default config file name if not specified on command line. Defaults to 'routers.json' in the script directory my $configFileName = $FindBin::RealBin . '/routers.json'; # fields used in the router configuration my @fields = ( 'url', 'apiKey', 'apiSecret', 'ovpnIndex', 'template', 'hostname', 'localPort', 'downloadToken', 'formats' ); # Check if running as root if ($> != 0) { die "Error: This script must be run as root.\n"; } #----------------------------- # usage: Print usage message #----------------------------- sub usage { print "Usage: $0 \n"; exit 1; } #----------------------------- # readConfig: Reads in the configuration file and returns a hash ref of the data # If file does not exist, returns empty hash ref # file - name of file to read (default $configFileName) #----------------------------- sub readConfig { my ($file) = @_; my $data = {}; if (-e $file) { open(my $fh, '<', $file) or die "Cannot open $file: $!"; local $/; # slurp mode my $jsonText = <$fh>; close $fh; $data = decode_json($jsonText); } return $data; } #----------------------------- # writeConfig: Writes the given data to the specified file in JSON format # file - name of file to write (default $configFileName) # data - reference to the data to write # permissions on the file are set to 0600 and ownership to root # since it contains sensitive information #----------------------------- sub writeConfig { my ( $file, $data ) = @_; open( my $fh, '>', $file ) or die "Cannot open $file: $!"; print $fh encode_json($data); close $fh; chmod 0600, $file or warn "Could not set permissions on $file: $!"; chown 0, 0, $file or warn "Could not set ownership to root on $file: $!"; } #----------------------------- # trim: Remove leading and trailing whitespace from a string # s - the string to trim #----------------------------- sub trim { my ($s) = @_; $s =~ s/^\s+|\s+$//g if defined $s; return $s; } #----------------------------- # menuSelect: Display a menu of options and prompt the user to select one # prompt - the prompt to display to the user # options - reference to an array of option strings # additionalText - reference to an array of additional text strings to display next to each option # returns the selected option string (1-based index) #----------------------------- sub menuSelect { my ( $prompt, $options, $additionalText ) = @_; print "$prompt\n"; for my $i ( 0 .. $#$options ) { print " " . ($i+1) . ". " . $options->[$i] . ($additionalText->[$i] ? $additionalText->[$i] : "") . "\n"; } print "Select option (1-" . ($#$options + 1) . "): "; my $sel = ; chomp $sel; if ( $sel !~ /^\d+$/ || $sel < 1 || $sel > ($#$options + 1) ) { warn "Invalid selection\n"; return menuSelect( $prompt, $options, $additionalText ); } return $options->[$sel - 1]; } #----------------------------- # selectOvpnIndex: Prompts the user to select an ovpnIndex from the list # in the OPNsense router specified in the config hash # config - reference to the router's configuration hash # returns the selected ovpnIndex string or undef if none selected #----------------------------- sub selectOvpnIndex { my ( $config ) = @_; if ( defined $config->{'url'} && defined $config->{'apiKey'} && defined $config->{'apiSecret'} ) { my $opnsense = new opnsense( url => $config->{'url'}, apiKey => $config->{'apiKey'}, apiSecret => $config->{'apiSecret'}, ); my $providers = $opnsense->getVpnProviders(); if ( ref($providers) eq 'HASH' && keys %$providers ) { my @providerIndex = sort keys %$providers; my @additionalText = map { defined $config->{'ovpnIndex'} && ($_ eq $config->{'ovpnIndex'} ? " (current) " : " - ") . $providers->{$_} } @providerIndex; my $selectedProvider = &menuSelect( "Select VPN Provider", \@providerIndex, \@additionalText ); return $selectedProvider; } else { warn "No VPN providers found or error retrieving providers. Please check your connection details.\n"; } } else { warn "Please set url, apiKey, and apiSecret before selecting ovpnIndex.\n"; } return undef; } #----------------------------- # editFormats: Edit the formats hash for a router # formats - reference to the router's formats hash # Each format is a hash key pointing to a hashref with 'filename' and 'additionalStrings' keys # returns the updated formats hash reference #----------------------------- sub editFormats { my ( $formats ) = @_; $formats = {} unless ref($formats) eq 'HASH'; my $option = ''; while ( $option ne 'Done' ) { my @formatNames = sort keys %$formats; print "\nCurrent formats:\n"; if ( @formatNames ) { for my $name ( @formatNames ) { my $format = $formats->{$name}; my $fname = ref($format) eq 'HASH' ? ($format->{'filename'} || '(no filename)') : '(invalid)'; my $addStr = ref($format) eq 'HASH' ? ($format->{'additionalStrings'} || '') : ''; print " $name: $fname" . ($addStr ? " [strings: $addStr]" : "") . "\n"; } } else { print " (no formats defined)\n"; } $option = &menuSelect( "Manage formats", [ @formatNames, 'Add new format', 'Done' ] ); if ( $option eq 'Add new format' ) { print "Enter format name: "; my $formatName = ; chomp $formatName; $formatName = trim($formatName); if ( $formatName ne '' ) { $formats->{$formatName} = {} unless exists $formats->{$formatName}; print "Format '$formatName' created\n"; } } elsif ( $option ne 'Done' ) { # Edit existing format my $formatName = $option; my $format = $formats->{$formatName}; $format = {} unless ref($format) eq 'HASH'; my $action = ''; while ( $action ne 'Back' ) { print "\nFormat '$formatName':\n"; print " filename: " . ($format->{'filename'} || '(not set)') . "\n"; print " additionalStrings: " . ($format->{'additionalStrings'} || '(not set)') . "\n"; $action = &menuSelect( "Edit format '$formatName'", [ 'Change filename', 'Change additionalStrings', 'Delete format', 'Back' ] ); if ( $action eq 'Change filename' ) { print "Enter new filename (ROUTER and USER are replaced with actual values): "; my $filename = ; chomp $filename; $format->{'filename'} = trim($filename); } elsif ( $action eq 'Change additionalStrings' ) { print "Enter new additionalStrings (\\n separated): "; my $addStr = ; chomp $addStr; $format->{'additionalStrings'} = trim($addStr); } elsif ( $action eq 'Delete format' ) { my $confirm = &menuSelect( "Are you sure you want to delete format '$formatName'?", [ 'No', 'Yes' ] ); if ( $confirm eq 'Yes' ) { delete $formats->{$formatName}; print "Format '$formatName' deleted\n"; last; } } } $formats->{$formatName} = $format unless $action eq 'Delete format'; } } return $formats; } #----------------------------- # editRouter: Edit the configuration for a router # config - reference to the router's configuration hash # routerName - name of the router being edited # keys - reference to an array of keys (fields) that can be edited # works on one router at a time, allows setting fields for that router # returns true if configuration was saved, false if not #----------------------------- sub editRouter { my ( $config, $routerName, $keys ) = @_; my $option = ''; my $saved = 1; my $currentRouter = $config->{$routerName}; $currentRouter->{'template'} = 'PlainOpenVPN' unless defined $currentRouter->{'template'}; my $vpnProviders = (); while ( $option ne 'Done' ) { print "\nEditing router configuration for $routerName" . ($saved ? "" : " (Changed)") . ":\n"; $option = &menuSelect( "Select field to edit", [ @$keys, 'Save Config', 'Done' ], [ map { defined $currentRouter->{$_} ? " (current: $currentRouter->{$_})" : " (not set)" } @$keys, '' ] ); if ( $option eq 'ovpnIndex' ) { $currentRouter->{'ovpnIndex'} = &selectOvpnIndex( $currentRouter ); $saved = 0; # mark as unsaved } elsif ( $option eq 'formats' ) { $currentRouter->{'formats'} = &editFormats( $currentRouter->{'formats'} ); $saved = 0; # mark as unsaved } elsif ( $option eq 'Save Config' ) { writeConfig( $configFileName, $config ); $saved = 1; # mark as saved print "Configuration saved to $configFileName\n"; } elsif ($option eq 'Done') { $option = &menuSelect( "Exiting configuration editor, but changes were not saved.", [ 'Exit', 'Save and Exit' ] ); if ( $option eq 'Save and Exit' ) { writeConfig( $configFileName, $config ); $saved = 1; # mark as saved print "Configuration saved to $configFileName\n"; } last; } else { $saved = 0; # mark as unsaved print "Enter value for $option: "; my $value = ; chomp $value; $value = trim($value); $currentRouter->{$option} = $value if $value ne ''; } } return $saved; } $configFileName = shift if @ARGV; # read the configuration file, if it exists, and place hash in $config my $config = &readConfig( $configFileName ); # display menu of existing routers plus option to add new router my $routerName = &menuSelect( "Select router to configure", [ keys %$config, 'Add new router' ] ); # adding a new router, so get the name, then create an empty hashref for it if ( $routerName eq 'Add new router' ) { print "Enter new router name: "; $routerName = ; chomp $routerName; $routerName = trim($routerName); $config->{$routerName} = {}; } # edit the router configuration &editRouter( $config, $routerName, \@fields ); 1;