API Ruby class

From MikroTik Wiki
Revision as of 04:36, 19 December 2009 by Astounding (talk | contribs) (Initial wiki documentation revision regarding the Ruby API)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to: navigation, search

Class

From Aaron's web site:


#!/usr/local/bin/ruby
########################################################################
# FILE:     mtik.rb -- A Ruby MikroTik API utility
# VERSION:  2.0.1
#
# Written by Aaron D. Gifford - http://www.aarongifford.com/
#   If you use this, I would enjoy hearing from you:
#     http://www.aarongifford.com/leaveanote.html
#
# Copyright (c) 2009, InfoWest, Inc.
# All Rights Reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, the above list of authors and contributors, this list of
#    conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
# 3. Neither the name of the author(s) or copyright holder(s) nor the
#    names of any contributors may be used to endorse or promote products
#    derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S), AUTHOR(S) AND
# CONTRIBUTORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
# AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL
# IN NO EVENT SHALL THE COPYRIGHT HOLDER(S), AUTHOR(S), OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# DCONSEQUENTIAL AMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
# THE POSSIBILITY OF SUCH DAMAGE.
########################################################################
class MTikError < RuntimeError
end
class MTikTrapError < MTikError
end
class MTikFatalError < MTikError
end

## An API reply is just an array of response sentences.  Each sentence
## is a  key/value Hash object.  This is a separate class from the
## built-in Array class just to avoid polluting Array method space
## with the find_sentence method.
class MTikReply < Array
  def find_sentence(key)
    i = 0
    while i < self.length
      if self[i].key?(key)
        return self[i]
      end
      i += 1
    end
    return nil
  end
end

## An API request object is an array of words, the first of which is the
## command, the rest of which are optional arguments.
## NOTE: 
##   * All arguments must ALREADY be encoded in the MikroTik API
##     "=key=value" (or ".id=value") style.
##   * Any ".tag=tagvalue" arguments WILL BE IGNORED!  This software
##     creates a unique tag for EACH and EVERY request.
##   * A request is incomplete until a '!done' reply sentence has
##     been received.
class MTikRequest < Array
  @@tagspace = 0  ## For keeping all tags unique...

  def initialize(await_completion, command, *args, &callback)
    super()

    @reply            = MTikReply.new
    @command          = command
    @await_completion = await_completion
    @complete         = false

    if block_given?
      @callback = callback            ## Implicit block callback
    else
      @callback = args.delete_at(-1)  ## Expect explicitly passed Proc callback
    end

    ## Add command to the request sentence list:
    self.push(command)

    ## Add all arguments to the request sentence list:
    @arglist = Array.new
    self.addargs(args)

    ## Append a unique tag for the request:
    @tag = @@tagspace.to_s
    @@tagspace += 1
    self.push(".tag=#{@tag}")
  end

  def addargs(*args)
    ## Add all additional arguments to the request sentence list:
    args.flatten.each do |arg|
      ## Ignore any argument beginning with ".tag="
      next if /^\.tag=/.match(arg)
      @arglist.push(arg)
      self.push(arg)
    end
  end

  def callback(sentence)
    @callback.call(self, sentence)
  end

  ## This is an internal-to-class utility method:
  ## (The Fixnum class really should have a pack method.)
  def self.bytepack(num)
    s = String.new
    if RUBY_VERSIION >= '1.9.0'
      s.force_encoding(Encoding::BINARY)
    end
    x = self < 0 ? -num : num  ## Treat as unsigned
    while x > 0
      s += (x & 0xff).chr
      x >>= 8
    end
    return s
  end

  ## Another internal utility method:
  ## Convert a byte string to a MikroTik API "word":
  def to_tikword(str)
    str = str.dup
    if RUBY_VERSION >= '1.9.0'
      str.force_encoding(Encoding::BINARY)
    end
    if str.length < 0x80
      return str.length.chr + str
    elsif str.length < 0x4000
      return bytepack(str.length | 0x8000) + str
    elsif str.length < 0x200000
      return bytepack(str.length | 0xc00000) + str
    elsif str.length < 0x10000000
      return bytepack(str.length | 0xe0000000) + str
    elsif str.length < 0x0100000000
      return 0xf0.chr + bytepack(str.length) + str
    else
      raise RuntimeError.new(
        "String is too long to be encoded for " +
        "the MikroTik API using a 4-byte length!"
      )
    end
  end

  def request
    ## Encode the request for sending to the device:
    return self.map {|w| to_tikword(w)}.join + 0x00.chr
  end

  def done!
    return @complete = true
  end

  def done?
    return @complete
  end

  attr_reader  :command, :arglist, :tag, :await_completion, :reply
end

class MTik
  require 'socket'
  require 'digest/md5'

  ## Defaults:
  PORT = 8728
  USER = 'admin'
  PASS = ''
  CONN_TIMEOUT = 60
  CMD_TIMEOUT  = 60

  def initialize(args)
    @sock         = nil
    @requests     = Hash.new
    @host         = args[:host]
    @port         = args[:port] || MTik::PORT
    @user         = args[:user] || MTik::USER
    @pass         = args[:pass] || MTik::PASS
    @conn_timeout = args[:conn_timeout] || MTik::CONN_TIMEOUT
    @cmd_timeout  = args[:cmd_timeout]  || MTik::CMD_TIMEOUT
    @data         = ''
    @outstanding = 0

    ## Initiate connection and immediately login to device:
    login
  end

  attr_reader :requests, :host, :port, :user, :pass, :conn_timeout, :cmd_timeout, :outstanding

  ## Ruby really needs a String#pack -- If it had one,
  ## "deadbeef".pack("H*") would work instead of this kludge:
  def hexpack(str)
    return str.length % 2 == 0 ?
      [str].pack('H'+str.length.to_s) :
      [str[0,1]].pack('h') + [str[1,str.length-1]].pack('H'+(str.length-1).to_s)
  end

  def login
    connect
    return unless connected?
    get_reply('/login') do |req, sentence|
      ## Make sure the reply has the info we expect:
      if req.reply.length != 1 || req.reply[0].length != 3 || !req.reply[0].key?('ret')
        raise MTikError.new("Login failed: unexpected reply to login attempt.")
      end

      ## Grab the challenge from first (only) sentence in the reply:
      challenge = hexpack(req.reply[0]['ret'])

      ## Generate reply MD5 hash and convert binary hash to hex string:
      response  = Digest::MD5.hexdigest(0.chr + @pass + challenge)
      ## Send reply login command:
      get_reply('/login', '=name=' + @user, '=response=00' + response) do |req, sentence|
        if req.reply[0].key?('!trap')
          raise MTikError.new("Login failed: " + (req.reply[0].key?('message') ? req.reply[0]['message'] : 'Unknown error.'))
        end
        unless req.reply.length == 1 && req.reply[0].length == 2 && req.reply[0].key?('!done')
          @sock.close
          @sock = nil
          raise MTikError.new('Login failed: Unknown response to login.')
        end
      end
    end
  end

  def connect
    return unless @sock.nil?
    ## TODO: Enclose in begin/rescue/end and catch more errors,
    ## possibly try again a few times... Implement connection
    ## timeout stuff (and catch timeout errors)
    begin
      @sock = TCPSocket::new(@host, @port)
    rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
      @sock = nil
      raise e ## Re-raise the exception
    end
  end

  ## Wait for and read exactly one sentence, regardless of content:
  def get_sentence
    ## TODO: Implement timeouts, detect disconnection, maybe do auto-reconnect
    if @sock.nil?
      raise MTikError.new("Cannot retrieve reply sentence--not connected.")
    end
    sentence = Hash.new
    oldlen = -1
    while true ## read-data loop
      if @data.length == oldlen
        sleep(1)  ## Wait for some more data
      else
        while true  ## word parsing loop
          bytes, word = get_tikword(@data)
          @data[0, bytes] = ''
          if word.nil?
            break
          end
          if word.length == 0
            ## Received END-OF-SENTENCE
            if sentence.length == 0
              raise MTikError.new("Received END-OF-SENTENCE from device with no sentence data.")
            end
            ## Debugging or verbose, show the received sentence:
            if $DEBUG || $VERBOSE
              sentence.each do |k, v|
                if v.nil?
                  STDERR.print ">>> '#{k}' (#{k.length})\n"
                else
                  STDERR.print ">>> '#{k}=#{v}' (#{k.length+v.length+1})\n"
                end
              end
              STDERR.print ">>> END-OF SENTENCE\n\n"
            end
            if sentence.key?('!fatal')
              ## Fatal error (or '/quit'):
              close  ## Assume disconnection
            end
            ## Finished. Return the sentence:
            return sentence
          else
            ## Add word to sentence
            if m = /^=?([^=]+)=(.*)$/.match(word)
              sentence[m[1]] = m[2]
            else
              sentence[word] = nil
            end
          end
        end  ## word parsing loop
      end
      ## Read some more data:
      oldlen = @data.length
      @data += @sock.recv(8192)
    end  ## read-data loop
  end

  ## Read one or more reply sentences:
  ## TODO: Implement timeouts, detect disconnection, maybe do auto-reconnect
  def wait_for_reply
    ## Sanity check:
    if @data.length > 0
      raise MTikError.new("An unexpected #{@data.length} bytes were found from a previous reply. API utility may be buggy.\n")
    end
    if @requests.length < 1 || @outstanding < 1
      raise MTikError.new("Cannot retrieve reply--No request was made. (#{@outstanding} outstanding of #{@requests.length} requests)")
    end
    ## SENTENCE READING LOOP:
    begin

      ## Fetch a sentence:
      sentence = get_sentence

      ## Check for '!fatal' before checking for a tag--'!fatal'
      ## is never(???) tagged: 
      if sentence.key?('!fatal')
        ## FATAL ERROR has occured! (Or a '/quit' command was issued...)
        if @data.length > 0
          raise MTikError.new("Sanity check failed on receipt of '!fatal' message: #{@data.length} more bytes remain to be parsed. API utility may be buggy.")
        end

        quit = false
        ## Iterate over all incomplete requests:
        @requests.each_value do |r|
          unless r.done?
            r.done!
            @outstanding -= 1
            if r.await_completion
              ## Pass partial reply to callback along with '!fatal' sentence
              r.callback(sentence)
            end
            ## Was this a '/quit' command?
            if r.command == '/quit'
              quit = true
            end
          end
        end

        ## SANITY CHECK:
        if @outstanding != 0
          raise MTikError.new("Logic error: sanity check failed with #{@outstanding} outstanding (unfinished) commands awaiting completion.")
        end

        ## Raise fatal error if there wasn't a '/quit' command:
        unless quit
          raise MTikFatalError.new(sentence.key?('message') ? sentence['message'] : '')
        end
        ## On /quit, just return:
        return
      end

      ## We expect ALL sentences thus far to be tagged:
      unless sentence.key?('.tag')
        ## This code tags EVERY request, so NO RESPONSE should be untagged
        ## except maybe a '!fatal' error...
        raise MTikError.new("Unexected untagged response received.")
      end
      rtag = sentence['.tag']

      ## Find which request this reply sentence belongs to:
      unless @requests.key?(rtag)
        raise MTikError.new("Unknown tag '#{rtag}' found in response.")
      end
      request = @requests[rtag]

      ## Sanity check: No sentences should arrive for completed requests.
      if request.done?
        raise MTikError.new("Unexpected new reply sentence received for already-completed request.")
      end

      ## Add the sentence to the request's reply:
      request.reply.push(sentence)

      ## On '!done', flag the request response as complete:
      if sentence.key?('!done')
        request.done!
        @outstanding -= 1
        ## Pass the data to the callback:
        request.callback(sentence)
      else
        unless request.await_completion && !request.done?
          ## Pass the data to the callback:
          request.callback(sentence)
        end
      end
    ## Keep reading sentences as long as there is data to be parsed:
    end while @data.length > 0
  end

  def send_request(await_completion, command, *args, &callback)
    args.flatten!
    req = nil
    if await_completion.is_a?(MTikRequest)
      req = await_completion
      req.addargs(command)
      req.addargs(args)
      if block_given?
        raise MTikError.new("You cannot call the send_request method with a callback block if you pass an existing MTikRequest object.")
      end
    else
      unless block_given?
        callback = args.pop
      end
      req = MTikRequest.new(await_completion, command, args, callback)
    end
    @requests[req.tag] = req
    @outstanding += 1

    if $DEBUG || $VERBOSE
      STDERR.print "<<< '#{req.command}' (#{req.command.length})\n" if $DEBUG || $VERBOSE
      req.arglist.each do |x|
        STDERR.print "<<< '#{x}' (#{x.length})\n"
      end
    end
    STDERR.print "<<< END-OF-SENTENCE\n\n" if $DEBUG || $VERBOSE

    @sock.send(req.request, 0)
    return req
  end

  ## WARNING: Only use get_reply when all outstanding
  ## commands will complete, otherwise get_reply will
  ## hang in the wait_for_reply loop.
  def get_reply(command, *args, &callback)
    args.flatten!
    unless block_given?
      callback = args.pop
    end
    send_request(true, command, args, callback)
    while @outstanding > 0
      wait_for_reply
    end
  end

  def close
    return if @sock.nil?
    @sock.close
    @sock = nil
  end

  def connected?
    return @sock.nil? ? false : true
  end

  ## Because of differences in the Ruby 1.8.x vs 1.9.x 'String' class,
  ## add a 'cbyte' utility method that returns the character byte at the
  ## specified offset of the string that will work with either version
  ## (treating all strings as 8-bit binary encoded data in Ruby >= 1.9)
  if RUBY_VERSION >= '1.9.0'
    ## Ruby 1.9 supports multi-byte characters, so we
    ## force the string to be treated as 8-bit binary
    ## in order to extract just the byte we want. Also
    ## String#[] in 1.9 returns the character, NOT the
    ## integer byte value as 1.8 did, hence the #ord
    ## method call:
    def cbyte(str, offset)
      return str.encode(Encoding::BINARY)[offset].ord
    end
  else
    ## Ruby 1.8 doesn't support multi-byte characters,
    ## and String#[] works to return the byte of the
    ## specified character:
    def cbyte(str, offset)
      return str[offset]
    end
  end

  ## Parse binary string data and return the first 'Tik "word"
  ## found:
  def get_tikword(data)
    unless data.is_a?(String)
      raise ArgumentError.new("bad argument: expected String but got #{data.class}")
    end

    ## Be sure we're working in 8-bit binary (Ruby 1.9+):
    if RUBY_VERSION >= '1.9.0'
      data.force_encoding(Encoding::BINARY)
    end

    unless data.length > 0
      return 0, nil   ## Not enough data to parse
    end

    ## The first byte tells us how the word length is encoded:
    len = 0
    len_byte = cbyte(data, 0)
    if len_byte & 0x80 == 0
      len = len_byte & 0x7f
      i = 1
    elsif len_byte & 0x40 == 0
      unless data.length > 0x81
        return 0, nil   ## Not enough data to parse
      end
      len = ((len_byte & 0x3f) << 8) | cbyte(data, 1)
      i = 2
    elsif len_byte & 0x20 == 0
      unless data.length > 0x4002
        return 0, nil   ## Not enough data to parse
      end
      len = ((len_byte & 0x1f) << 16) | (cbyte(data, 1) << 8) | cbyte(data, 2)
      i = 3
    elsif len_byte & 0x10 == 0
      unless data.length > 0x200003
        return 0, nil   ## Not enough data to parse
      end
      len = ((len_byte & 0x0f) << 24) | (cbyte(data, 1) << 16) | (cbyte(data, 2) << 8) | cbyte(data, 3)
      i = 4
    elsif len_byte == 0xf0
      len = (cbyte(data, 1) << 24) | (cbyte(data, 2) << 16) | (cbyte(data, 3) << 8) | cbyte(data, 4)
      i = 5
    else
      ## This will also catch reserved control words where the first byte is >= 0xf8
      raise ArgumentError.new("bad argument: String length encoding is invalid")
    end
    if data.length - i < len
      return 0, nil   ## Not enough data to parse
    end
    return i + len, data[i, len]
  end

  ## An all-in-one class function to instantiate, connect, send one or
  ## more commands, retrieve the response(s), close the connection,
  ## and return the response(s):
  def self.command(args)
    tk = MTik.new(
      :host => args[:host],
      :user => args[:user],
      :pass => args[:pass],
      :port => args[:port],
      :conn_timeout => args[:conn_timeout],
      :cmd_timeout  => args[:cmd_timeout]
    )
    cmd = args[:command]
    replies = Array.new
    if cmd.is_a?(String)
      ## Single command, no arguments
      cmd = [ cmd ]
    end
    if cmd.is_a?(Array)
      ## Either a single command with arguments
      ## or multiple commands:
      if cmd[0].is_a?(Array)
        ## Array of arrays means multiple commands:
        cmd.each do |c|
          tk.send_request(true, c[0], c[1,c.length-1]) do |req, sentence|
            replies.push(req.reply)
          end
        end
      else
        ## Single command
        tk.send_request(true, cmd[0], cmd[1,cmd.length-1]) do |req, sentence|
          replies.push(req.reply)
        end
      end
    else
      raise ArgumentError.new("invalid command argument")
    end
    while tk.outstanding > 0
      tk.wait_for_reply
    end
    tk.send_request(true, '/quit') {}
    tk.wait_for_reply
    tk.close
    return replies
  end

  ## Act as an interactive client:
  def self.interactive_client(*argv)
    argv.flatten!
    old_verbose = $VERBOSE
    $VERBOSE = 1
    begin
      tk = MTik.new(:host => argv[0], :user => argv[1], :pass => argv[2])
    rescue MTikError, Errno::ECONNREFUSED => e
      print "=== LOGIN ERROR: #{e.message}\n"
      exit
    end

    while true
      print "\nCommand (/quit to end): "
      cmd = STDIN.gets.sub(/^\s+/, '').sub(/\s*[\r\n]*$/, '')
      maxreply = 0
      m = /^(\d+):/.match(cmd)
      unless m.nil?
        maxreply = m[1].to_i
        cmd.sub!(/^\d+:/, '')
      end
      next if cmd == ''
      break if cmd == '/quit'
      unless /^\/[a-zA-Z0-9]+/.match(cmd)
        print "=== INVALID COMMAND: #{cmd}\n" if $DEBUG || $VERBOSE
        break
      end
      print "=== COMMAND: #{cmd}\n" if $DEBUG || $VERBOSE
      args  = cmd.split(/\s+/)
      cmd   = args.shift
      trap  = false
      count = 0
      state = 0
      begin
        tk.send_request(false, cmd, args) do |req, sentence|
          if sentence.key?('!trap')
            trap = sentence
            print "=== TRAP: '" + (trap.key?('message') ? trap['message'] : "UNKNOWN") + "'\n\n"
          elsif sentence.key?('!re')
            count += 1
            if maxreply > 0 && count == maxreply
              state = 2
              tk.send_request(true, '/cancel', '=tag=' + req.tag) do |req, sentence|
                state = 1
              end
            end
          elsif !sentence.key?('!done') && !sentence.key?('!fatal')
            raise MTikError.new("Unknown or unexpected reply sentence type.")
          end
          if state == 0 && req.done?
            state = 1
          end
        end
        while state != 1
          tk.wait_for_reply
        end
      rescue MTikError => e
        print "=== ERROR: #{e.message}\n"
      end
      unless tk.connected?
        begin
          tk.login
        rescue MTikError => e
          print "=== LOGIN ERROR: #{e.message}\n"
          tk.close
          exit
        end
      end
    end

    tk.get_reply('/quit') do |req, sentence|
      print "=== SESSION TERMINATED: '" + (
        sentence.keys[1].nil? ?
        'Reason unknown.' :
        sentence.keys[1] + (
          sentence[sentence.keys[1]].nil? ? '' :
          sentence[sentence.keys[1]]
        )
      ) + "'\n\n"
    end
    unless tk.connected?
      print "=== Disconnected ===\n\n"
    end
    tk.close
    $VERBOSE = old_verbose
  end
end


#### Interactive client example:
if false
  MTik::interactive_client(ARGV)
end

#### One-shot command example:
if false
  $VERBOSE=1  ## Set how you want
  MTik::command(
    :host=>ARGV[0],
    :user=>ARGV[1],
    :pass=>ARGV[2],
    :command=>ARGV[3, ARGV.length-1]
  )
end

#### One-shot command example, with JSON-style response output:
if false
  ## Quick-n-dirty JSON-ifyer for this example:
  def json_str(str)
    newstr = "'"
    str.each_byte do |byte|
      if byte == 92     ## back-slash
        newstr += '\\\\'
      elsif byte == 34  ## double-quote
        newstr += '\\"'
      elseif byte == 39 ## apostrophe/single-quote
        newstr += '\\' + "'"
      elseif byte == 47 ## forward-slash
        newstr += '\\/'
      elsif byte == 8   ## backspace
        newstr += '\\b'
      elsif byte == 12  ## formfeed
        newstr += '\\f'
      elsif byte == 10  ## linefeed
        newstr += '\\n'
      elsif byte == 13  ## carriage return
        newstr += '\\r'
      elsif byte == 9   ## horizontal tab
        newstr += '\\t'
      elsif byte == 38  ## ampersand
        newstr += sprintf('\\u%04X', byte)
      elsif byte == 60  ## less-than
        newstr += sprintf('\\u%04X', byte)
      elsif byte == 62  ## greater-than
        newstr += sprintf('\\u%04X', byte)
      elsif byte >= 32 && byte <= 126
        newstr += byte.chr
      else
        newstr += sprintf('\\u%04X', byte)
      end
    end
    return newstr + "'"
  end

  ## Quick-n-dirty JSON-ifier for responses:
  def json_reply(response)
    return '[' +
    response.map do |sentence| 
      '{' +
      sentence.map do |key, value|
        json_str(key) + ': ' +
        if value.nil?
          'NULL'
        elsif /^-?(?:\d+(\.\d+)?|\d*\.\d+)$/.match(value)
          value
        else
          json_str(value)
        end
      end.join(',') +
      '}'
    end.join(',') +
    ']'
  end

  print json_reply(
    MTik::command(
      :host=>ARGV[0],
      :user=>ARGV[1],
      :pass=>ARGV[2],
      :command=>ARGV[3, ARGV.length-1]
    )[0]
  ) + "\n"
end


Examples

The above code includes several example ways to use the Ruby API, including an interactive command-line client. Just change the "if false" line above the section in question to enable any of the three examples.

Here is a sample run of the interactive client (with the appropriate if false changed to if true to enable it):

user@bsdhost:~$ ./mtik.rb 10.20.30.1 admin wrongpassword
<<< '/login' (6)
<<< END-OF-SENTENCE

>>> '!done' (5)
>>> 'ret=bf41fd4286417870c5eb86674a3b8fe4' (36)
>>> '.tag=0' (6)
>>> END-OF SENTENCE

<<< '/login' (6)
<<< '=name=admin' (11)
<<< '=response=0003a042937d84ca4bc4cf7da50aadd507' (44)
<<< END-OF-SENTENCE

>>> '!trap' (5)
>>> 'message=cannot log in' (21)
>>> '.tag=1' (6)
>>> END-OF SENTENCE

>>> '!done' (5)
>>> '.tag=1' (6)
>>> END-OF SENTENCE

=== LOGIN ERROR: Login failed: cannot log in
user@bsdhost:~$ 

Okay, that run was deliberately with the wrong password. Here's the login with the correct password:

user@bsdhost:~$ ./mtik.rb 10.20.30.1 admin correctpassword
<<< '/login' (6)
<<< END-OF-SENTENCE

>>> '!done' (5)
>>> 'ret=857e91c460620a02c3ca72ea7cf6c696' (36)
>>> '.tag=0' (6)
>>> END-OF SENTENCE

<<< '/login' (6)
<<< '=name=admin' (11)
<<< '=response=001a77aec14077ec267c5297969ba1fa24' (44)
<<< END-OF-SENTENCE

>>> '!done' (5)
>>> '.tag=1' (6)
>>> END-OF SENTENCE


Command (/quit to end):

At this point, the interactive client will accept MikroTik API commands in the format /command/name arg1 arg2 arg3 or also 12:/command/name arg1 arg2 arg3 where the 12: is a custom numeric prefix that tells the Ruby interactive client to auto-cancel the command in question after exactly 12 reply sentences are received, since otherwise a command with continuous output would hang the single-threaded interactive client in an endless reply-handling loop (until someone aborted it).

Arguments to API commands in this interactive client must ALREADY be in the API argument form. For example:

Command (/quit to end): /interface/getall ?name=ether1
=== COMMAND: /interface/getall ?name=ether1
<<< '/interface/getall' (17)
<<< '?name=ether1' (12)
<<< END-OF-SENTENCE

>>> '!re' (3)
>>> '.id=*5' (6)
>>> 'name=ether1' (11)
>>> 'type=ether' (10)
>>> 'mtu=1500' (8)
>>> 'l2mtu=1500' (10)
>>> 'bytes=26908361008/15001379552' (29)
>>> 'packets=34880279/26382227' (25)
>>> 'drops=0/0' (9)
>>> 'errors=5/0' (10)
>>> 'dynamic=false' (13)
>>> 'running=true' (12)
>>> 'disabled=false' (14)
>>> 'comment=' (8)
>>> '.tag=4' (6)
>>> END-OF SENTENCE

>>> '!done' (5)
>>> '.tag=4' (6)
>>> END-OF SENTENCE


Command (/quit to end):

Did you see how the argument ?name=ether1 properly prefixed the query parameter name with the question mark ? and also paired it via = with the query value?

By now, you may have noticed that this Ruby API implementation automatically adds a unique .tag to every command. That means if you specify a tag value, the Ruby code will ignore it and use its own. It adds a tag so that replies can be correctly matched to the appropriate request.

Now here's the same query again, only add another parameter, =interval=1 so that the command will repeatedly send output each second. To avoid the command continuing forever, it will be prefixed with 6: to limit the number of response sentences to exactly six before the interactive client will automagically issue an appropriate /cancel =tag=XYZ command to cancel it.

Command (/quit to end): 6:/interface/getall ?name=ether1=interval=1
=== COMMAND: /interface/getall ?name=ether1=interval=1
<<< '/interface/getall' (17)
<<< '?name=ether1' (12)
<<< '=interval=1' (11)
<<< END-OF-SENTENCE

>>> '!re' (3)
>>> '.id=*5' (6)
>>> 'name=ether1' (11)
>>> 'type=ether' (10)
>>> 'mtu=1524' (8)
>>> 'l2mtu=1524' (10)
>>> 'bytes=26909135851/15002882324' (29)
>>> 'packets=34886461/26387909' (25)
>>> 'drops=0/0' (9)
>>> 'errors=5/0' (10)
>>> 'dynamic=false' (13)
>>> 'running=true' (12)
>>> 'disabled=false' (14)
>>> 'comment=' (8)
>>> '.tag=2' (6)
>>> END-OF SENTENCE

>>> '!re' (3)
>>> '.id=*5' (6)
>>> 'name=ether1' (11)
>>> 'type=ether' (10)
>>> 'mtu=1524' (8)
>>> 'l2mtu=1524' (10)
>>> 'bytes=26909140098/15002892177' (29)
>>> 'packets=34886498/26387943' (25)
>>> 'drops=0/0' (9)
>>> 'errors=5/0' (10)
>>> 'dynamic=false' (13)
>>> 'running=true' (12)
>>> 'disabled=false' (14)
>>> 'comment=' (8)
>>> '.tag=2' (6)
>>> END-OF SENTENCE

>>> '!re' (3)
>>> '.id=*5' (6)
>>> 'name=ether1' (11)
>>> 'type=ether' (10)
>>> 'mtu=1524' (8)
>>> 'l2mtu=1524' (10)
>>> 'bytes=26909141508/15002893670' (29)
>>> 'packets=34886508/26387951' (25)
>>> 'drops=0/0' (9)
>>> 'errors=5/0' (10)
>>> 'dynamic=false' (13)
>>> 'running=true' (12)
>>> 'disabled=false' (14)
>>> 'comment=' (8)
>>> '.tag=2' (6)
>>> END-OF SENTENCE

>>> '!re' (3)
>>> '.id=*5' (6)
>>> 'name=ether1' (11)
>>> 'type=ether' (10)
>>> 'mtu=1524' (8)
>>> 'l2mtu=1524' (10)
>>> 'bytes=26909143624/15002895110' (29)
>>> 'packets=34886524/26387963' (25)
>>> 'drops=0/0' (9)
>>> 'errors=5/0' (10)
>>> 'dynamic=false' (13)
>>> 'running=true' (12)
>>> 'disabled=false' (14)
>>> 'comment=' (8)
>>> '.tag=2' (6)
>>> END-OF SENTENCE

>>> '!re' (3)
>>> '.id=*5' (6)
>>> 'name=ether1' (11)
>>> 'type=ether' (10)
>>> 'mtu=1524' (8)
>>> 'l2mtu=1524' (10)
>>> 'bytes=26909144116/15002895406' (29)
>>> 'packets=34886530/26387967' (25)
>>> 'drops=0/0' (9)
>>> 'errors=5/0' (10)
>>> 'dynamic=false' (13)
>>> 'running=true' (12)
>>> 'disabled=false' (14)
>>> 'comment=' (8)
>>> '.tag=2' (6)
>>> END-OF SENTENCE

>>> '!re' (3)
>>> '.id=*5' (6)
>>> 'name=ether1' (11)
>>> 'type=ether' (10)
>>> 'mtu=1524' (8)
>>> 'l2mtu=1524' (10)
>>> 'bytes=26909144824/15002896659' (29)
>>> 'packets=34886535/26387973' (25)
>>> 'drops=0/0' (9)
>>> 'errors=5/0' (10)
>>> 'dynamic=false' (13)
>>> 'running=true' (12)
>>> 'disabled=false' (14)
>>> 'comment=' (8)
>>> '.tag=2' (6)
>>> END-OF SENTENCE

<<< '/cancel' (7)
<<< '=tag=2' (6)
<<< END-OF-SENTENCE

>>> '!trap' (5)
>>> 'category=2' (10)
>>> 'message=interrupted' (19)
>>> '.tag=2' (6)
>>> END-OF SENTENCE

=== TRAP: 'interrupted'

>>> '!done' (5)
>>> '.tag=3' (6)
>>> END-OF SENTENCE

>>> '!done' (5)
>>> '.tag=2' (6)
>>> END-OF SENTENCE


Command (/quit to end):

TO COME: Some non-interactive examples of using the Ruby API.

Notes

  • This has only been testing using Ruby 1.9.1 and Ruby 1.8.7 on several FreeBSD hosts, though it should work identically on other Ruby installations.
  • Encoding/decoding longer words has NOT be thoroughly tested.
  • Timeouts and auto-reconnecting currently is NOT implemented.
  • The above code is single-threaded, but is probably be safe to use within a multi-threaded Ruby application (untested).
  • The 2.0 version uses an event/callback style to send commands and receive responses.
  • The 2.0 version DOES support multiple simultaneous commands, and supports commands that send back periodic continual responses, but you have to be careful to implement a main event loop for this functionality to work correctly.
  • Version 2.0.1 is a memory hog if you run for very long as it never frees up the request and reply objects created. A future version will expire completed request/reply sets.

See also