mtik 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/CHANGELOG.txt +29 -0
- data/LICENSE.txt +36 -0
- data/README.txt +64 -0
- data/VERSION.txt +1 -0
- data/examples/tikcli.rb +49 -0
- data/examples/tikcommand.rb +69 -0
- data/examples/tikfetch.rb +109 -0
- data/examples/tikjson.rb +68 -0
- data/lib/mtik.rb +244 -0
- data/lib/mtik/connection.rb +603 -0
- data/lib/mtik/error.rb +37 -0
- data/lib/mtik/fatalerror.rb +37 -0
- data/lib/mtik/reply.rb +50 -0
- data/lib/mtik/request.rb +265 -0
- data/lib/mtik/timeouterror.rb +37 -0
- metadata +67 -0
@@ -0,0 +1,603 @@
|
|
1
|
+
############################################################################
|
2
|
+
## A Ruby library implementing the Ruby MikroTik API
|
3
|
+
############################################################################
|
4
|
+
## Author:: Aaron D. Gifford - http://www.aarongifford.com/
|
5
|
+
## Copyright:: Copyright (c) 2009-2010, InfoWest, Inc.
|
6
|
+
## License:: BSD license
|
7
|
+
##
|
8
|
+
## Redistribution and use in source and binary forms, with or without
|
9
|
+
## modification, are permitted provided that the following conditions
|
10
|
+
## are met:
|
11
|
+
## 1. Redistributions of source code must retain the above copyright
|
12
|
+
## notice, the above list of authors and contributors, this list of
|
13
|
+
## conditions and the following disclaimer.
|
14
|
+
## 2. Redistributions in binary form must reproduce the above copyright
|
15
|
+
## notice, this list of conditions and the following disclaimer in the
|
16
|
+
## documentation and/or other materials provided with the distribution.
|
17
|
+
## 3. Neither the name of the author(s) or copyright holder(s) nor the
|
18
|
+
## names of any contributors may be used to endorse or promote products
|
19
|
+
## derived from this software without specific prior written permission.
|
20
|
+
##
|
21
|
+
## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S), AUTHOR(S) AND
|
22
|
+
## CONTRIBUTORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING,
|
23
|
+
## INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
24
|
+
## AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
|
25
|
+
## IN NO EVENT SHALL THE COPYRIGHT HOLDER(S), AUTHOR(S), OR CONTRIBUTORS BE
|
26
|
+
## LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
27
|
+
## DCONSEQUENTIAL AMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
28
|
+
## SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
29
|
+
## INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
30
|
+
## CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
31
|
+
## ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
|
32
|
+
## THE POSSIBILITY OF SUCH DAMAGE.
|
33
|
+
############################################################################
|
34
|
+
|
35
|
+
## The MTik::Connection class is the workhorse where most stuff gets done.
|
36
|
+
## Create an instance of this object to connect to a MikroTik device via
|
37
|
+
## the API and execute commands (requests) and receive responses (replies).
|
38
|
+
class MTik::Connection
|
39
|
+
require 'socket'
|
40
|
+
require 'digest/md5'
|
41
|
+
|
42
|
+
## Initialize/construct the new _MTik_ object. One or more
|
43
|
+
## key/value pair style arguments must be specified. The one
|
44
|
+
## required argument is the host or IP of the device to connect
|
45
|
+
## to.
|
46
|
+
## _host_ :: This is the only _required_ argument. Example:
|
47
|
+
## + :host => "rb411.example.org" +
|
48
|
+
## _port_ :: Override the default API port (8728)
|
49
|
+
## _user_ :: Override the default API username ('admin')
|
50
|
+
## _pass_ :: Override the default API password (blank)
|
51
|
+
## _conn_timeout_ :: Override the default connection
|
52
|
+
## timeout (60 seconds) -- *NOT USED*
|
53
|
+
## _cmd_timeout_ :: Override the default command timeout
|
54
|
+
## (60 seconds) -- the number of seconds
|
55
|
+
## to wait for additional API input.
|
56
|
+
def initialize(args)
|
57
|
+
@sock = nil
|
58
|
+
@requests = Hash.new
|
59
|
+
@host = args[:host]
|
60
|
+
@port = args[:port] || MTik::PORT
|
61
|
+
@user = args[:user] || MTik::USER
|
62
|
+
@pass = args[:pass] || MTik::PASS
|
63
|
+
@conn_timeout = args[:conn_timeout] || MTik::CONN_TIMEOUT
|
64
|
+
@cmd_timeout = args[:cmd_timeout] || MTik::CMD_TIMEOUT
|
65
|
+
@data = ''
|
66
|
+
@parsing = false ## Recursion flag
|
67
|
+
|
68
|
+
## Initiate connection and immediately login to device:
|
69
|
+
login
|
70
|
+
end
|
71
|
+
|
72
|
+
## Return the number of currently outstanding requests
|
73
|
+
def outstanding
|
74
|
+
return @requests.length
|
75
|
+
end
|
76
|
+
attr_reader :requests, :host, :port, :user, :pass, :conn_timeout, :cmd_timeout
|
77
|
+
|
78
|
+
## Utility to pack a binary string as an ASCII hex string
|
79
|
+
## (Ruby really needs pack method for the String class--like
|
80
|
+
## "deadbeef".pack("H*")--then this would be unnecessary.)
|
81
|
+
def hexpack(str)
|
82
|
+
return str.length % 2 == 0 ?
|
83
|
+
[str].pack('H'+str.length.to_s) :
|
84
|
+
[str[0,1]].pack('h') + [str[1,str.length-1]].pack('H'+(str.length-1).to_s)
|
85
|
+
end
|
86
|
+
|
87
|
+
## Connect and login to the device using the API
|
88
|
+
def login
|
89
|
+
connect
|
90
|
+
unless connected?
|
91
|
+
raise MTik::Error.new("Login failed: Unable to connect to device.")
|
92
|
+
end
|
93
|
+
|
94
|
+
## Send first /login command to obtain the challenge:
|
95
|
+
reply = get_reply('/login')
|
96
|
+
## Make sure the reply has the info we expect:
|
97
|
+
if reply.length != 1 || reply[0].length != 3 || !reply[0].key?('ret')
|
98
|
+
raise MTik::Error.new("Login failed: unexpected reply to login attempt.")
|
99
|
+
end
|
100
|
+
|
101
|
+
## Grab the challenge from first (only) sentence in the reply:
|
102
|
+
challenge = hexpack(reply[0]['ret'])
|
103
|
+
|
104
|
+
## Generate reply MD5 hash and convert binary hash to hex string:
|
105
|
+
response = Digest::MD5.hexdigest(0.chr + @pass + challenge)
|
106
|
+
|
107
|
+
## Send second /login command with our response:
|
108
|
+
reply = get_reply('/login', '=name=' + @user, '=response=00' + response)
|
109
|
+
if reply[0].key?('!trap')
|
110
|
+
raise MTik::Error.new("Login failed: " + (reply[0].key?('message') ? reply[0]['message'] : 'Unknown error.'))
|
111
|
+
end
|
112
|
+
unless reply.length == 1 && reply[0].length == 2 && reply[0].key?('!done')
|
113
|
+
@sock.close
|
114
|
+
@sock = nil
|
115
|
+
raise MTik::Error.new('Login failed: Unknown response to login.')
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
## Connect to the device
|
120
|
+
def connect
|
121
|
+
return unless @sock.nil?
|
122
|
+
## TODO: Perhaps catch more errors; implement connection timeout
|
123
|
+
begin
|
124
|
+
@sock = TCPSocket::new(@host, @port)
|
125
|
+
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ENETUNREACH,
|
126
|
+
Errno::EHOSTUNREACH => e
|
127
|
+
@sock = nil
|
128
|
+
raise e ## Re-raise the exception
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
## Wait for and read exactly one sentence, regardless of content:
|
133
|
+
def get_sentence
|
134
|
+
## TODO: Implement timeouts, detect disconnection, maybe do auto-reconnect
|
135
|
+
if @sock.nil?
|
136
|
+
raise MTik::Error.new("Cannot retrieve reply sentence--not connected.")
|
137
|
+
end
|
138
|
+
sentence = Hash.new
|
139
|
+
oldlen = -1
|
140
|
+
while true ## read-data loop
|
141
|
+
if @data.length == oldlen
|
142
|
+
sleep(1) ## Wait for some more data
|
143
|
+
else
|
144
|
+
while true ## word parsing loop
|
145
|
+
bytes, word = get_tikword(@data)
|
146
|
+
@data[0, bytes] = ''
|
147
|
+
if word.nil?
|
148
|
+
break
|
149
|
+
end
|
150
|
+
if word.length == 0
|
151
|
+
## Received END-OF-SENTENCE
|
152
|
+
if sentence.length == 0
|
153
|
+
raise MTik::Error.new("Received END-OF-SENTENCE from device with no sentence data.")
|
154
|
+
end
|
155
|
+
## Debugging or verbose, show the received sentence:
|
156
|
+
if MTik::debug || MTik::verbose
|
157
|
+
sentence.each do |k, v|
|
158
|
+
if v.nil?
|
159
|
+
STDERR.print ">>> '#{k}' (#{k.length})\n"
|
160
|
+
else
|
161
|
+
STDERR.print ">>> '#{k}=#{v}' (#{k.length+v.length+1})\n"
|
162
|
+
end
|
163
|
+
end
|
164
|
+
STDERR.print ">>> END-OF SENTENCE\n\n"
|
165
|
+
end
|
166
|
+
if sentence.key?('!fatal')
|
167
|
+
## Fatal error (or '/quit'):
|
168
|
+
close ## Assume disconnection
|
169
|
+
end
|
170
|
+
## Finished. Return the sentence:
|
171
|
+
return sentence
|
172
|
+
else
|
173
|
+
## Add word to sentence
|
174
|
+
if m = /^=?([^=]+)=(.*)$/.match(word)
|
175
|
+
sentence[m[1]] = m[2]
|
176
|
+
else
|
177
|
+
sentence[word] = nil
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end ## word parsing loop
|
181
|
+
end
|
182
|
+
oldlen = @data.length
|
183
|
+
## Read some more data IF any is available:
|
184
|
+
sel = IO.select([@sock],nil,[@sock], @cmd_timeout)
|
185
|
+
if sel.nil?
|
186
|
+
raise MTik::TimeoutError.new(
|
187
|
+
"Time-out while awaiting data with #{outstanding} pending " +
|
188
|
+
"requests: '" + @requests.values.map{|req| req.command}.join("' ,'") + "'"
|
189
|
+
)
|
190
|
+
end
|
191
|
+
if sel[0].length == 1
|
192
|
+
@data += @sock.recv(8192)
|
193
|
+
elsif sel[2].length == 1
|
194
|
+
raise MTik::Error.new(
|
195
|
+
"I/O (select) error while awaiting data with #{outstanding} pending " +
|
196
|
+
"requests: '" + @requests.values.map{|req| req.command}.join("' ,'") + "'"
|
197
|
+
)
|
198
|
+
end
|
199
|
+
end ## read-data loop
|
200
|
+
end
|
201
|
+
|
202
|
+
## Keep reading replies until ALL outstanding requests have completed
|
203
|
+
def wait_all
|
204
|
+
while outstanding > 0
|
205
|
+
wait_for_reply
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
## Keep reading replies until a SPECIFIC command has completed.
|
210
|
+
def wait_for_request(req)
|
211
|
+
while !req.done?
|
212
|
+
wait_for_reply
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
## Read one or more reply sentences.
|
217
|
+
## TODO: Implement timeouts, detect disconnection, maybe do auto-reconnect
|
218
|
+
def wait_for_reply
|
219
|
+
## Sanity check:
|
220
|
+
if @data.length > 0 && !@parsing
|
221
|
+
raise MTik::Error.new("An unexpected #{@data.length} bytes were found from a previous reply. API utility may be buggy.\n")
|
222
|
+
end
|
223
|
+
if @requests.length < 1
|
224
|
+
raise MTik::Error.new("Cannot retrieve reply--No request was made.")
|
225
|
+
end
|
226
|
+
|
227
|
+
## SENTENCE READING LOOP:
|
228
|
+
oldparsing = @parsing
|
229
|
+
@parsing = true
|
230
|
+
begin
|
231
|
+
## Fetch a sentence:
|
232
|
+
sentence = get_sentence ## This call must be ATOMIC or re-entrant safety fails
|
233
|
+
|
234
|
+
## Check for '!fatal' before checking for a tag--'!fatal'
|
235
|
+
## is never(???) tagged:
|
236
|
+
if sentence.key?('!fatal')
|
237
|
+
## FATAL ERROR has occured! (Or a '/quit' command was issued...)
|
238
|
+
if @data.length > 0
|
239
|
+
raise MTik::Error.new("Sanity check failed on receipt of '!fatal' message: #{@data.length} more bytes remain to be parsed. API utility may be buggy.")
|
240
|
+
end
|
241
|
+
|
242
|
+
quit = false
|
243
|
+
## Iterate over all incomplete requests:
|
244
|
+
@requests.each_value do |r|
|
245
|
+
if r.done?
|
246
|
+
raise MTik::Error.new("Sanity check failed: an outstanding request was flagged as done!")
|
247
|
+
end
|
248
|
+
@requests.delete(r.tag)
|
249
|
+
r.done!
|
250
|
+
if r.await_completion
|
251
|
+
## Pass partial reply to callback along with '!fatal' sentence
|
252
|
+
r.callback(sentence)
|
253
|
+
end
|
254
|
+
## Was this a '/quit' command?
|
255
|
+
if r.command == '/quit'
|
256
|
+
quit = true
|
257
|
+
## Attach the untagged '!fatal' reply to the '/quit' command:
|
258
|
+
r.reply.push(sentence)
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
## Raise fatal error if there wasn't a '/quit' command:
|
263
|
+
unless quit
|
264
|
+
raise MTik::FatalError.new(sentence.key?('message') ? sentence['message'] : '')
|
265
|
+
end
|
266
|
+
## On /quit, just return:
|
267
|
+
@parsing = oldparsing
|
268
|
+
return
|
269
|
+
end
|
270
|
+
|
271
|
+
## We expect ALL sentences thus far to be tagged:
|
272
|
+
unless sentence.key?('.tag')
|
273
|
+
## This code tags EVERY request, so NO RESPONSE should be untagged
|
274
|
+
## except maybe a '!fatal' error...
|
275
|
+
raise MTik::Error.new("Unexected untagged response received.")
|
276
|
+
end
|
277
|
+
rtag = sentence['.tag']
|
278
|
+
|
279
|
+
## Find which request this reply sentence belongs to:
|
280
|
+
unless @requests.key?(rtag)
|
281
|
+
raise MTik::Error.new("Unknown tag '#{rtag}' found in response.")
|
282
|
+
end
|
283
|
+
request = @requests[rtag]
|
284
|
+
|
285
|
+
## Sanity check: No sentences should arrive for completed requests.
|
286
|
+
if request.done?
|
287
|
+
raise MTik::Error.new("Unexpected new reply sentence received for already-completed request.")
|
288
|
+
end
|
289
|
+
|
290
|
+
## Add the sentence to the request's reply:
|
291
|
+
request.reply.push(sentence)
|
292
|
+
|
293
|
+
## On '!done', flag the request response as complete:
|
294
|
+
if sentence.key?('!done')
|
295
|
+
request.done!
|
296
|
+
## Pass the data to the callback:
|
297
|
+
request.callback(sentence)
|
298
|
+
## Remove the request:
|
299
|
+
@requests.delete(request.tag)
|
300
|
+
else
|
301
|
+
unless request.await_completion && !request.done?
|
302
|
+
## Pass the data to the callback:
|
303
|
+
request.callback(sentence)
|
304
|
+
end
|
305
|
+
end
|
306
|
+
## Keep reading sentences as long as there is data to be parsed:
|
307
|
+
end while @data.length > 0
|
308
|
+
@parsing = oldparsing
|
309
|
+
end
|
310
|
+
|
311
|
+
## Alias of send_request() with param 1 set to false
|
312
|
+
def request_each(command, *args, &callback)
|
313
|
+
return send_request(false, command, *args, &callback)
|
314
|
+
end
|
315
|
+
|
316
|
+
## Alias of send_request() with param 1 set to true
|
317
|
+
def request(command, *args, &callback)
|
318
|
+
return send_request(true, command, *args, &callback)
|
319
|
+
end
|
320
|
+
|
321
|
+
## Send a request to the device.
|
322
|
+
## +await_completion+ :: Boolean indicating whether to execute callbacks
|
323
|
+
## only once upon request completion (if set to _true_)
|
324
|
+
## or to execute for every received complete sentence
|
325
|
+
## (if set to _false_). ALTERNATIVELY, this parameter
|
326
|
+
## may be an object (MTik::Request) to be sent, in which
|
327
|
+
## case any command and/or arguments will be treated as
|
328
|
+
## additional arguments to the request contained in the
|
329
|
+
## object.
|
330
|
+
## +command+ :: The command to be executed.
|
331
|
+
## +args+ :: Zero or more arguments to the command
|
332
|
+
## +callback+ :: Proc/lambda code (or code block if not provided as
|
333
|
+
## an argument) to be called. (See the +await_completion+
|
334
|
+
##
|
335
|
+
def send_request(await_completion, command, *args, &callback)
|
336
|
+
if await_completion.is_a?(MTik::Request)
|
337
|
+
if req.done?
|
338
|
+
raise MTik::Error.new("Cannot MTik#send_request() with an already-completed MTik::Request object.")
|
339
|
+
end
|
340
|
+
req = await_completion
|
341
|
+
req.addarg(command)
|
342
|
+
req.addargs(*args)
|
343
|
+
else
|
344
|
+
req = MTik::Request.new(await_completion, command, *args, &callback)
|
345
|
+
end
|
346
|
+
## Add the new outstanding request
|
347
|
+
@requests[req.tag] = req
|
348
|
+
|
349
|
+
if MTik::debug || MTik::verbose
|
350
|
+
req.each do |x|
|
351
|
+
STDERR.print "<<< '#{x}' (#{x.length})\n"
|
352
|
+
end
|
353
|
+
end
|
354
|
+
STDERR.print "<<< END-OF-SENTENCE\n\n" if MTik::debug || MTik::verbose
|
355
|
+
|
356
|
+
req.conn(self) ## Associate the request to this connection object:
|
357
|
+
return req.send
|
358
|
+
end
|
359
|
+
|
360
|
+
## Send the request object over the socket
|
361
|
+
def xmit(req)
|
362
|
+
@sock.send(req.request, 0)
|
363
|
+
return req
|
364
|
+
end
|
365
|
+
|
366
|
+
## Send a command, then wait for the command to complete, then return
|
367
|
+
## the completed reply.
|
368
|
+
## _NOTE_ :: This call has its own event loop that will cycle
|
369
|
+
## until the command in question completes. You
|
370
|
+
## should:
|
371
|
+
## * NOT call get_reply with a command that may not
|
372
|
+
## complete with a "!done" response on its own
|
373
|
+
## (with no additional intervention); and
|
374
|
+
## * BE CAREFUL to understand how things interact if
|
375
|
+
## you mix this call with requests that generate
|
376
|
+
## continuous output.
|
377
|
+
## +command+ :: The command to execute
|
378
|
+
## +args+ :: Arguments (if any)
|
379
|
+
## +callback+ :: Proc/lambda or code block to act as callback
|
380
|
+
def get_reply(command, *args, &callback)
|
381
|
+
req = send_request(true, command, *args, &callback)
|
382
|
+
wait_for_request(req)
|
383
|
+
return req.reply
|
384
|
+
end
|
385
|
+
|
386
|
+
## This is exactly like get_reply() except that EACH
|
387
|
+
## sentence read will result in the passed Proc/block
|
388
|
+
## being called instead of just the final "!done" reply
|
389
|
+
def get_reply_each(command, *args, &callback)
|
390
|
+
req = send_request(false, command, *args, &callback)
|
391
|
+
wait_for_request(req)
|
392
|
+
return req.reply
|
393
|
+
end
|
394
|
+
|
395
|
+
## Close the connection.
|
396
|
+
def close
|
397
|
+
return if @sock.nil?
|
398
|
+
@sock.close
|
399
|
+
@sock = nil
|
400
|
+
end
|
401
|
+
|
402
|
+
## Is the connection open?
|
403
|
+
def connected?
|
404
|
+
return @sock.nil? ? false : true
|
405
|
+
end
|
406
|
+
|
407
|
+
## Because of differences in the Ruby 1.8.x vs 1.9.x 'String' class,
|
408
|
+
## a 'cbyte' utility method that returns the character byte at the
|
409
|
+
## specified offset of the supplied string is here defined so that
|
410
|
+
## there is a single consistent method that will work with either
|
411
|
+
## Ruby version (treating all strings as 8-bit binary data in Ruby 1.9+)
|
412
|
+
if RUBY_VERSION >= '1.9.0'
|
413
|
+
## Return the byte at the offset specified from the
|
414
|
+
## Ruby 1.9 8-bit binary string as an integer.
|
415
|
+
def cbyte(str, offset)
|
416
|
+
return str.encode(Encoding::BINARY)[offset].ord
|
417
|
+
end
|
418
|
+
else
|
419
|
+
## Return the byte at the offset specified from the
|
420
|
+
## Ruby 1.8.x character string (Ruby 1.8 doesn't
|
421
|
+
## support multi-byte characters so all characters
|
422
|
+
## are 8-bits in length).
|
423
|
+
def cbyte(str, offset)
|
424
|
+
return str[offset]
|
425
|
+
end
|
426
|
+
end
|
427
|
+
|
428
|
+
## Parse binary string data and return the first 'Tik "word"
|
429
|
+
## found:
|
430
|
+
def get_tikword(data)
|
431
|
+
unless data.is_a?(String)
|
432
|
+
raise ArgumentError.new("bad argument: expected String but got #{data.class}")
|
433
|
+
end
|
434
|
+
|
435
|
+
## Be sure we're working in 8-bit binary (Ruby 1.9+):
|
436
|
+
if RUBY_VERSION >= '1.9.0'
|
437
|
+
data.force_encoding(Encoding::BINARY)
|
438
|
+
end
|
439
|
+
|
440
|
+
unless data.length > 0
|
441
|
+
return 0, nil ## Not enough data to parse
|
442
|
+
end
|
443
|
+
|
444
|
+
## The first byte tells us how the word length is encoded:
|
445
|
+
len = 0
|
446
|
+
len_byte = cbyte(data, 0)
|
447
|
+
if len_byte & 0x80 == 0
|
448
|
+
len = len_byte & 0x7f
|
449
|
+
i = 1
|
450
|
+
elsif len_byte & 0x40 == 0
|
451
|
+
unless data.length > 0x81
|
452
|
+
return 0, nil ## Not enough data to parse
|
453
|
+
end
|
454
|
+
len = ((len_byte & 0x3f) << 8) | cbyte(data, 1)
|
455
|
+
i = 2
|
456
|
+
elsif len_byte & 0x20 == 0
|
457
|
+
unless data.length > 0x4002
|
458
|
+
return 0, nil ## Not enough data to parse
|
459
|
+
end
|
460
|
+
len = ((len_byte & 0x1f) << 16) | (cbyte(data, 1) << 8) | cbyte(data, 2)
|
461
|
+
i = 3
|
462
|
+
elsif len_byte & 0x10 == 0
|
463
|
+
unless data.length > 0x200003
|
464
|
+
return 0, nil ## Not enough data to parse
|
465
|
+
end
|
466
|
+
len = ((len_byte & 0x0f) << 24) | (cbyte(data, 1) << 16) | (cbyte(data, 2) << 8) | cbyte(data, 3)
|
467
|
+
i = 4
|
468
|
+
elsif len_byte == 0xf0
|
469
|
+
len = (cbyte(data, 1) << 24) | (cbyte(data, 2) << 16) | (cbyte(data, 3) << 8) | cbyte(data, 4)
|
470
|
+
i = 5
|
471
|
+
else
|
472
|
+
## This will also catch reserved control words where the first byte is >= 0xf8
|
473
|
+
raise ArgumentError.new("bad argument: String length encoding is invalid")
|
474
|
+
end
|
475
|
+
if data.length - i < len
|
476
|
+
return 0, nil ## Not enough data to parse
|
477
|
+
end
|
478
|
+
return i + len, data[i, len]
|
479
|
+
end
|
480
|
+
|
481
|
+
## Utility to execute the "/tool/fetch" command, instructing
|
482
|
+
## the device to download a file from the specified URL.
|
483
|
+
## Status updates are provided via the provided callback.
|
484
|
+
## _url_ :: The URL to fetch the file from
|
485
|
+
## _filename_ :: The filename to use on the device
|
486
|
+
## _callback_ :: Callback called for status updates. The three
|
487
|
+
## arguments passed to the callback are:
|
488
|
+
## _status_ :: Either 'downloading', 'connecting',
|
489
|
+
## 'failed', 'requesting', or 'finished',
|
490
|
+
## otherwise a '!trap' error occured,
|
491
|
+
## and the value is the trap message.
|
492
|
+
## _total_ :: Final expected file size in bytes
|
493
|
+
## _bytes_ :: Number of bytes transferred so far
|
494
|
+
def fetch(url, filename, &callback)
|
495
|
+
total = bytes = 0
|
496
|
+
status = ''
|
497
|
+
done = false
|
498
|
+
req = get_reply_each(
|
499
|
+
'/tool/fetch',
|
500
|
+
'=url=' + url,
|
501
|
+
'=dst-path=' + filename
|
502
|
+
) do |req, s|
|
503
|
+
if s.key?('!re') && !done
|
504
|
+
unless s.key?('status')
|
505
|
+
raise MTik::Error.new("Unknown response to '/tool/fetch': missing 'status' in response.")
|
506
|
+
end
|
507
|
+
status = s['status']
|
508
|
+
case status
|
509
|
+
when 'downloading'
|
510
|
+
total = s['total'].to_i
|
511
|
+
bytes = s['downloaded'].to_i
|
512
|
+
callback.call(status, total, bytes)
|
513
|
+
when 'connecting', 'requesting'
|
514
|
+
callback.call(status, 0, 0)
|
515
|
+
when 'failed', 'finished'
|
516
|
+
bytes = total if status == 'finished'
|
517
|
+
callback.call(status, total, bytes)
|
518
|
+
done = true
|
519
|
+
## Now terminate the download request (since it's done):
|
520
|
+
get_reply('/cancel', '=tag=' + req.tag) {}
|
521
|
+
else
|
522
|
+
raise MTik::Error.new("Unknown status in '/tool/fetch' response: '#{status}'")
|
523
|
+
end
|
524
|
+
elsif s.key?('!trap')
|
525
|
+
## Pass trap message back (unless finished--in which case we
|
526
|
+
## ignore the 'interrrupted' trap message):
|
527
|
+
callback.call(s['message'], total, bytes) if !done
|
528
|
+
end
|
529
|
+
end
|
530
|
+
end
|
531
|
+
|
532
|
+
## Utility to check and update MikroTik device settings within a
|
533
|
+
## specified subsection of the device.
|
534
|
+
def update_values(cmdpath, keyvaluepairs, &callback)
|
535
|
+
get_reply_each(cmdpath + '/getall') do |req, s|
|
536
|
+
if s.key?('!re')
|
537
|
+
## Iterate over each key/value pair and check if the current
|
538
|
+
## device subsection's "getall" matches one of the keys:
|
539
|
+
keyvaluepairs.each do |key, value|
|
540
|
+
## If the key is a String, it matches if the reply sentence
|
541
|
+
## has a matching key. If the key is a Regexp, then iterate
|
542
|
+
## over ALL sentence keys and find all items that match.
|
543
|
+
matchedkey = nil
|
544
|
+
if key.is_a?(String)
|
545
|
+
if s.key?(key)
|
546
|
+
matchedkey = key
|
547
|
+
end
|
548
|
+
elsif key.is_a?(Regexp)
|
549
|
+
s.each_key do |skey|
|
550
|
+
if key.match(skey)
|
551
|
+
matchedkey = skey
|
552
|
+
end
|
553
|
+
end
|
554
|
+
elsif key.is_a(Array)
|
555
|
+
## Iterate over each array item and perform matching on
|
556
|
+
## each String or Regexp therein:
|
557
|
+
key.each do |keyitem|
|
558
|
+
if keyitem.is_a?(String)
|
559
|
+
if s.key?(keyitem)
|
560
|
+
matchedkey = keyitem
|
561
|
+
end
|
562
|
+
elsif keyitem.is_a?(Regexp)
|
563
|
+
## Iterate over each sentence key and test matching
|
564
|
+
s.each_key do |skey|
|
565
|
+
if key.match(skey)
|
566
|
+
## Check setting's current value:
|
567
|
+
if value.is_a?(Proc)
|
568
|
+
v = value.call(skey, s[skey])
|
569
|
+
elsif value.is_a?(String)
|
570
|
+
v = value
|
571
|
+
else
|
572
|
+
raise MTik::Error.new("Invalid settings value class '#{value}' (expected String or Proc)")
|
573
|
+
end
|
574
|
+
if s[skey] != v
|
575
|
+
## Update setting from s[skey] to v
|
576
|
+
end
|
577
|
+
end
|
578
|
+
end
|
579
|
+
else
|
580
|
+
raise MTik::Error.new("Invalid settings match class '#{keyitem}' (expected Regexp or String)")
|
581
|
+
end
|
582
|
+
end
|
583
|
+
else
|
584
|
+
raise MTik::Error.new("Invalid settings match class '#{keyitem}' (expected Array, Regexp, or String)")
|
585
|
+
end
|
586
|
+
|
587
|
+
if s.key?(key)
|
588
|
+
## A key matches! && s[k] != v
|
589
|
+
oldv = s[k]
|
590
|
+
get_reply(cmdpath + '/set', '='+k+'='+v) do |req, s|
|
591
|
+
trap = req.reply.find_sentence('!trap')
|
592
|
+
unless trap.nil?
|
593
|
+
raise MTik::Error.new("Trap while executing '#{cmdpath}/set =#{k}=#{v}': #{trap['message']}")
|
594
|
+
end
|
595
|
+
callback.call(cmdpath + '/' + k, oldv, v)
|
596
|
+
end
|
597
|
+
end
|
598
|
+
end
|
599
|
+
end
|
600
|
+
end
|
601
|
+
end
|
602
|
+
end
|
603
|
+
|