vici: Add a ruby gem providing a native vici interface
authorMartin Willi <martin@revosec.ch>
Wed, 1 Oct 2014 13:59:43 +0000 (15:59 +0200)
committerMartin Willi <martin@revosec.ch>
Fri, 10 Oct 2014 09:42:17 +0000 (11:42 +0200)
src/libcharon/plugins/vici/ruby/.gitignore [new file with mode: 0644]
src/libcharon/plugins/vici/ruby/lib/vici.rb [new file with mode: 0644]
src/libcharon/plugins/vici/ruby/vici.gemspec [new file with mode: 0644]

diff --git a/src/libcharon/plugins/vici/ruby/.gitignore b/src/libcharon/plugins/vici/ruby/.gitignore
new file mode 100644 (file)
index 0000000..c111b33
--- /dev/null
@@ -0,0 +1 @@
+*.gem
diff --git a/src/libcharon/plugins/vici/ruby/lib/vici.rb b/src/libcharon/plugins/vici/ruby/lib/vici.rb
new file mode 100644 (file)
index 0000000..e8a9ddc
--- /dev/null
@@ -0,0 +1,569 @@
+##
+# The Vici module implements a native ruby client side library for the
+# strongSwan VICI protocol. The Connection class provides a high-level
+# interface to issue requests or listen for events.
+#
+#  Copyright (C) 2014 Martin Willi
+#  Copyright (C) 2014 revosec AG
+#
+#  Permission is hereby granted, free of charge, to any person obtaining a copy
+#  of this software and associated documentation files (the "Software"), to deal
+#  in the Software without restriction, including without limitation the rights
+#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+#  copies of the Software, and to permit persons to whom the Software is
+#  furnished to do so, subject to the following conditions:
+#
+#  The above copyright notice and this permission notice shall be included in
+#  all copies or substantial portions of the Software.
+#
+#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+#  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+#  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+#  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+#  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+#  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+#  THE SOFTWARE.
+
+module Vici
+
+  ##
+  # Vici specific exception all others inherit from
+  class Error < StandardError
+  end
+
+  ##
+  # Error while parsing a vici message from the daemon
+  class ParseError < Error
+  end
+
+  ##
+  # Error while encoding a vici message from ruby data structures
+  class EncodeError < Error
+  end
+
+  ##
+  # Error while exchanging messages over the vici Transport layer
+  class TransportError < Error
+  end
+
+  ##
+  # Generic vici command execution error
+  class CommandError < Error
+  end
+
+  ##
+  # Error if an issued vici command is unknown by the daemon
+  class CommandUnknownError < CommandError
+  end
+
+  ##
+  # Error if a command failed to execute in the daemon
+  class CommandExecError < CommandError
+  end
+
+  ##
+  # Generic vici event handling error
+  class EventError < Error
+  end
+
+  ##
+  # Tried to register to / unregister from an unknown vici event
+  class EventUnknownError < EventError
+  end
+
+  ##
+  # Exception to raise from an event listening closure to stop listening
+  class StopEventListening < Exception
+  end
+
+
+  ##
+  # The Message class provides the low level encoding and decoding of vici
+  # protocol messages. Directly using this class is usually not required.
+  class Message
+
+    SECTION_START = 1
+    SECTION_END = 2
+    KEY_VALUE = 3
+    LIST_START = 4
+    LIST_ITEM = 5
+    LIST_END = 6
+
+    def initialize(data = "")
+      if data == nil
+        @root = Hash.new()
+      elsif data.is_a?(Hash)
+        @root = data
+      else
+        @encoded = data
+      end
+    end
+
+    ##
+    # Get the raw byte encoding of an on-the-wire message
+    def encoding
+      if @encoded == nil
+        @encoded = encode(@root)
+      end
+      @encoded
+    end
+
+    ##
+    # Get the root element of the parsed ruby data structures
+    def root
+      if @root == nil
+        @root = parse(@encoded)
+      end
+      @root
+    end
+
+    private
+
+    def encode_name(name)
+      [name.length].pack("c") << name
+    end
+
+    def encode_value(value)
+      if value.class != String
+        value = value.to_s
+      end
+      [value.length].pack("n") << value
+    end
+
+    def encode_kv(encoding, key, value)
+      encoding << KEY_VALUE << encode_name(key) << encode_value(value)
+    end
+
+    def encode_section(encoding, key, value)
+      encoding << SECTION_START << encode_name(key)
+      encoding << encode(value) << SECTION_END
+    end
+
+    def encode_list(encoding, key, value)
+      encoding << LIST_START << encode_name(key)
+      value.each do |item|
+        encoding << LIST_ITEM << encode_value(item)
+      end
+      encoding << LIST_END
+    end
+
+    def encode(node)
+      encoding = ""
+      node.each do |key, value|
+        case value.class
+          when String, Fixnum, true, false
+            encoding = encode_kv(encoding, key, value)
+          else
+            if value.is_a?(Hash)
+              encoding = encode_section(encoding, key, value)
+            elsif value.is_a?(Array)
+              encoding = encode_list(encoding, key, value)
+            else
+              encoding = encode_kv(encoding, key, value)
+            end
+        end
+      end
+      encoding
+    end
+
+    def parse_name(encoding)
+      len = encoding.unpack("c")[0]
+      name = encoding[1, len]
+      return encoding[(1 + len)..-1], name
+    end
+
+    def parse_value(encoding)
+      len = encoding.unpack("n")[0]
+      value = encoding[2, len]
+      return encoding[(2 + len)..-1], value
+    end
+
+    def parse(encoding)
+      stack = [Hash.new]
+      list = nil
+      while encoding.length != 0 do
+        type = encoding.unpack("c")[0]
+        encoding = encoding[1..-1]
+        case type
+          when SECTION_START
+            encoding, name = parse_name(encoding)
+            stack.push(stack[-1][name] = Hash.new)
+          when SECTION_END
+            if stack.length() == 1
+              raise ParseError, "unexpected section end"
+            end
+            stack.pop()
+          when KEY_VALUE
+            encoding, name = parse_name(encoding)
+            encoding, value = parse_value(encoding)
+            stack[-1][name] = value
+          when LIST_START
+            encoding, name = parse_name(encoding)
+            stack[-1][name] = []
+            list = name
+          when LIST_ITEM
+            raise ParseError, "unexpected list item" if list == nil
+            encoding, value = parse_value(encoding)
+            stack[-1][list].push(value)
+          when LIST_END
+            raise ParseError, "unexpected list end" if list == nil
+            list = nil
+          else
+            raise ParseError, "invalid type: #{type}"
+        end
+      end
+      if stack.length() > 1
+        raise ParseError, "unexpected message end"
+      end
+      stack[0]
+    end
+  end
+
+
+  ##
+  # The Transport class implements to low level segmentation of packets
+  # to the underlying transport stream.  Directly using this class is usually
+  # not required.
+  class Transport
+
+    CMD_REQUEST = 0
+    CMD_RESPONSE = 1
+    CMD_UNKNOWN = 2
+    EVENT_REGISTER = 3
+    EVENT_UNREGISTER = 4
+    EVENT_CONFIRM = 5
+    EVENT_UNKNOWN = 6
+    EVENT = 7
+
+    ##
+    # Create a transport layer using a provided socket for communication.
+    def initialize(socket)
+      @socket = socket
+      @events = Hash.new
+    end
+
+    ##
+    # Write a packet prefixed by its length over the transport socket. Type
+    # specifies the message, the optional label and message get appended.
+    def write(type, label, message)
+      encoding = ""
+      if label
+        encoding << label.length << label
+      end
+      if message
+        encoding << message.encoding
+      end
+      @socket.send([encoding.length + 1, type].pack("Nc") + encoding, 0)
+    end
+
+    ##
+    # Read a packet from the transport socket. Returns the packet type, and
+    # if available in the packet a label and the contained message.
+    def read
+      len = @socket.recv(4).unpack("N")[0]
+      encoding = @socket.recv(len)
+      type = encoding.unpack("c")[0]
+      len = 1
+      case type
+        when CMD_REQUEST, EVENT_REGISTER, EVENT_UNREGISTER, EVENT
+          label = encoding[2, encoding[1].unpack("c")[0]]
+          len += label.length + 1
+        when CMD_RESPONSE, CMD_UNKNOWN, EVENT_CONFIRM, EVENT_UNKNOWN
+          label = nil
+        else
+          raise TransportError, "invalid message: #{type}"
+      end
+      if encoding.length == len
+        return type, label, Message.new
+      end
+      return type, label, Message.new(encoding[len..-1])
+    end
+
+    def dispatch_event(name, message)
+      @events[name].each do |handler|
+        handler.call(name, message)
+      end
+    end
+
+    def read_and_dispatch_event
+      type, label, message = read
+      p
+      if type == EVENT
+        dispatch_event(label, message)
+      else
+        raise TransportError, "unexpected message: #{type}"
+      end
+    end
+
+    def read_and_dispatch_events
+      loop do
+        type, label, message = read
+        if type == EVENT
+          dispatch_event(label, message)
+        else
+          return type, label, message
+        end
+      end
+    end
+
+    ##
+    # Send a command with a given name, and optionally a message. Returns
+    # the reply message on success.
+    def request(name, message = nil)
+      write(CMD_REQUEST, name, message)
+      type, label, message = read_and_dispatch_events
+      case type
+        when CMD_RESPONSE
+          return message
+        when CMD_UNKNOWN
+          raise CommandUnknownError, name
+        else
+          raise CommandError, "invalid response for #{name}"
+      end
+    end
+
+    ##
+    # Register a handler method for the given event name
+    def register(name, handler)
+      write(EVENT_REGISTER, name, nil)
+      type, label, message = read_and_dispatch_events
+      case type
+        when EVENT_CONFIRM
+          if @events.has_key?(name)
+            @events[name] += [handler]
+          else
+            @events[name] = [handler];
+          end
+        when EVENT_UNKNOWN
+          raise EventUnknownError, name
+        else
+          raise EventError, "invalid response for #{name} register"
+      end
+    end
+
+    ##
+    # Unregister a handler method for the given event name
+    def unregister(name, handler)
+      write(EVENT_UNREGISTER, name, nil)
+      type, label, message = read_and_dispatch_events
+      case type
+        when EVENT_CONFIRM
+          @events[name] -= [handler]
+        when EVENT_UNKNOWN
+          raise EventUnknownError, name
+        else
+          raise EventError, "invalid response for #{name} unregister"
+      end
+    end
+  end
+
+
+  ##
+  # The Connection class provides the high-level interface to monitor, configure
+  # and control the IKE daemon. It takes a connected stream-oriented Socket for
+  # the communication with the IKE daemon.
+  #
+  # This class takes and returns ruby objects for the exchanged message data.
+  # * Sections get encoded as Hash, containing other sections as Hash, or
+  # * Key/Values, where the values are Strings as Hash values
+  # * Lists get encoded as Arrays with String values
+  # Non-String values that are not a Hash nor an Array get converted with .to_s
+  # during encoding.
+  class Connection
+
+    def initialize(socket)
+      @transp = Transport.new(socket)
+    end
+
+    ##
+    # List matching loaded connections. The provided closure is invoked
+    # for each matching connection.
+    def list_conns(match = nil, &block)
+      call_with_event("list-conns", Message.new(match), "list-conn", &block)
+    end
+
+    ##
+    # List matching active SAs. The provided closure is invoked for each
+    # matching SA.
+    def list_sas(match = nil, &block)
+      call_with_event("list-sas", Message.new(match), "list-sa", &block)
+    end
+
+    ##
+    # List matching installed policies. The provided closure is invoked
+    # for each matching policy.
+    def list_policies(match, &block)
+      call_with_event("list-policies", Message.new(match), "list-policy",
+                      &block)
+    end
+
+    ##
+    # List matching loaded certificates. The provided closure is invoked
+    # for each matching certificate definition.
+    def list_certs(match = nil, &block)
+      call_with_event("list-certs", Message.new(match), "list-cert", &block)
+    end
+
+    ##
+    # Load a connection into the daemon.
+    def load_conn(conn)
+      check_success(@transp.request("load-conn", Message.new(conn)))
+    end
+
+    ##
+    # Unload a connection from the daemon.
+    def unload_conn(conn)
+      check_success(@transp.request("unload-conn", Message.new(conn)))
+    end
+
+    ##
+    # Get the names of connections managed by vici.
+    def get_conns()
+      @transp.request("get-conns").root
+    end
+
+    ##
+    # Clear all loaded credentials.
+    def clear_creds()
+      check_success(@transp.request("clear-creds"))
+    end
+
+    ##
+    # Load a certificate into the daemon.
+    def load_cert(cert)
+      check_success(@transp.request("load-cert", Message.new(cert)))
+    end
+
+    ##
+    # Load a private key into the daemon.
+    def load_key(key)
+      check_success(@transp.request("load-key", Message.new(key)))
+    end
+
+    ##
+    # Load a shared key into the daemon.
+    def load_shared(shared)
+      check_success(@transp.request("load-shared", Message.new(shared)))
+    end
+
+    ##
+    # Load a virtual IP / attribute pool
+    def load_pool(pool)
+      check_success(@transp.request("load-pool", Message.new(pool)))
+    end
+
+    ##
+    # Unload a virtual IP / attribute pool
+    def unload_pool(pool)
+      check_success(@transp.request("unload-pool", Message.new(pool)))
+    end
+
+    ##
+    # Get the currently loaded pools.
+    def get_pools()
+      @transp.request("get-pools").root
+    end
+
+    ##
+    # Initiate a connection. The provided closure is invoked for each log line.
+    def initiate(options, &block)
+      check_success(call_with_event("initiate", Message.new(options),
+                    "control-log", &block))
+    end
+
+    ##
+    # Terminate a connection. The provided closure is invoked for each log line.
+    def terminate(options, &block)
+      check_success(call_with_event("terminate", Message.new(options),
+                    "control-log", &block))
+    end
+
+    ##
+    # Install a shunt/route policy.
+    def install(policy)
+      check_success(@transp.request("install", Message.new(policy)))
+    end
+
+    ##
+    # Uninstall a shunt/route policy.
+    def uninstall(policy)
+      check_success(@transp.request("uninstall", Message.new(policy)))
+    end
+
+    ##
+    # Reload strongswan.conf settings.
+    def reload_settings
+      check_success(@transp.request("reload-settings", nil))
+    end
+
+    ##
+    # Get daemon statistics and information.
+    def stats
+      @transp.request("stats", nil).root
+    end
+
+    ##
+    # Get daemon version information
+    def version
+      @transp.request("version", nil).root
+    end
+
+    ##
+    # Listen for a set of event messages. This call is blocking, and invokes
+    # the passed closure for each event received. The closure receives the
+    # event name and the event message as argument. To stop listening, the
+    # closure may raise a StopEventListening exception, the only catched
+    # exception.
+    def listen_events(events, &block)
+      self.class.instance_eval do
+        define_method(:listen_event) do |label, message|
+          block.call(label, message.root)
+        end
+      end
+      events.each do |event|
+        @transp.register(event, method(:listen_event))
+      end
+      begin
+        loop do
+          @transp.read_and_dispatch_event
+        end
+      rescue StopEventListening
+      ensure
+        events.each do |event|
+          @transp.unregister(event, method(:listen_event))
+        end
+      end
+    end
+
+    ##
+    # Issue a command request, but register for a specific event while the
+    # command is active. VICI uses this mechanism to stream potentially large
+    # data objects continuously. The provided closure is invoked for all
+    # event messages.
+    def call_with_event(command, request, event, &block)
+      self.class.instance_eval do
+        define_method(:call_event) do |label, message|
+          block.call(message.root)
+        end
+      end
+      @transp.register(event, method(:call_event))
+      begin
+        reply = @transp.request(command, request)
+      ensure
+        @transp.unregister(event, method(:call_event))
+      end
+      reply
+    end
+
+    ##
+    # Check if the reply of a command indicates "success", otherwise raise a
+    # CommandExecError exception
+    def check_success(reply)
+      root = reply.root
+      if root["success"] != "yes"
+        raise CommandExecError, root["errmsg"]
+      end
+      root
+    end
+  end
+end
diff --git a/src/libcharon/plugins/vici/ruby/vici.gemspec b/src/libcharon/plugins/vici/ruby/vici.gemspec
new file mode 100644 (file)
index 0000000..36bc21b
--- /dev/null
@@ -0,0 +1,16 @@
+Gem::Specification.new do |s|
+  s.name          = "vici"
+  s.version       = "0.0.1"
+  s.authors       = ["Martin Willi"]
+  s.email         = ["martin@strongswan.ch"]
+  s.description   = %q{
+     The strongSwan VICI protocol allows external application to monitor,
+     configure and control the IKE daemon charon. This ruby gem provides a
+     native client side implementation of the VICI protocol, well suited to
+     script automated tasks in a relaible way.
+  }
+  s.summary       = "Native ruby interface for strongSwan VICI"
+  s.homepage      = "https://wiki.strongswan.org/projects/strongswan/wiki/Vici"
+  s.license       = "MIT"
+  s.files         = "lib/vici.rb"
+end