File Coverage

blib/lib/MikroTik/Client.pm
Criterion Covered Total %
statement 127 144 88.1
branch 35 48 72.9
condition 21 25 84.0
subroutine 36 41 87.8
pod 4 4 100.0
total 223 262 85.1


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