File Coverage

blib/lib/MikroTik/Client.pm
Criterion Covered Total %
statement 146 149 97.9
branch 35 42 83.3
condition 23 32 71.8
subroutine 36 36 100.0
pod 2 2 100.0
total 242 261 92.7


line stmt bran cond sub pod time code
1             package MikroTik::Client;
2 4     4   376491 use MikroTik::Client::Mo;
  4         16  
  4         22  
3              
4 4     4   2076 use MikroTik::Client::Response;
  4         14  
  4         165  
5 4     4   25 use MikroTik::Client::Sentence qw(encode_sentence);
  4         8  
  4         221  
6 4     4   24 use Carp ();
  4         7  
  4         77  
7 4     4   2319 use Mojo::Collection;
  4         938763  
  4         226  
8 4     4   2881 use Mojo::IOLoop;
  4         776309  
  4         34  
9 4     4   279 use Mojo::Util qw(md5_sum term_escape);
  4         10  
  4         288  
10 4     4   28 use Scalar::Util 'weaken';
  4         7  
  4         286  
11              
12 4     4   22 use constant CONN_TIMEOUT => $ENV{MIKROTIK_CLIENT_CONNTIMEOUT};
  4         9  
  4         382  
13 4   50 4   25 use constant DEBUG => $ENV{MIKROTIK_CLIENT_DEBUG} || 0;
  4         26  
  4         418  
14              
15             # Mojolicious 8.72 deprecated bunch of specific TLS negotiation options
16             # in favour of single tls_options
17             use constant MOJO_TLS_OPTS => !!
18 4     4   62 eval { require Mojolicious; Mojolicious->VERSION('8.72'); 1 };
  4         10  
  4         9  
  4         2476  
  4         751592  
  4         11276  
19              
20             our $VERSION = 'v0.612';
21              
22             has ca => sub { $ENV{MIKROTIK_CLIENT_CA} };
23             has cert => sub { $ENV{MIKROTIK_CLIENT_CERT} };
24             has error => '';
25             has host => '192.168.88.1';
26             has insecure => sub { $ENV{MIKROTIK_CLIENT_INSECURE} // 1 };
27             has key => sub { $ENV{MIKROTIK_CLIENT_KEY} };
28             has ioloop => sub { Mojo::IOLoop->new() };
29             has new_login => 1;
30             has password => '';
31             has port => sub { $_[0]->tls ? 8729 : 8728 };
32             has timeout => 10;
33             has tls => 1;
34             has user => 'admin';
35             has _tag => 0;
36              
37             # Aliases
38             # {
39             # no strict 'refs';
40             # *{__PACKAGE__ . "::cmd"} = \&command;
41             # *{__PACKAGE__ . "::cmd_p"} = \&command_p;
42             # *{__PACKAGE__ . "::_fail"} = \&_finish;
43             # }
44             Mojo::Util::monkey_patch(__PACKAGE__, 'cmd', \&command);
45             Mojo::Util::monkey_patch(__PACKAGE__, 'cmd_p', \&command_p);
46             Mojo::Util::monkey_patch(__PACKAGE__, '_fail', \&_finish);
47              
48 2 50   2   791469 sub DESTROY { shift->_cleanup unless ${^GLOBAL_PHASE} eq 'DESTRUCT' }
49              
50             sub cancel {
51 2 50   2 1 32 my $cb = ref $_[-1] eq 'CODE' ? pop : sub { };
        2      
52             return
53 2         22 shift->_command(Mojo::IOLoop->singleton, '/cancel', {'tag' => shift}, undef, $cb);
54             }
55              
56             sub command {
57 16 100   16   3916 my $cb = ref $_[-1] eq 'CODE' ? pop : undef;
58 16         44 my ($self, $cmd, $attr, $query) = @_;
59              
60             # non-blocking
61 16 100       80 return $self->_command(Mojo::IOLoop->singleton, $cmd, $attr, $query, $cb) if $cb;
62              
63             # blocking
64 13         22 my $res;
65             $self->_command($self->ioloop, $cmd, $attr, $query,
66 13     13   40 sub { $_[0]->ioloop->stop(); $res = $_[2]; });
  13         56  
  13         215  
67 13         42 $self->ioloop->start();
68              
69 13         1090 return $res;
70             }
71              
72             sub command_p {
73 3     3   4169 return shift->_promisify(Mojo::IOLoop->singleton, @_);
74             }
75              
76             sub subscribe {
77 1 50   1 1 20 do { $_[0]->{error} = 'can\'t subscribe in blocking mode'; return; }
  0         0  
  0         0  
78             unless ref $_[-1] eq 'CODE';
79 1         3 my $cb = pop;
80 1         4 my ($self, $cmd, $attr, $query) = @_;
81 1         4 $attr->{'.subscription'} = 1;
82 1         5 return $self->_command(Mojo::IOLoop->singleton, $cmd, $attr, $query, $cb);
83             }
84              
85             sub _cleanup {
86 4     4   478 my $self = shift;
87 4   0     11 $_->{timeout} && $_->{loop}->remove($_->{timeout}) for values %{$self->{requests}};
  4         17  
88             $_->{stream} && $_->{stream}->unsubscribe('close')->close()
89 4   33     12 for values %{$self->{connections}};
  4         65  
90 4         789 delete @{$self}{qw(connections requests)};
  4         333  
91             }
92              
93             sub _close {
94 5     5   34 my ($self, $loop, $err) = @_;
95 5   100     40 $self->_fail_all($loop, $err // 'closed prematurely');
96 5         198 delete $self->{connections}{$loop};
97             }
98              
99             sub _command {
100 31     31   120 my ($self, $loop, $cmd, $attr, $query, $cb) = @_;
101              
102 31         79 my $tag = ++$self->{_tag};
103 31         249 my $r = $self->{requests}{$tag} = {tag => $tag, loop => $loop, cb => $cb};
104 31         95 $r->{subscription} = delete $attr->{'.subscription'};
105              
106 31         42 warn "-- got request for command '$cmd' (tag: $tag)\n" if DEBUG;
107              
108 31         133 $r->{sentence} = encode_sentence($cmd, $attr, $query, $tag);
109 31         108 return $self->_send_request($r);
110             }
111              
112             sub _connect {
113 9     9   22 my ($self, $loop) = @_;
114              
115 9         15 warn "-- creating new connection\n" if DEBUG;
116 9         23 my $c = $self->{connections}{$loop};
117              
118             # define SSL_VERIFY_NONE 0x00
119             # define SSL_VERIFY_PEER 0x01
120 9 50       37 my $verify_mode = $self->insecure ? 0x00 : 0x01;
121 9         36 my %tls_opts = (
122             tls => $self->tls,
123             tls_ca => $self->ca,
124             tls_cert => $self->cert,
125             tls_key => $self->key,
126             (
127             MOJO_TLS_OPTS
128             ? (tls_options => {SSL_cipher_list => 'HIGH', SSL_verify_mode => $verify_mode})
129             : (tls_ciphers => 'HIGH', tls_verify => $verify_mode)
130             )
131             );
132              
133             $c->{id} = $loop->client(
134             {
135             address => $self->host,
136             port => $self->port,
137             timeout => CONN_TIMEOUT,
138             %tls_opts
139              
140             } => sub {
141 9     9   65020 my ($loop, $err, $stream) = @_;
142              
143 9 100       40 if ($err) { $self->_close($loop, $err); return }
  2         14  
  2         7  
144              
145 7         13 warn "-- connection established\n" if DEBUG;
146              
147 7         20 $c->{stream} = $stream;
148              
149 7         15 weaken $self;
150 7         51 $stream->on(read => sub { $self->_read($loop, $_[1]) });
  23         11336  
151 7 0       67 $stream->on(error => sub { $self and $self->_fail_all($loop, $_[1]) });
  0         0  
152 7 50       83 $stream->on(close => sub { $self and $self->_close($loop) });
  1         502756  
153              
154 7         102 $self->_login($loop);
155             }
156 9         40 );
157             }
158              
159             sub _enqueue {
160 12     12   26 my ($self, $r) = @_;
161 12         41 my $c = $self->{connections}{$r->{loop}};
162 12 100       55 $self->_connect($r->{loop}) unless $c->{id};
163 12   100     2561 push @{$c->{queue} ||= []}, $r;
  12         93  
164 12         60 return $r->{tag};
165             }
166              
167             sub _fail_all {
168 5     5   13 $_[0]->_fail($_, $_[2]) for grep { $_->{loop} eq $_[1] } values %{$_[0]->{requests}};
  5         47  
  5         25  
169             }
170              
171             sub _finish {
172 31     31   84 my ($self, $r, $err) = @_;
173 31         102 delete $self->{requests}{$r->{tag}};
174 31 100       101 if (my $timer = $r->{timeout}) { $r->{loop}->remove($timer) }
  26         125  
175 31   100     1203 $r->{cb}->($self, ($self->{error} = $err // ''), $r->{data});
176             }
177              
178             sub _login {
179 7     7   20 my ($self, $loop) = @_;
180 7         14 warn "-- trying to log in\n" if DEBUG;
181              
182             $self->_promisify(
183             $loop, '/login',
184             ($self->new_login ? {name => $self->user, password => $self->password} : {})
185              
186             )->then(sub {
187 6     6   1578 my $res = shift;
188 6 100       104 return $res if !$res->[0]{ret}; # New style login post-v6.43
189              
190 2         12 my $secret = md5_sum("\x00", $self->password, pack 'H*', $res->[0]{ret});
191 2         32 return $self->_promisify($loop, '/login',
192             {name => $self->user, response => "00$secret"});
193              
194             })->then(sub {
195 5     5   1108 my $c = $self->{connections}{$loop};
196 5         12 $self->_write_sentence($c->{stream}, $_) for @{delete $c->{queue}};
  5         27  
197              
198             })->catch(sub {
199 2     2   1085 $self->_close($loop, $_[0]);
200              
201 7 100       35 })->wait;
202             }
203              
204             sub _promisify {
205 12     12   50 my ($self, $loop) = (shift, shift);
206 12         140 my $p = Mojo::Promise->new()->ioloop($loop);
207             $self->_command(
208             $loop,
209             (shift, shift, shift) => sub {
210 12 100   12   70 return $p->reject($_[1], $_[2]) if $_[1];
211 8         41 $p->resolve($_[2]);
212             }
213 12         675 );
214 12         155 return $p;
215             }
216              
217             sub _read {
218 23     23   62 my ($self, $loop, $bytes) = @_;
219              
220 23         37 warn term_escape "-- read from socket: " . length($bytes) . "\n$bytes\n" if DEBUG;
221              
222 23   66     173 my $resp = $self->{connections}{$loop}{response} ||= MikroTik::Client::Response->new();
223 23         89 my $data = $resp->parse(\$bytes);
224              
225 23         62 for (@$data) {
226 42 100       1721 next unless my $r = $self->{requests}{delete $_->{'.tag'}};
227 41         85 my $type = delete $_->{'.type'};
228 41 100 66     173 push @{$r->{data} ||= Mojo::Collection->new()}, $_ if %$_ && !$r->{subscription};
  25   100     161  
229              
230 41 100 100     339 if ($type eq '!re' && $r->{subscription}) {
    100 100        
    100          
231 1         12 $r->{cb}->($self, '', $_);
232              
233             }
234             elsif ($type eq '!done') {
235 16   66     106 $r->{data} ||= Mojo::Collection->new();
236 16         102 $self->_finish($r);
237              
238             }
239             elsif ($type eq '!trap' || $type eq '!fatal') {
240 8         33 $self->_fail($r, $_->{message});
241             }
242             }
243             }
244              
245             sub _send_request {
246 31     31   66 my ($self, $r) = @_;
247 31 100       223 return $self->_enqueue($r) unless my $stream = $self->{connections}{$r->{loop}}{stream};
248 19         57 return $self->_write_sentence($stream, $r);
249             }
250              
251             sub _write_sentence {
252 27     27   59 my ($self, $stream, $r) = @_;
253 27         37 warn term_escape "-- writing sentence for tag: $r->{tag}\n$r->{sentence}\n" if DEBUG;
254              
255 27         168 $stream->write($r->{sentence});
256              
257 27 100       983 return $r->{tag} if $r->{subscription};
258              
259 26         53 weaken $self;
260             $r->{timeout}
261 26     2   88 = $r->{loop}->timer($self->timeout => sub { $self->_fail($r, 'response timeout') });
  2         1504505  
262              
263 26         1909 return $r->{tag};
264             }
265              
266             1;
267              
268              
269             =encoding utf8
270              
271             =head1 NAME
272              
273             MikroTik::Client - Non-blocking interface to MikroTik API
274              
275             =head1 SYNOPSIS
276              
277             my $api = MikroTik::Client->new();
278              
279             # Blocking
280             my $list = $api->command(
281             '/interface/print',
282             {'.proplist' => '.id,name,type'},
283             {type => ['ipip-tunnel', 'gre-tunnel'], running => 'true'}
284             );
285             if (my $err = $api->error) { die "$err\n" }
286             printf "%s: %s\n", $_->{name}, $_->{type} for @$list;
287              
288              
289             # Non-blocking
290             my $tag = $api->command(
291             '/system/resource/print',
292             {'.proplist' => 'board-name,version,uptime'} => sub {
293             my ($api, $err, $list) = @_;
294             ...;
295             Mojo::IOLoop->stop;
296             }
297             );
298             Mojo::IOLoop->start;
299              
300             # Subscribe
301             $tag = $api->subscribe(
302             '/interface/listen' => sub {
303             my ($api, $err, $el) = @_;
304             ...;
305             }
306             );
307             Mojo::IOLoop->timer(3 => sub { $api->cancel($tag) });
308             Mojo::IOLoop->start;
309              
310             # Errors handling
311             $api->command(
312             '/random/command' => sub {
313             my ($api, $err, $list) = @_;
314              
315             if ($err) {
316             warn "Error: $err, category: " . $list->[0]{category};
317             return;
318             }
319              
320             ...;
321             }
322             );
323             Mojo::IOLoop->start;
324              
325             # Promises
326             $api->cmd_p('/interface/print')
327             ->then(sub { my $res = shift }, sub { my ($err, $attr) = @_ })
328             ->finally(sub { Mojo::IOLoop->stop });
329             Mojo::IOLoop->start;
330              
331             =head1 DESCRIPTION
332              
333             Both blocking and non-blocking interface to a MikroTik API service. With queries,
334             command subscriptions and Promises/A+.
335              
336             =head1 CHANGES
337              
338             Starting from C this module switched back to using L as
339             event loop backend. This should not affect blocking calls, but it might break
340             non-blocking ones. Since both L and L prefer using L,
341             when it's available, it should not really matter which one starts an event loop.
342              
343             For other event systems you can set C environment variable to
344             C. It would force L to play a bit more
345             nicely with L.
346              
347             BEGIN {
348             $ENV{MOJO_REACTOR} = "MikroTik::Client::Reactor::AE";
349             }
350              
351             use AnyEvent;
352             use AnyEvent::Loop;
353              
354             my $done = AE::cv;
355             $api->command('/some/command' => $done);
356             $done->recv;
357              
358             =head1 ATTRIBUTES
359              
360             L implements the following attributes.
361              
362             =head2 ca
363              
364             my $ca = $api->ca;
365             $api->ca("/etc/ssl/certs/ca-bundle.crt")
366              
367             Path to TLS authority file.
368              
369             Can be changed with C environment variable.
370              
371             =head2 cert
372              
373             my $cert = $api->cert;
374             $api->cert("./client.crt")
375              
376             Path to the TLS cert file.
377              
378             Can be bundled with a private key and intermediate public certificates.
379             If it's contains a private key, L attribute is optional.
380              
381             Can be changed with C environment variable.
382              
383             =head2 error
384              
385             my $last_error = $api->error;
386              
387             Keeps an error from last L call. Empty string on successful commands.
388              
389             =head2 host
390              
391             my $host = $api->host;
392             $api = $api->host('border-gw.local');
393              
394             Host name or IP address to connect to. Defaults to C<192.168.88.1>.
395              
396             =head2 insecure
397              
398             my $insecure = $api->insecure;
399             $api->insecure(0);
400              
401             Do not verify TLS certificates. Connection will be encrypted, but peer certificate
402             won't be validated. B.
403              
404             Can be changed with C environment variable.
405              
406             =head2 ioloop
407              
408             my $loop = $api->ioloop;
409             $api = $api->loop(Mojo::IOLoop->new());
410              
411             Event loop instance to use for blocking calls. Defaults to L
412             object.
413              
414             =head2 key
415              
416             my $key = $api->key;
417             $api->key("./client.crt")
418              
419             Path to TLS key file. Optional if a private key is bundled with L file.
420              
421             Can be changed with C environment variable.
422              
423             =head2 new_login
424              
425             my $new_login = $api->new_login;
426             $api = $api->new_login(0);
427              
428             Used to enable new login scheme introduced in RouterOS C. Now it's a way to
429             disable it, if required for some reason. Enabled by default.
430              
431             =head2 password
432              
433             my $pass = $api->password;
434             $api = $api->password('secret');
435              
436             Password for authentication. Empty string by default.
437              
438             =head2 port
439              
440             my $port = $api->port;
441             $api = $api->port(8000);
442              
443             API service port for connection. Defaults to C<8729> and C<8728> for TLS and
444             clear text connections respectively.
445              
446             =head2 timeout
447              
448             my $timeout = $api->timeout;
449             $api = $api->timeout(15);
450              
451             Timeout in seconds for sending request and receiving response before command
452             will be canceled. Default is C<10> seconds.
453              
454             =head2 tls
455              
456             my $tls = $api->tls;
457             $api = $api->tls(1);
458              
459             Use TLS for connection. Enabled by default.
460              
461             CAVEAT: It's enabled by default, but it requires TLS support from
462             L
463              
464             =head2 user
465              
466             my $user = $api->user;
467             $api = $api->user('admin');
468              
469             User name for authentication. Defaults to C.
470              
471             =head1 METHODS
472              
473             =head2 cancel
474              
475             # subscribe to a command output
476             my $tag = $api->subscribe('/ping', {address => '127.0.0.1'} => sub {...});
477              
478             # cancel command after 10 seconds
479             Mojo::IOLoop->timer(10 => sub { $api->cancel($tag) });
480              
481             # or with callback
482             $api->cancel($tag => sub {...});
483              
484             Cancels background commands. Can accept a callback as last argument.
485              
486             =head2 cmd
487              
488             my $list = $api->cmd('/interface/print');
489              
490             An alias for L.
491              
492             =head2 cmd_p
493              
494             my $p = $api->cmd_p('/interface/print');
495              
496             An alias for L.
497              
498             =head2 command
499              
500             my $command = '/interface/print';
501             my $attr = {'.proplist' => '.id,name,type'};
502             my $query = {type => ['ipip-tunnel', 'gre-tunnel'], running => 'true'};
503              
504             my $list = $api->command($command, $attr, $query);
505             die $api->error if $api->error;
506             for (@$list) {...}
507              
508             $api->command('/user/set', {'.id' => 'admin', comment => 'System admin'});
509              
510             # Non-blocking
511             $api->command('/ip/address/print' => sub {
512             my ($api, $err, $list) = @_;
513              
514             return if $err;
515              
516             for (@$list) {...}
517             });
518              
519             # Omit attributes
520             $api->command('/user/print', undef, {name => 'admin'} => sub {...});
521              
522             # Errors handling
523             $list = $api->command('/random/command');
524             if (my $err = $api->error) {
525             die "Error: $err, category: " . $list->[0]{category};
526             }
527              
528             Executes commands on a device. Returns L of hashrefs with results.
529             Can accept a callback for non-blocking calls.
530              
531             On errors it may pass extra info in return argument in addition to an error value.
532              
533             For a query syntax refer to L.
534              
535             =head2 command_p
536              
537             my $promise = $api->command_p('/interface/print');
538              
539             $promise->then(
540             sub {
541             my $res = shift;
542             ...
543             })->catch(sub {
544             my ($err, $attr) = @_;
545             });
546              
547             Same as L, but always performs requests non-blocking and returns a
548             L object instead of accepting a callback.
549              
550             =head2 subscribe
551              
552             my $tag = $api->subscribe('/ping',
553             {address => '127.0.0.1'} => sub {
554             my ($api, $err, $res) = @_;
555             });
556              
557             Mojo::IOLoop->timer(
558             3 => sub { $api->cancel($tag) }
559             );
560              
561             Subscribe to a command with continuous responses such as C or C.
562             Should be terminated with L.
563              
564             =head1 DEBUGGING
565              
566             You can set the MIKROTIK_CLIENT_DEBUG environment variable to get some debug output
567             printed to stderr.
568              
569             Also, you can change connection timeout with the MIKROTIK_CLIENT_CONNTIMEOUT variable.
570              
571             =head1 COPYRIGHT AND LICENSE
572              
573             Andre Parker, 2017-2025.
574              
575             This program is free software, you can redistribute it and/or modify it under
576             the terms of the Artistic License version 2.0.
577              
578             =head1 SEE ALSO
579              
580             L, L
581              
582             =cut
583