Subversion Repositories web_pages

Rev

Rev 17 | Blame | Compare with Previous | Last modification | View Log | Download | RSS feed

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

=head1 NAME

opnsense - Perl interface for OPNsense Router API

=head1 SYNOPSIS

   use opnsense;
   
   # Create an OPNsense API client
   my $opn = opnsense->new(
      url       => 'https://192.168.1.1',
      apiKey    => 'your-api-key',
      apiSecret => 'your-api-secret',
      ovpnIndex => '1',
      template  => 'PlainOpenVPN',
      hostname  => 'vpn.example.com',
      localPort => '1194'
   );
   
   # Get VPN providers
   my $providers = $opn->getVpnProviders();
   
   # Get VPN user certificates
   my $vpnUsers = $opn->getVpnUsers();
   
   # Get all system users (including TOTP secrets)
   my $allUsers = $opn->getAllUsers();
   
   # Download VPN configuration for a certificate
   my $vpnConfig = $opn->getVpnConfig($certId);

=head1 DESCRIPTION

This module provides an object-oriented interface to the OPNsense router API.
It supports both curl-based and LWP::UserAgent-based HTTP requests with SSL
verification disabled for compatibility with self-signed certificates.

The module is designed to retrieve VPN configurations, user credentials,
TOTP secrets, and manage OpenVPN export functionality.

=head1 CONFIGURATION

   $opnsense::useCurl = 1;  # Use curl (default) or 0 for LWP::UserAgent
   $opnsense::debug = 0;    # Enable debug output (1) or disable (0)

=head1 CHANGE HISTORY

=over 4

=item v1.0.0 2025-10-01 - RWR

Initial version

=item v1.0.1 2025-10-07 - RWR

Fixed bug where script was looking for localport, but was localPort.
Fixed bug where not all users were returned because opnSense (v24.7)
does not send the username, it sends the description. Quick fix is to
use username, then description.

=back

=cut


package opnsense;
use strict;
use warnings;

use LWP::UserAgent;
use JSON;
use Data::Dumper;
use XML::Simple;

our $VERSION = "1.0.0";

our $useCurl = 1; # set to 1 to use curl for API requests, 0 to use LWP
our $debug = 0;    # set to 1 for debug output

=head1 METHODS

=head2 new

   my $opn = opnsense->new(%args);

Constructor. Creates a new OPNsense API client object.

B<Parameters:>

=over 4

=item * url - Base URL of the OPNsense router (e.g., 'https://192.168.1.1')

=item * apiKey - API key from OPNsense (System | Access | Users)

=item * apiSecret - API secret from OPNsense

=item * ovpnIndex - OpenVPN provider index (single digit or hash)

=item * template - OpenVPN template type (typically 'PlainOpenVPN')

=item * hostname - External hostname for VPN endpoint

=item * localPort - Port number for VPN connections

=back

B<Returns:> opnsense object

=cut

#-----------------------------
# new: Constructor
#-----------------------------
sub new {
   my ($class, %args) = @_;
   my $self = {
      baseUrl   => $args{url},
      apiKey    => $args{apiKey},
      apiSecret => $args{apiSecret},
      ovpnIndex => $args{ovpnIndex},
      template  => $args{template},
      hostname  => $args{hostname},
      localPort => $args{localPort},
      ua        => $useCurl ?
         'curl -s --insecure' :
         LWP::UserAgent->new(
            ssl_opts => { verify_hostname => 0, SSL_verify_mode => 0 }
         ),
   };
   bless $self, $class;
   return $self;
}

=head2 apiRequest

   my $response = $opn->apiRequest($endpoint, $method, $data);

Internal method that dispatches API requests to either curl or LWP handler
based on the $opnsense::useCurl setting.

B<Parameters:>

=over 4

=item * endpoint - API endpoint path (e.g., '/api/openvpn/export/providers')

=item * method - HTTP method (GET, POST, PUT, DELETE). Default: GET

=item * data - Optional hashref of data to send with POST/PUT requests

=back

B<Returns:> Decoded JSON response or XML string

=cut

#-----------------------------
# apiRequest: API request dispatcher
#-----------------------------
sub apiRequest {

   if ($useCurl) {
       return apiRequestCurl(@_);
   } else {
       return apiRequestLwp(@_);
   }
}

=head2 apiRequestCurl

   my $response = $self->apiRequestCurl($endpoint, $method, $data);

Internal method that performs API requests using system curl command.
SSL verification is disabled (--insecure flag).

B<Parameters:>

=over 4

=item * endpoint - API endpoint path

=item * method - HTTP method (GET, POST, PUT, DELETE). Default: GET

=item * data - Optional hashref of data to send

=back

B<Returns:> Decoded JSON response or raw XML string

=cut

#-----------------------------
# apiRequestCurl: API request using curl
#-----------------------------
sub apiRequestCurl {
   my ($self, $endpoint, $method, $data) = @_;
   $method ||= 'GET';
   my $url = $self->{baseUrl} . $endpoint;

   my @data = ();
   push @data, '--request', $method;
   push @data, "--header 'Content-Type: application/json'" if ( $method eq 'POST' || $method eq 'PUT' );
   push @data, "--user '$self->{apiKey}:$self->{apiSecret}'";
   push @data, "--data '" . encode_json($data) . "'" if $data;
   my $cmd = join(' ', $self->{ua}, @data, $url);
   die "In apiRequestCurl, command is:\n $cmd" if $debug;
   my $json_text = `$cmd`;
   if ( $json_text =~ /<\?xml/) {
      # returned an XML string, just send it
      return $json_text;
   }
   return decode_json($json_text);
}

=head2 apiRequestLwp

   my $response = $self->apiRequestLwp($endpoint, $method, $data);

Internal method that performs API requests using LWP::UserAgent.
SSL verification is disabled for self-signed certificates.

B<Parameters:>

=over 4

=item * endpoint - API endpoint path

=item * method - HTTP method (GET, POST, PUT, DELETE). Default: GET

=item * data - Optional hashref of data to send

=back

B<Returns:> Decoded JSON response

B<Dies:> If the API request fails

=cut

#-----------------------------
# apiRequestLwp: API request using LWP
#-----------------------------
sub apiRequestLwp {
    my ($self, $endpoint, $method, $data) = @_;
    $method ||= 'GET';
    my $url = $self->{baseUrl} . $endpoint;
    my $req = HTTP::Request->new($method => $url);
    $req->header('Content-Type' => 'application/json');
    $req->header('Authorization' => 'key ' . $self->{apiKey} . ':' . $self->{apiSecret});
    die "In apiRequestLwp, request object:\n" . Dumper($req);
    $req->content(encode_json($data)) if $data;
    my $res = $self->{ua}->request($req);
    return decode_json($res->decoded_content) if $res->is_success;
    die "API request failed: " . $res->status_line;

}

=head2 getVpnUsers

   my $vpnUsers = $opn->getVpnUsers();

Retrieves VPN user certificates from the OPNsense router.

B<Returns:> Hashref keyed by certificate ID, value is username

Example:
   {
      '60cc1de5e6dfd' => 'john',
      '6327c3d037f8e' => 'mary'
   }

B<Note:> Skips users with spaces in username. Uses 'users' field if available,
falls back to 'description' field for compatibility with OPNsense v24.7.

=cut

#-----------------------------
# getVpnUsers: get VPN users
#-----------------------------
sub getVpnUsers {
    my ($self) = @_;
    my $return = {};
    my $endpoint = "/api/openvpn/export/accounts/$self->{ovpnIndex}";
    my $users = $self->apiRequest($endpoint);
    #print "In get_vpn_users, users object:\n" . Dumper($users); die;
    foreach my $user ( keys %$users ) {
      next unless $user;
      # the username can be in the array users (preferable) or in the description
      my $username = ($users->{$user}->{'users'}->[0] ? $users->{$user}->{'users'}->[0] : $users->{$user}->{'description'} );
      # we do not allow usernames with spaces in our setup
      next if $username =~ m/ /;
#         $user && 
#         $users->{$user}->{'users'} &&
#         ref($users->{$user}->{'users'}) eq 'ARRAY'
#         && @{$users->{$user}->{'users'}} > 0;
         # only return the first user in the array
      $return->{$user} = $username;
    }
#    print "In get_vpn_users, return object:\n" . Dumper($return); die;
    return $return;
}

=head2 getAllUsers

   my $allUsers = $opn->getAllUsers();

Retrieves all system users from the OPNsense router, including TOTP secrets
and authentication data.

B<Returns:> Hashref keyed by username, value is user object

User object structure:
   {
      'john' => {
         'name' => 'john',
         'password' => '<hashed password>',
         'otp_seed' => '<TOTP secret in base32>',
         'disabled' => 0,
         'description' => 'John Doe',
         ...
      }
   }

B<Note:> This method downloads the entire router configuration via
/api/core/backup/download/this and extracts user data, as the /api/system/user
endpoint is not functional in some OPNsense versions.

=cut

#-----------------------------
# getAllUsers: get all users on the system
#-----------------------------
# returns a hashref keyed by username, value is the user object
# The api is seriously broken. It is supposed to be /api/system/user, but that returns an error
# so, we download the entire config and extract the users from there
sub getAllUsers {
    my ($self) = @_;
    my $endpoint = "/api/core/backup/download/this";
    my $xml = XML::Simple->new();
    my $data = $self->apiRequest($endpoint);
    my $config = $xml->XMLin($data);
    my $return = {};
    if (ref($config->{system}->{user}) eq 'ARRAY') {
        foreach my $user (@{$config->{system}->{user}}) {
            $return->{$user->{name}} = $user;
        }
    } elsif (ref($config->{system}->{user}) eq 'HASH') {
        $return = $config->{system}->{user};
    }
    return $return;
}

=head2 getVpnProviders

   my $providers = $opn->getVpnProviders();

Retrieves list of OpenVPN providers (instances) configured on the router.

B<Returns:> Hashref keyed by provider ID (ovpnIndex), value is provider name

Example:
   {
      '1' => 'Main VPN Server',
      '2c3f5a1b' => 'Secondary VPN'
   }

B<Used by:> configure script to allow selection of VPN provider

=cut

#-----------------------------
# getVpnProviders: get VPN providers
#-----------------------------
sub getVpnProviders {
   my ($self) = @_;
   my $endpoint = "/api/openvpn/export/providers";
   my $return = {};
   my $providers = $self->apiRequest($endpoint);
   #die "In getVPNProviders, providers object:\n" . Dumper($providers);
   return $return unless
      ref($providers) eq 'HASH' && keys %$providers;
    # key by vpnid, value is name
   foreach my $provider ( keys %$providers ) {
       $return->{$providers->{$provider}->{'vpnid'}} = $providers->{$provider}->{'name'};
    }
   return $return; 
}

=head2 getVpnConfig

   my $vpnConfig = $opn->getVpnConfig($certId);

Downloads OpenVPN configuration file for a specific certificate.

B<Parameters:>

=over 4

=item * certId - Certificate ID from getVpnUsers()

=back

B<Returns:> Hashref containing:
   {
      'content' => '<base64 encoded .ovpn file>',
      ...
   }

B<Note:> The returned content is base64 encoded and must be decoded before use.
See makeVPNConfigFile() in opnsense-totp-ovpn-export for decoding example.

=cut

#-----------------------------
# getVpnConfig: get VPN configuration file
#-----------------------------
sub getVpnConfig {
   my ( $self, $cert ) = @_;
   my $endpoint = "/api/openvpn/export/download/$self->{ovpnIndex}/$cert";
   my $payload = ();
   $payload->{'openvpn_export'} = {
       validate_server_cn => 1,
       hostname => $self->{hostname},
       template => $self->{template},
       auth_nocache => 0,
       p12_password_confirm => "",
       random_local_port => 1,
       servers => "\"1\"",
       plain_config => "",
       p12_password => "",
       local_port => $self->{localPort},
       cryptoapi => 0
   };
   $debug = 0;
   my $return = $self->apiRequest($endpoint, 'POST', $payload);
   return $return;
}

=head1 DEPENDENCIES

=over 4

=item * LWP::UserAgent - For LWP-based HTTP requests

=item * JSON - For encoding/decoding JSON data

=item * Data::Dumper - For debugging output

=item * XML::Simple - For parsing router configuration XML

=back

=head1 SECURITY CONSIDERATIONS

=over 4

=item * SSL verification is disabled to support self-signed certificates

=item * API credentials are passed in plain text (use HTTPS)

=item * Store API keys securely with restrictive file permissions (0600)

=back

=head1 SEE ALSO

=over 4

=item * configure - Router configuration management tool

=item * opnsense-totp-ovpn-export - Main export script

=item * opnsense.pm.md - Developer's guide

=back

=head1 AUTHOR

Daily Data, Inc.

=head1 COPYRIGHT AND LICENSE

Copyright (c) 2025, Daily Data, Inc.
All rights reserved.

This software is provided under the BSD 3-Clause License.
See the LICENSE file for details.

=cut

1;