Subversion Repositories web_pages

Rev

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

Rev Author Line No. Line
14 rodolico 1
# Copyright (c) 2025, Daily Data, Inc.
2
# All rights reserved.
3
#
4
# Redistribution and use in source and binary forms, with or without modification,
5
# are permitted provided that the following conditions are met:
6
#
7
# 1. Redistributions of source code must retain the above copyright notice, this list
8
#    of conditions and the following disclaimer.
9
# 2. Redistributions in binary form must reproduce the above copyright notice, this
10
#    list of conditions and the following disclaimer in the documentation and/or other
11
#    materials provided with the distribution.
12
# 3. Neither the name of Daily Data, Inc. nor the names of its contributors may be
13
#    used to endorse or promote products derived from this software without specific
14
#    prior written permission.
15
#
16
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
17
# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
18
# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
19
# SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
20
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
21
# TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
22
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
23
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
24
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
25
# DAMAGE.
26
 
20 rodolico 27
=head1 NAME
17 rodolico 28
 
20 rodolico 29
opnsense - Perl interface for OPNsense Router API
30
 
31
=head1 SYNOPSIS
32
 
33
   use opnsense;
34
 
35
   # Create an OPNsense API client
36
   my $opn = opnsense->new(
37
      url       => 'https://192.168.1.1',
38
      apiKey    => 'your-api-key',
39
      apiSecret => 'your-api-secret',
40
      ovpnIndex => '1',
41
      template  => 'PlainOpenVPN',
42
      hostname  => 'vpn.example.com',
43
      localPort => '1194'
44
   );
45
 
46
   # Get VPN providers
47
   my $providers = $opn->getVpnProviders();
48
 
49
   # Get VPN user certificates
50
   my $vpnUsers = $opn->getVpnUsers();
51
 
52
   # Get all system users (including TOTP secrets)
53
   my $allUsers = $opn->getAllUsers();
54
 
55
   # Download VPN configuration for a certificate
56
   my $vpnConfig = $opn->getVpnConfig($certId);
57
 
58
=head1 DESCRIPTION
59
 
60
This module provides an object-oriented interface to the OPNsense router API.
61
It supports both curl-based and LWP::UserAgent-based HTTP requests with SSL
62
verification disabled for compatibility with self-signed certificates.
63
 
64
The module is designed to retrieve VPN configurations, user credentials,
65
TOTP secrets, and manage OpenVPN export functionality.
66
 
67
=head1 CONFIGURATION
68
 
69
   $opnsense::useCurl = 1;  # Use curl (default) or 0 for LWP::UserAgent
70
   $opnsense::debug = 0;    # Enable debug output (1) or disable (0)
71
 
72
=head1 CHANGE HISTORY
73
 
74
=over 4
75
 
76
=item v1.0.0 2025-10-01 - RWR
77
 
78
Initial version
79
 
80
=item v1.0.1 2025-10-07 - RWR
81
 
82
Fixed bug where script was looking for localport, but was localPort.
83
Fixed bug where not all users were returned because opnSense (v24.7)
84
does not send the username, it sends the description. Quick fix is to
85
use username, then description.
86
 
87
=back
88
 
89
=cut
90
 
91
 
14 rodolico 92
package opnsense;
93
use strict;
94
use warnings;
95
 
96
use LWP::UserAgent;
97
use JSON;
98
use Data::Dumper;
99
use XML::Simple;
100
 
16 rodolico 101
our $VERSION = "1.0.0";
14 rodolico 102
 
16 rodolico 103
our $useCurl = 1; # set to 1 to use curl for API requests, 0 to use LWP
104
our $debug = 0;    # set to 1 for debug output
14 rodolico 105
 
20 rodolico 106
=head1 METHODS
107
 
108
=head2 new
109
 
110
   my $opn = opnsense->new(%args);
111
 
112
Constructor. Creates a new OPNsense API client object.
113
 
114
B<Parameters:>
115
 
116
=over 4
117
 
118
=item * url - Base URL of the OPNsense router (e.g., 'https://192.168.1.1')
119
 
120
=item * apiKey - API key from OPNsense (System | Access | Users)
121
 
122
=item * apiSecret - API secret from OPNsense
123
 
124
=item * ovpnIndex - OpenVPN provider index (single digit or hash)
125
 
126
=item * template - OpenVPN template type (typically 'PlainOpenVPN')
127
 
128
=item * hostname - External hostname for VPN endpoint
129
 
130
=item * localPort - Port number for VPN connections
131
 
132
=back
133
 
134
B<Returns:> opnsense object
135
 
136
=cut
137
 
16 rodolico 138
#-----------------------------
139
# new: Constructor
140
#-----------------------------
14 rodolico 141
sub new {
16 rodolico 142
   my ($class, %args) = @_;
143
   my $self = {
144
      baseUrl   => $args{url},
145
      apiKey    => $args{apiKey},
146
      apiSecret => $args{apiSecret},
147
      ovpnIndex => $args{ovpnIndex},
148
      template  => $args{template},
149
      hostname  => $args{hostname},
150
      localPort => $args{localPort},
151
      ua        => $useCurl ?
152
         'curl -s --insecure' :
153
         LWP::UserAgent->new(
154
            ssl_opts => { verify_hostname => 0, SSL_verify_mode => 0 }
155
         ),
156
   };
157
   bless $self, $class;
158
   return $self;
14 rodolico 159
}
160
 
20 rodolico 161
=head2 apiRequest
162
 
163
   my $response = $opn->apiRequest($endpoint, $method, $data);
164
 
165
Internal method that dispatches API requests to either curl or LWP handler
166
based on the $opnsense::useCurl setting.
167
 
168
B<Parameters:>
169
 
170
=over 4
171
 
172
=item * endpoint - API endpoint path (e.g., '/api/openvpn/export/providers')
173
 
174
=item * method - HTTP method (GET, POST, PUT, DELETE). Default: GET
175
 
176
=item * data - Optional hashref of data to send with POST/PUT requests
177
 
178
=back
179
 
180
B<Returns:> Decoded JSON response or XML string
181
 
182
=cut
183
 
16 rodolico 184
#-----------------------------
185
# apiRequest: API request dispatcher
186
#-----------------------------
187
sub apiRequest {
14 rodolico 188
 
16 rodolico 189
   if ($useCurl) {
190
       return apiRequestCurl(@_);
14 rodolico 191
   } else {
16 rodolico 192
       return apiRequestLwp(@_);
14 rodolico 193
   }
194
}
195
 
20 rodolico 196
=head2 apiRequestCurl
197
 
198
   my $response = $self->apiRequestCurl($endpoint, $method, $data);
199
 
200
Internal method that performs API requests using system curl command.
201
SSL verification is disabled (--insecure flag).
202
 
203
B<Parameters:>
204
 
205
=over 4
206
 
207
=item * endpoint - API endpoint path
208
 
209
=item * method - HTTP method (GET, POST, PUT, DELETE). Default: GET
210
 
211
=item * data - Optional hashref of data to send
212
 
213
=back
214
 
215
B<Returns:> Decoded JSON response or raw XML string
216
 
217
=cut
218
 
16 rodolico 219
#-----------------------------
220
# apiRequestCurl: API request using curl
221
#-----------------------------
222
sub apiRequestCurl {
14 rodolico 223
   my ($self, $endpoint, $method, $data) = @_;
224
   $method ||= 'GET';
16 rodolico 225
   my $url = $self->{baseUrl} . $endpoint;
14 rodolico 226
 
227
   my @data = ();
228
   push @data, '--request', $method;
229
   push @data, "--header 'Content-Type: application/json'" if ( $method eq 'POST' || $method eq 'PUT' );
230
   push @data, "--user '$self->{apiKey}:$self->{apiSecret}'";
231
   push @data, "--data '" . encode_json($data) . "'" if $data;
232
   my $cmd = join(' ', $self->{ua}, @data, $url);
16 rodolico 233
   die "In apiRequestCurl, command is:\n $cmd" if $debug;
14 rodolico 234
   my $json_text = `$cmd`;
235
   if ( $json_text =~ /<\?xml/) {
236
      # returned an XML string, just send it
237
      return $json_text;
238
   }
239
   return decode_json($json_text);
240
}
241
 
20 rodolico 242
=head2 apiRequestLwp
14 rodolico 243
 
20 rodolico 244
   my $response = $self->apiRequestLwp($endpoint, $method, $data);
245
 
246
Internal method that performs API requests using LWP::UserAgent.
247
SSL verification is disabled for self-signed certificates.
248
 
249
B<Parameters:>
250
 
251
=over 4
252
 
253
=item * endpoint - API endpoint path
254
 
255
=item * method - HTTP method (GET, POST, PUT, DELETE). Default: GET
256
 
257
=item * data - Optional hashref of data to send
258
 
259
=back
260
 
261
B<Returns:> Decoded JSON response
262
 
263
B<Dies:> If the API request fails
264
 
265
=cut
266
 
16 rodolico 267
#-----------------------------
268
# apiRequestLwp: API request using LWP
269
#-----------------------------
270
sub apiRequestLwp {
14 rodolico 271
    my ($self, $endpoint, $method, $data) = @_;
272
    $method ||= 'GET';
16 rodolico 273
    my $url = $self->{baseUrl} . $endpoint;
14 rodolico 274
    my $req = HTTP::Request->new($method => $url);
275
    $req->header('Content-Type' => 'application/json');
276
    $req->header('Authorization' => 'key ' . $self->{apiKey} . ':' . $self->{apiSecret});
16 rodolico 277
    die "In apiRequestLwp, request object:\n" . Dumper($req);
14 rodolico 278
    $req->content(encode_json($data)) if $data;
279
    my $res = $self->{ua}->request($req);
280
    return decode_json($res->decoded_content) if $res->is_success;
281
    die "API request failed: " . $res->status_line;
282
 
283
}
284
 
20 rodolico 285
=head2 getVpnUsers
286
 
287
   my $vpnUsers = $opn->getVpnUsers();
288
 
289
Retrieves VPN user certificates from the OPNsense router.
290
 
291
B<Returns:> Hashref keyed by certificate ID, value is username
292
 
293
Example:
294
   {
295
      '60cc1de5e6dfd' => 'john',
296
      '6327c3d037f8e' => 'mary'
297
   }
298
 
299
B<Note:> Skips users with spaces in username. Uses 'users' field if available,
300
falls back to 'description' field for compatibility with OPNsense v24.7.
301
 
302
=cut
303
 
16 rodolico 304
#-----------------------------
305
# getVpnUsers: get VPN users
306
#-----------------------------
307
sub getVpnUsers {
14 rodolico 308
    my ($self) = @_;
309
    my $return = {};
310
    my $endpoint = "/api/openvpn/export/accounts/$self->{ovpnIndex}";
16 rodolico 311
    my $users = $self->apiRequest($endpoint);
17 rodolico 312
    #print "In get_vpn_users, users object:\n" . Dumper($users); die;
14 rodolico 313
    foreach my $user ( keys %$users ) {
17 rodolico 314
      next unless $user;
315
      # the username can be in the array users (preferable) or in the description
316
      my $username = ($users->{$user}->{'users'}->[0] ? $users->{$user}->{'users'}->[0] : $users->{$user}->{'description'} );
317
      # we do not allow usernames with spaces in our setup
318
      next if $username =~ m/ /;
319
#         $user && 
320
#         $users->{$user}->{'users'} &&
321
#         ref($users->{$user}->{'users'}) eq 'ARRAY'
322
#         && @{$users->{$user}->{'users'}} > 0;
14 rodolico 323
         # only return the first user in the array
17 rodolico 324
      $return->{$user} = $username;
14 rodolico 325
    }
17 rodolico 326
#    print "In get_vpn_users, return object:\n" . Dumper($return); die;
14 rodolico 327
    return $return;
328
}
329
 
20 rodolico 330
=head2 getAllUsers
331
 
332
   my $allUsers = $opn->getAllUsers();
333
 
334
Retrieves all system users from the OPNsense router, including TOTP secrets
335
and authentication data.
336
 
337
B<Returns:> Hashref keyed by username, value is user object
338
 
339
User object structure:
340
   {
341
      'john' => {
342
         'name' => 'john',
343
         'password' => '<hashed password>',
344
         'otp_seed' => '<TOTP secret in base32>',
345
         'disabled' => 0,
346
         'description' => 'John Doe',
347
         ...
348
      }
349
   }
350
 
351
B<Note:> This method downloads the entire router configuration via
352
/api/core/backup/download/this and extracts user data, as the /api/system/user
353
endpoint is not functional in some OPNsense versions.
354
 
355
=cut
356
 
16 rodolico 357
#-----------------------------
358
# getAllUsers: get all users on the system
359
#-----------------------------
14 rodolico 360
# returns a hashref keyed by username, value is the user object
361
# The api is seriously broken. It is supposed to be /api/system/user, but that returns an error
362
# so, we download the entire config and extract the users from there
16 rodolico 363
sub getAllUsers {
14 rodolico 364
    my ($self) = @_;
365
    my $endpoint = "/api/core/backup/download/this";
366
    my $xml = XML::Simple->new();
16 rodolico 367
    my $data = $self->apiRequest($endpoint);
14 rodolico 368
    my $config = $xml->XMLin($data);
369
    my $return = {};
370
    if (ref($config->{system}->{user}) eq 'ARRAY') {
371
        foreach my $user (@{$config->{system}->{user}}) {
372
            $return->{$user->{name}} = $user;
373
        }
374
    } elsif (ref($config->{system}->{user}) eq 'HASH') {
375
        $return = $config->{system}->{user};
376
    }
377
    return $return;
378
}
379
 
20 rodolico 380
=head2 getVpnProviders
381
 
382
   my $providers = $opn->getVpnProviders();
383
 
384
Retrieves list of OpenVPN providers (instances) configured on the router.
385
 
386
B<Returns:> Hashref keyed by provider ID (ovpnIndex), value is provider name
387
 
388
Example:
389
   {
390
      '1' => 'Main VPN Server',
391
      '2c3f5a1b' => 'Secondary VPN'
392
   }
393
 
394
B<Used by:> configure script to allow selection of VPN provider
395
 
396
=cut
397
 
16 rodolico 398
#-----------------------------
399
# getVpnProviders: get VPN providers
400
#-----------------------------
401
sub getVpnProviders {
14 rodolico 402
   my ($self) = @_;
403
   my $endpoint = "/api/openvpn/export/providers";
404
   my $return = {};
16 rodolico 405
   my $providers = $self->apiRequest($endpoint);
14 rodolico 406
   #die "In getVPNProviders, providers object:\n" . Dumper($providers);
407
   return $return unless
408
      ref($providers) eq 'HASH' && keys %$providers;
409
    # key by vpnid, value is name
410
   foreach my $provider ( keys %$providers ) {
411
       $return->{$providers->{$provider}->{'vpnid'}} = $providers->{$provider}->{'name'};
412
    }
413
   return $return; 
414
}
415
 
20 rodolico 416
=head2 getVpnConfig
417
 
418
   my $vpnConfig = $opn->getVpnConfig($certId);
419
 
420
Downloads OpenVPN configuration file for a specific certificate.
421
 
422
B<Parameters:>
423
 
424
=over 4
425
 
426
=item * certId - Certificate ID from getVpnUsers()
427
 
428
=back
429
 
430
B<Returns:> Hashref containing:
431
   {
432
      'content' => '<base64 encoded .ovpn file>',
433
      ...
434
   }
435
 
436
B<Note:> The returned content is base64 encoded and must be decoded before use.
437
See makeVPNConfigFile() in opnsense-totp-ovpn-export for decoding example.
438
 
439
=cut
440
 
16 rodolico 441
#-----------------------------
442
# getVpnConfig: get VPN configuration file
443
#-----------------------------
444
sub getVpnConfig {
14 rodolico 445
   my ( $self, $cert ) = @_;
446
   my $endpoint = "/api/openvpn/export/download/$self->{ovpnIndex}/$cert";
447
   my $payload = ();
448
   $payload->{'openvpn_export'} = {
449
       validate_server_cn => 1,
450
       hostname => $self->{hostname},
451
       template => $self->{template},
452
       auth_nocache => 0,
453
       p12_password_confirm => "",
454
       random_local_port => 1,
455
       servers => "\"1\"",
456
       plain_config => "",
457
       p12_password => "",
17 rodolico 458
       local_port => $self->{localPort},
14 rodolico 459
       cryptoapi => 0
460
   };
16 rodolico 461
   $debug = 0;
462
   my $return = $self->apiRequest($endpoint, 'POST', $payload);
14 rodolico 463
   return $return;
464
}
465
 
20 rodolico 466
=head1 DEPENDENCIES
14 rodolico 467
 
20 rodolico 468
=over 4
469
 
470
=item * LWP::UserAgent - For LWP-based HTTP requests
471
 
472
=item * JSON - For encoding/decoding JSON data
473
 
474
=item * Data::Dumper - For debugging output
475
 
476
=item * XML::Simple - For parsing router configuration XML
477
 
478
=back
479
 
480
=head1 SECURITY CONSIDERATIONS
481
 
482
=over 4
483
 
484
=item * SSL verification is disabled to support self-signed certificates
485
 
486
=item * API credentials are passed in plain text (use HTTPS)
487
 
488
=item * Store API keys securely with restrictive file permissions (0600)
489
 
490
=back
491
 
492
=head1 SEE ALSO
493
 
494
=over 4
495
 
496
=item * configure - Router configuration management tool
497
 
498
=item * opnsense-totp-ovpn-export - Main export script
499
 
500
=item * opnsense.pm.md - Developer's guide
501
 
502
=back
503
 
504
=head1 AUTHOR
505
 
506
Daily Data, Inc.
507
 
508
=head1 COPYRIGHT AND LICENSE
509
 
510
Copyright (c) 2025, Daily Data, Inc.
511
All rights reserved.
512
 
513
This software is provided under the BSD 3-Clause License.
514
See the LICENSE file for details.
515
 
516
=cut
517
 
14 rodolico 518
1;