File Coverage

blib/lib/API/MikroTik.pm
Criterion Covered Total %
statement 148 151 98.0
branch 34 44 77.2
condition 19 28 67.8
subroutine 35 35 100.0
pod 2 2 100.0
total 238 260 91.5


line stmt bran cond sub pod time code
1             package API::MikroTik;
2 3     3   142810 use Mojo::Base '-base';
  3         581411  
  3         25  
3              
4 3     3   2136 use API::MikroTik::Response;
  3         8  
  3         25  
5 3     3   117 use API::MikroTik::Sentence qw(encode_sentence);
  3         6  
  3         129  
6 3     3   17 use Carp ();
  3         6  
  3         48  
7 3     3   1383 use Mojo::Collection;
  3         9677  
  3         127  
8 3     3   1412 use Mojo::IOLoop;
  3         336238  
  3         23  
9 3     3   183 use Mojo::Util 'md5_sum';
  3         8  
  3         156  
10 3     3   27 use Scalar::Util 'weaken';
  3         6  
  3         150  
11              
12 3     3   17 use constant CONN_TIMEOUT => $ENV{API_MIKROTIK_CONNTIMEOUT};
  3         8  
  3         198  
13 3   50 3   17 use constant DEBUG => $ENV{API_MIKROTIK_DEBUG} || 0;
  3         6  
  3         210  
14 3     3   24 use constant PROMISES => !!(eval { require Mojo::Promise; 1 });
  3         7  
  3         8  
  3         119  
  3         7616  
15              
16             our $VERSION = 'v0.241';
17              
18             has error => '';
19             has host => '192.168.88.1';
20             has ioloop => sub { Mojo::IOLoop->new() };
21             has password => '';
22             has port => 0;
23             has timeout => 10;
24             has tls => 1;
25             has user => 'admin';
26             has _tag => 0;
27              
28             # Aliases
29             Mojo::Util::monkey_patch(__PACKAGE__, 'cmd', \&command);
30             Mojo::Util::monkey_patch(__PACKAGE__, 'cmd_p', \&command_p);
31             Mojo::Util::monkey_patch(__PACKAGE__, '_fail', \&_finish);
32              
33 1 50   1   2151 sub DESTROY { Mojo::Util::_global_destruction() or shift->_cleanup() }
34              
35             sub cancel {
36 2 50   2 1 104 my $cb = ref $_[-1] eq 'CODE' ? pop : sub { };
        2      
37 2         37 return shift->_command(Mojo::IOLoop->singleton, '/cancel', {'tag' => shift},
38             undef, $cb);
39             }
40              
41             sub command {
42 12 100   12   13309 my $cb = ref $_[-1] eq 'CODE' ? pop : undef;
43 12         41 my ($self, $cmd, $attr, $query) = @_;
44              
45             # non-blocking
46 12 100       46 return $self->_command(Mojo::IOLoop->singleton, $cmd, $attr, $query, $cb)
47             if $cb;
48              
49             # blocking
50 9         18 my $res;
51             $self->_command($self->ioloop, $cmd, $attr, $query,
52 9     9   33 sub { $_[0]->ioloop->stop(); $res = $_[2]; });
  9         47  
  9         212  
53 9         44 $self->ioloop->start();
54              
55 9         1123 return $res;
56             }
57              
58             sub command_p {
59 3     3   5190 Carp::croak 'Mojolicious v7.54+ is required for using promises.'
60             unless PROMISES;
61 3         9 my ($self, $cmd, $attr, $query) = @_;
62              
63 3         21 my $p = Mojo::Promise->new();
64             $self->_command(
65             Mojo::IOLoop->singleton,
66             $cmd, $attr, $query,
67             sub {
68 3 100   3   14 return $p->reject($_[1], $_[2]) if $_[1];
69 1         6 $p->resolve($_[2]);
70             }
71 3         84 );
72              
73 3         19 return $p;
74             }
75              
76             sub subscribe {
77 1 50   1 1 19 do { $_[0]->{error} = 'can\'t subscribe in blocking mode'; return; }
  0         0  
  0         0  
78             unless ref $_[-1] eq 'CODE';
79 1         4 my $cb = pop;
80 1         4 my ($self, $cmd, $attr, $query) = @_;
81 1         3 $attr->{'.subscription'} = 1;
82 1         5 return $self->_command(Mojo::IOLoop->singleton, $cmd, $attr, $query, $cb);
83             }
84              
85             sub _cleanup {
86 1     1   11 my $self = shift;
87             $_->{timeout} && $_->{loop}->remove($_->{timeout})
88 1   0     2 for values %{$self->{requests}};
  1         6  
89 1   33     2 $_ && $_->unsubscribe('close')->close() for values %{$self->{handles}};
  1         13  
90 1         243 delete $self->{handles};
91             }
92              
93             sub _close {
94 2     2   19 my ($self, $loop) = @_;
95 2         16 $self->_fail_all($loop, 'closed prematurely');
96 2         10 delete $self->{handles}{$loop};
97 2         19 delete $self->{responses}{$loop};
98             }
99              
100             sub _command {
101 28     28   352 my ($self, $loop, $cmd, $attr, $query, $cb) = @_;
102              
103 28         82 my $tag = ++$self->{_tag};
104 28         148 my $r = $self->{requests}{$tag} = {tag => $tag, loop => $loop, cb => $cb};
105 28         70 $r->{subscription} = delete $attr->{'.subscription'};
106              
107 28         62 warn "-- got request for command '$cmd' (tag: $tag)\n" if DEBUG;
108              
109 28         184 $r->{sentence} = encode_sentence($cmd, $attr, $query, $tag);
110 28         96 return $self->_send_request($r);
111             }
112              
113             sub _connect {
114 7     7   16 my ($self, $r) = @_;
115              
116 7         11 warn "-- creating new connection\n" if DEBUG;
117              
118 7         25 my $queue = $self->{queues}{$r->{loop}} = [$r];
119              
120 7         60 my $tls = $self->tls;
121 7 0       53 my $port = $self->port ? $self->{port} : $tls ? 8729 : 8728;
    50          
122              
123             $r->{loop}->client(
124             {
125             address => $self->host,
126             port => $port,
127             timeout => CONN_TIMEOUT,
128             tls => $tls,
129             tls_ciphers => 'HIGH'
130             } => sub {
131 7     7   13103 my ($loop, $err, $stream) = @_;
132              
133 7         24 delete $self->{queues}{$loop};
134              
135 7 100       23 if ($err) { $self->_fail($_, $err) for @$queue; return }
  2         11  
  2         136  
136              
137 5         11 warn "-- connection established\n" if DEBUG;
138              
139 5         13 $self->{handles}{$loop} = $stream;
140              
141 5         16 weaken $self;
142 5         34 $stream->on(read => sub { $self->_read($loop, $_[1]) });
  21         9771  
143             $stream->on(
144 5 0       47 error => sub { $self and $self->_fail_all($loop, $_[1]) });
  0         0  
145 5 50       41 $stream->on(close => sub { $self && $self->_close($loop) });
  2         501121  
146              
147             $self->_login(
148             $loop,
149             sub {
150 5 100       17 if ($_[1]) {
151 1         6 $_[0]->_fail($_, $_[1]) for @$queue;
152 1         5 $stream->close();
153 1         44 return;
154             }
155 4         27 $self->_write_sentence($stream, $_) for @$queue;
156             }
157 5         54 );
158             }
159 7         75 );
160              
161 7         1741 return $r->{tag};
162             }
163              
164             sub _enqueue {
165 10     10   25 my ($self, $r) = @_;
166 10 100       43 return $self->_connect($r) unless my $queue = $self->{queues}{$r->{loop}};
167 3         7 push @$queue, $r;
168 3         15 return $r->{tag};
169             }
170              
171             sub _fail_all {
172             $_[0]->_fail($_, $_[2])
173 2     2   7 for grep { $_->{loop} eq $_[1] } values %{$_[0]->{requests}};
  1         44  
  2         14  
174             }
175              
176             sub _finish {
177 28     28   89 my ($self, $r, $err) = @_;
178 28         147 delete $self->{requests}{$r->{tag}};
179 28 100       100 if (my $timer = $r->{timeout}) { $r->{loop}->remove($timer) }
  24         110  
180 28   100     1065 $r->{cb}->($self, ($self->{error} = $err // ''), $r->{data});
181             }
182              
183             sub _login {
184 5     5   16 my ($self, $loop, $cb) = @_;
185 5         9 warn "-- trying to log in\n" if DEBUG;
186              
187             $loop->delay(
188             sub {
189 5     5   3033 $self->_command($loop, '/login', {}, undef, $_[0]->begin());
190             },
191             sub {
192 5     5   155 my ($delay, $err, $res) = @_;
193 5 50       27 return $self->$cb($err) if $err;
194             my $secret
195 5         22 = md5_sum("\x00", $self->password, pack 'H*', $res->[0]{ret});
196 5         80 $self->_command($loop, '/login',
197             {name => $self->user, response => "00$secret"},
198             undef, $delay->begin());
199             },
200             sub {
201 5     5   169 $self->$cb($_[1]);
202             },
203 5         74 );
204             }
205              
206             sub _read {
207 21     21   63 my ($self, $loop, $bytes) = @_;
208              
209 21         39 warn "-- read bytes from socket: " . (length $bytes) . "\n" if DEBUG;
210              
211 21   66     164 my $response = $self->{responses}{$loop} ||= API::MikroTik::Response->new();
212 21         125 my $data = $response->parse(\$bytes);
213              
214 21         63 for (@$data) {
215 34 100       1104 next unless my $r = $self->{requests}{delete $_->{'.tag'}};
216 33         78 my $type = delete $_->{'.type'};
217 21   66     142 push @{$r->{data} ||= Mojo::Collection->new()}, $_
218 33 100 100     125 if %$_ && !$r->{subscription};
219              
220 33 100 100     314 if ($type eq '!re' && $r->{subscription}) {
    100 100        
    100          
221 1         16 $r->{cb}->($self, '', $_);
222              
223             }
224             elsif ($type eq '!done') {
225 15   66     67 $r->{data} ||= Mojo::Collection->new();
226 15         81 $self->_finish($r);
227              
228             }
229             elsif ($type eq '!trap' || $type eq '!fatal') {
230 7         27 $self->_fail($r, $_->{message});
231             }
232             }
233             }
234              
235             sub _send_request {
236 28     28   66 my ($self, $r) = @_;
237 28 100       139 return $self->_enqueue($r) unless my $stream = $self->{handles}{$r->{loop}};
238 18         57 return $self->_write_sentence($stream, $r);
239             }
240              
241             sub _write_sentence {
242 25     25   61 my ($self, $stream, $r) = @_;
243 25         43 warn "-- writing sentence for tag: $r->{tag}\n" if DEBUG;
244              
245 25         116 $stream->write($r->{sentence});
246              
247 25 100       850 return $r->{tag} if $r->{subscription};
248              
249 24         90 weaken $self;
250             $r->{timeout} = $r->{loop}
251 24     2   73 ->timer($self->timeout => sub { $self->_fail($r, 'response timeout') });
  2         1502647  
252              
253 24         2017 return $r->{tag};
254             }
255              
256             1;
257              
258              
259             =encoding utf8
260              
261             =head1 NAME
262              
263             API::MikroTik - Non-blocking interface to MikroTik API
264              
265             =head1 SYNOPSIS
266              
267             my $api = API::MikroTik->new();
268              
269             # Blocking
270             my $list = $api->command(
271             '/interface/print',
272             {'.proplist' => '.id,name,type'},
273             {type => ['ipip-tunnel', 'gre-tunnel'], running => 'true'}
274             );
275             if (my $err = $api->error) { die "$err\n" }
276             printf "%s: %s\n", $_->{name}, $_->{type} for @$list;
277              
278              
279             # Non-blocking
280             my $tag = $api->command(
281             '/system/resource/print',
282             {'.proplist' => 'board-name,version,uptime'} => sub {
283             my ($api, $err, $list) = @_;
284             ...;
285             }
286             );
287             Mojo::IOLoop->start();
288              
289             # Subscribe
290             $tag = $api->subscribe(
291             '/interface/listen' => sub {
292             my ($api, $err, $el) = @_;
293             ...;
294             }
295             );
296             Mojo::IOLoop->timer(3 => sub { $api->cancel($tag) });
297             Mojo::IOLoop->start();
298              
299             # Errors handling
300             $api->command(
301             '/random/command' => sub {
302             my ($api, $err, $list) = @_;
303              
304             if ($err) {
305             warn "Error: $err, category: " . $list->[0]{category};
306             return;
307             }
308              
309             ...;
310             }
311             );
312             Mojo::IOLoop->start();
313              
314             # Promises
315             $api->cmd_p('/interface/print')
316             ->then(sub { my $res = shift }, sub { my ($err, $attr) = @_ })
317             ->finally(sub { Mojo::IOLoop->stop() });
318             Mojo::IOLoop->start();
319              
320             =head1 DESCRIPTION
321              
322             Both blocking and non-blocking interface to a MikroTik API service. With queries,
323             command subscriptions and Promises/A+ (courtesy of an I/O loop). Based on
324             L and would work alongside L.
325              
326             =head1 ATTRIBUTES
327              
328             L implements the following attributes.
329              
330             =head2 error
331              
332             my $last_error = $api->error;
333              
334             Keeps an error from last L call. Empty string on successful commands.
335              
336             =head2 host
337              
338             my $host = $api->host;
339             $api = $api->host('border-gw.local');
340              
341             Host name or IP address to connect to. Defaults to C<192.168.88.1>.
342              
343             =head2 ioloop
344              
345             my $loop = $api->ioloop;
346             $api = $api->loop(Mojo::IOLoop->new());
347              
348             Event loop object to use for blocking operations, defaults to L
349             object.
350              
351             =head2 password
352              
353             my $pass = $api->password;
354             $api = $api->password('secret');
355              
356             Password for authentication. Empty string by default.
357              
358             =head2 port
359              
360             my $port = $api->port;
361             $api = $api->port(8000);
362              
363             API service port for connection. Defaults to C<8729> and C<8728> for TLS and
364             clear text connections respectively.
365              
366             =head2 timeout
367              
368             my $timeout = $api->timeout;
369             $api = $api->timeout(15);
370              
371             Timeout in seconds for sending request and receiving response before command
372             will be canceled. Default is C<10> seconds.
373              
374             =head2 tls
375              
376             my $tls = $api->tls;
377             $api = $api->tls(1);
378              
379             Use TLS for connection. Enabled by default.
380              
381             =head2 user
382              
383             my $user = $api->user;
384             $api = $api->user('admin');
385              
386             User name for authentication purposes. Defaults to C.
387              
388             =head1 METHODS
389              
390             =head2 cancel
391              
392             # subscribe to a command output
393             my $tag = $api->subscribe('/ping', {address => '127.0.0.1'} => sub {...});
394              
395             # cancel command after 10 seconds
396             Mojo::IOLoop->timer(10 => sub { $api->cancel($tag) });
397              
398             # or with callback
399             $api->cancel($tag => sub {...});
400              
401             Cancels background commands. Can accept a callback as last argument.
402              
403             =head2 cmd
404              
405             my $list = $api->cmd('/interface/print');
406              
407             An alias for L.
408              
409             =head2 cmd_p
410              
411             my $promise = $api->cmd_p('/interface/print');
412              
413             An alias for L.
414              
415             =head2 command
416              
417             my $command = '/interface/print';
418             my $attr = {'.proplist' => '.id,name,type'};
419             my $query = {type => ['ipip-tunnel', 'gre-tunnel'], running => 'true'};
420              
421             my $list = $api->command($command, $attr, $query);
422             die $api->error if $api->error;
423             for (@$list) {...}
424              
425             $api->command('/user/set', {'.id' => 'admin', comment => 'System admin'});
426              
427             # Non-blocking
428             $api->command('/ip/address/print' => sub {
429             my ($api, $err, $list) = @_;
430              
431             return if $err;
432              
433             for (@$list) {...}
434             });
435              
436             # Omit attributes
437             $api->command('/user/print', undef, {name => 'admin'} => sub {...});
438              
439             # Errors handling
440             $list = $api->command('/random/command');
441             if (my $err = $api->error) {
442             die "Error: $err, category: " . $list->[0]{category};
443             }
444              
445             Executes a command on a remote host and returns L with hashrefs
446             containing elements returned by a host. You can append a callback for non-blocking
447             calls.
448              
449             In a case of error it may return extra attributes to C or C API
450             replies in addition to error messages in an L attribute or an C<$err>
451             argument. You should never rely on defines of the result to catch errors.
452              
453             For a query syntax refer to L.
454              
455             =head2 command_p
456              
457             my $promise = $api->command_p('/interface/print');
458              
459             $promise->then(
460             sub {
461             my $res = shift;
462             ...
463             })->catch(sub {
464             my ($err, $attr) = @_;
465             });
466              
467             Same as L, but always performs requests non-blocking and returns a
468             L object instead of accepting a callback. L v7.54+ is
469             required for promises functionality.
470              
471             =head2 subscribe
472              
473             my $tag = $api->subscribe('/ping',
474             {address => '127.0.0.1'} => sub {
475             my ($api, $err, $res) = @_;
476             });
477              
478             Mojo::IOLoop->timer(
479             3 => sub { $api->cancel($tag) }
480             );
481              
482             Subscribe to an output of commands with continuous responses such as C or
483             C. Should be terminated with L.
484              
485             =head1 DEBUGGING
486              
487             You can set the API_MIKROTIK_DEBUG environment variable to get some debug output
488             printed to stderr.
489              
490             Also, you can change connection timeout with the API_MIKROTIK_CONNTIMEOUT variable.
491              
492             =head1 COPYRIGHT AND LICENSE
493              
494             Andre Parker, 2017-2018.
495              
496             This program is free software, you can redistribute it and/or modify it under
497             the terms of the Artistic License version 2.0.
498              
499             =head1 SEE ALSO
500              
501             L, L
502              
503             =cut
504