Merge branch 'vici-python'
authorMartin Willi <martin@revosec.ch>
Wed, 18 Mar 2015 14:03:28 +0000 (15:03 +0100)
committerMartin Willi <martin@revosec.ch>
Wed, 18 Mar 2015 14:03:28 +0000 (15:03 +0100)
Introduce a Python Egg for the vici plugin, contributed by Björn Schuberg.

23 files changed:
.travis.yml
NEWS
configure.ac
scripts/test.sh
src/libcharon/plugins/vici/Makefile.am
src/libcharon/plugins/vici/README.md
src/libcharon/plugins/vici/python/.gitignore [new file with mode: 0644]
src/libcharon/plugins/vici/python/LICENSE [new file with mode: 0644]
src/libcharon/plugins/vici/python/MANIFEST.in [new file with mode: 0644]
src/libcharon/plugins/vici/python/Makefile.am [new file with mode: 0644]
src/libcharon/plugins/vici/python/setup.py.in [new file with mode: 0644]
src/libcharon/plugins/vici/python/vici/__init__.py [new file with mode: 0644]
src/libcharon/plugins/vici/python/vici/compat.py [new file with mode: 0644]
src/libcharon/plugins/vici/python/vici/exception.py [new file with mode: 0644]
src/libcharon/plugins/vici/python/vici/protocol.py [new file with mode: 0644]
src/libcharon/plugins/vici/python/vici/session.py [new file with mode: 0644]
src/libcharon/plugins/vici/python/vici/test/__init__.py [new file with mode: 0644]
src/libcharon/plugins/vici/python/vici/test/test_protocol.py [new file with mode: 0644]
src/libcharon/plugins/vici/ruby/Makefile.am
src/libcharon/plugins/vici/ruby/lib/vici.rb
src/libcharon/plugins/vici/vici_builder.c
src/libcharon/plugins/vici/vici_query.c
src/swanctl/commands/list_conns.c

index c7f0e84..46e7cfd 100644 (file)
@@ -8,6 +8,7 @@ before_install:
   - sudo apt-get update -qq
   - sudo apt-get install -qq bison flex gperf gettext
   - ./scripts/test.sh deps
+  - ./scripts/test.sh pydeps
 
 script:
   - ./autogen.sh
diff --git a/NEWS b/NEWS
index eb1ebe8..e6efa66 100644 (file)
--- a/NEWS
+++ b/NEWS
@@ -35,6 +35,10 @@ strongswan-5.3.0
   Windows 7 IKEv2 clients, which announces its services over the tunnel if the
   negotiated IPsec policy allows it.
 
+- For the vici plugin a Python Egg has been added to allow Python applications
+  to control or monitor the IKE daemon using the VICI interface, similar to the
+  existing ruby gem. The Python library has been contributed by Björn Schuberg.
+
 - EAP server methods now can fulfill public key constraints, such as rightcert
   or rightca. Additionally, public key and signature constraints can be
   specified for EAP methods in the rightauth keyword. Currently the EAP-TLS and
index 55dc32c..11d4f49 100644 (file)
@@ -69,6 +69,7 @@ ARG_WITH_SET([mpz_powm_sec],         [yes], [use the more side-channel resistant
 ARG_WITH_SET([dev-headers],          [no], [install strongSwan development headers to directory.])
 ARG_WITH_SET([printf-hooks],         [auto], [force the use of a specific printf hook implementation (auto, builtin, glibc, vstr).])
 ARG_WITH_SET([rubygemdir],           ["gem environment gemdir"], [path to install ruby gems to])
+ARG_WITH_SET([pythoneggdir],         ["main site-packages directory"], [path to install python eggs to to])
 
 if test -n "$PKG_CONFIG"; then
        systemdsystemunitdir_default=$($PKG_CONFIG --variable=systemdsystemunitdir systemd)
@@ -290,6 +291,7 @@ ARG_DISBL_SET([load-warning],   [disable the charon plugin load option warning i
 ARG_ENABL_SET([mediation],      [enable IKEv2 Mediation Extension.])
 ARG_ENABL_SET([unwind-backtraces],[use libunwind to create backtraces for memory leaks and segfaults.])
 ARG_ENABL_SET([ruby-gems],      [enable installation of provided ruby gems.])
+ARG_ENABL_SET([python-eggs],    [enable installation of provided python eggs.])
 # compile options
 ARG_ENABL_SET([coverage],       [enable lcov coverage report generation.])
 ARG_ENABL_SET([leak-detective], [enable malloc hooks to find memory leaks.])
@@ -1186,6 +1188,19 @@ if test x$ruby_gems = xtrue; then
        AC_SUBST(RUBYGEMDIR, "$rubygemdir")
 fi
 
+if test x$python_eggs = xtrue; then
+       AC_PATH_PROG([EASY_INSTALL], [easy_install], [], [$PATH:/bin:/usr/bin:/usr/local/bin])
+       if test x$EASY_INSTALL = x; then
+               AC_MSG_ERROR(Python easy_install not found)
+       fi
+       if test "x$pythoneggdir" = "xmain site-packages directory"; then
+               AC_SUBST(PYTHONEGGINSTALLDIR, "")
+       else
+               AC_SUBST(PYTHONEGGINSTALLDIR, "--install-dir $pythoneggdir")
+       fi
+       AC_PATH_PROG([PY_TEST], [py.test], [], [$PATH:/bin:/usr/bin:/usr/local/bin])
+fi
+
 # ===============================================
 #  collect plugin list for strongSwan components
 # ===============================================
@@ -1552,6 +1567,8 @@ AM_CONDITIONAL(USE_SVC, test x$svc = xtrue)
 AM_CONDITIONAL(USE_SYSTEMD, test x$systemd = xtrue)
 AM_CONDITIONAL(USE_LEGACY_SYSTEMD, test -n "$systemdsystemunitdir" -a "x$systemdsystemunitdir" != xno)
 AM_CONDITIONAL(USE_RUBY_GEMS, test x$ruby_gems = xtrue)
+AM_CONDITIONAL(USE_PYTHON_EGGS, test x$python_eggs = xtrue)
+AM_CONDITIONAL(USE_PY_TEST, test "x$PY_TEST" != x)
 
 # ========================
 #  set global definitions
@@ -1758,6 +1775,7 @@ AC_CONFIG_FILES([
        src/libcharon/plugins/stroke/Makefile
        src/libcharon/plugins/vici/Makefile
        src/libcharon/plugins/vici/ruby/Makefile
+       src/libcharon/plugins/vici/python/Makefile
        src/libcharon/plugins/updown/Makefile
        src/libcharon/plugins/dhcp/Makefile
        src/libcharon/plugins/load_tester/Makefile
index 19a6c8d..d21deae 100755 (executable)
@@ -52,7 +52,8 @@ all)
        DEPS="$DEPS libcurl4-gnutls-dev libsoup2.4-dev libunbound-dev libldns-dev
                  libmysqlclient-dev libsqlite3-dev clearsilver-dev libfcgi-dev
                  libnm-glib-dev libnm-glib-vpn-dev libpcsclite-dev libpam0g-dev
-                 binutils-dev libunwind7-dev libjson0-dev iptables-dev"
+                 binutils-dev libunwind7-dev libjson0-dev iptables-dev python-pip"
+       PYDEPS="pytest"
        ;;
 win*)
        CONFIG="--disable-defaults --enable-svc --enable-ikev2
@@ -95,6 +96,11 @@ if test "$1" = "deps"; then
        exit $?
 fi
 
+if test "$1" = "pydeps"; then
+       test -z "$PYDEPS" || sudo pip -q install $PYDEPS
+       exit $?
+fi
+
 CONFIG="$CONFIG
        --enable-silent-rules
        --enable-test-vectors
index da71de3..b253960 100644 (file)
@@ -74,3 +74,7 @@ SUBDIRS =
 if USE_RUBY_GEMS
 SUBDIRS += ruby
 endif
+
+if USE_PYTHON_EGGS
+SUBDIRS += python
+endif
index c25b99e..0ce4271 100644 (file)
@@ -821,9 +821,9 @@ during encoding.
 
 ## Connecting to the daemon ##
 
-To create a connection to the daemon, a socket must be passed to the
-_Connection_ constructor. There is no default, but on Unix systems usually
-a Unix socket over _/var/run/charon.vici_ is used:
+To create a connection to the daemon, a socket can be passed to the
+_Connection_ constructor. If none is passed, a default Unix socket at
+_/var/run/charon.vici_ is used:
 
        require "vici"
        require "socket"
@@ -855,3 +855,73 @@ _list-conns_ command and implicitly the _list-conn_ event:
 
 For more details about the ruby gem refer to the comments in the gem source
 code or the generated documentation.
+
+# vici Python egg #
+
+The _vici Python egg_ is a pure Python implementation of the VICI protocol to
+implement client applications. It is provided in the _python_ subdirectory, and
+gets built and installed if strongSwan has been _./configure_'d with
+_--enable-vici_ and _--enable-python-eggs_.
+
+The _vici_ module provides a _Session()_ constructor for a high level interface,
+the underlying classes are usually not required to build Python applications
+using VICI. The _Session_ class provides methods for the supported VICI
+commands.
+
+To represent the VICI message data tree, the library converts the binary
+encoding to Python data types. The _Session_ class takes and returns Python
+objects for the exchanged message data:
+ * Sections get encoded as OrderedDict, containing other sections, or
+ * Key/Values, where the values are strings as dictionary values
+ * Lists get encoded as Python Lists with string values
+Values that do not conform to Python dict or list get converted to strings using
+str().
+
+## Connecting to the daemon ##
+
+To create a connection to the daemon, a socket can be passed to the _Session_
+constructor. If none is passed, a default Unix socket at _/var/run/charon.vici_
+is used:
+
+       import vici
+       import socket
+
+       s = socket.socket(socket.AF_UNIX)
+       s.connect("/var/run/charon.vici")
+       v = vici.Session(s)
+
+## A simple client request ##
+
+An example to print the daemon version information is as simple as:
+
+       ver = v.version()
+
+       print "{daemon} {version} ({sysname}, {release}, {machine})".format(**ver)
+
+## A request with response iteration ##
+
+The _Session_ class returns an iterable Python generator for streamed events to
+continuously stream objects to the caller. The following example lists all
+loaded connections using the _list-conns_ command and implicitly the _list-conn_
+event:
+
+       for conn in v.list_conns():
+               for key in conn:
+                       print key
+
+Please note that if the returned generator is not iterated completely, it must
+be closed using _close()_. This is implicitly done when breaking from a loop,
+but an explicit call may be required when directly iterating the generator with
+_next()_.
+
+## Sorting in dictionaries ##
+
+In VICI, in some message trees the order of objects in dictionary matters. In
+contrast to ruby Hashes, Python dictionaries do not preserve order of added
+objects. It is therefore recommended to use OrderedDicts instead of the default
+dictionaries. Objects returned by the library use OrderedDicts.
+
+## API documentation ##
+
+For more details about the Python egg refer to the comments in the Python source
+code.
diff --git a/src/libcharon/plugins/vici/python/.gitignore b/src/libcharon/plugins/vici/python/.gitignore
new file mode 100644 (file)
index 0000000..5c45898
--- /dev/null
@@ -0,0 +1,5 @@
+*.pyc
+build
+dist
+vici.egg-info
+setup.py
diff --git a/src/libcharon/plugins/vici/python/LICENSE b/src/libcharon/plugins/vici/python/LICENSE
new file mode 100644 (file)
index 0000000..111523c
--- /dev/null
@@ -0,0 +1,19 @@
+Copyright (c) 2015 Björn Schuberg
+
+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.
diff --git a/src/libcharon/plugins/vici/python/MANIFEST.in b/src/libcharon/plugins/vici/python/MANIFEST.in
new file mode 100644 (file)
index 0000000..1aba38f
--- /dev/null
@@ -0,0 +1 @@
+include LICENSE
diff --git a/src/libcharon/plugins/vici/python/Makefile.am b/src/libcharon/plugins/vici/python/Makefile.am
new file mode 100644 (file)
index 0000000..f517378
--- /dev/null
@@ -0,0 +1,33 @@
+EXTRA_DIST = LICENSE MANIFEST.in \
+       setup.py.in \
+       vici/test/__init__.py \
+       vici/test/test_protocol.py \
+       vici/__init__.py \
+       vici/compat.py \
+       vici/exception.py \
+       vici/protocol.py \
+       vici/session.py
+
+setup.py: $(srcdir)/setup.py.in
+       $(AM_V_GEN) sed \
+       -e "s:@EGG_VERSION@:$(PACKAGE_VERSION):" \
+       $(srcdir)/setup.py.in > $@
+
+all-local: dist/vici-$(PACKAGE_VERSION)-py$(PYTHON_VERSION).egg
+
+dist/vici-$(PACKAGE_VERSION)-py$(PYTHON_VERSION).egg: $(EXTRA_DIST) setup.py
+       (cd $(srcdir); $(PYTHON) setup.py bdist_egg \
+               -b $(shell readlink -f $(builddir))/build \
+               -d $(shell readlink -f $(builddir))/dist)
+
+clean-local: setup.py
+       $(PYTHON) setup.py clean -a
+       rm -rf vici.egg-info dist setup.py
+
+install-exec-local: dist/vici-$(PACKAGE_VERSION)-py$(PYTHON_VERSION).egg
+       $(EASY_INSTALL) $(PYTHONEGGINSTALLDIR) \
+               dist/vici-$(PACKAGE_VERSION)-py$(PYTHON_VERSION).egg
+
+if USE_PY_TEST
+  TESTS = $(PY_TEST)
+endif
diff --git a/src/libcharon/plugins/vici/python/setup.py.in b/src/libcharon/plugins/vici/python/setup.py.in
new file mode 100644 (file)
index 0000000..0e4ad82
--- /dev/null
@@ -0,0 +1,34 @@
+from setuptools import setup
+
+
+long_description = (
+    "The strongSwan VICI protocol allows external application to monitor, "
+    "configure and control the IKE daemon charon. This python package provides "
+    "a native client side implementation of the VICI protocol, well suited to "
+    "script automated tasks in a reliable way."
+)
+
+setup(
+    name="vici",
+    version="@EGG_VERSION@",
+    description="Native python interface for strongSwan VICI",
+    author="Bjorn Schuberg",
+    url="https://wiki.strongswan.org/projects/strongswan/wiki/Vici",
+    license="MIT",
+    packages=["vici"],
+    long_description=long_description,
+    include_package_data=True,
+    classifiers=(
+        "Development Status :: 3 - Alpha",
+        "Intended Audience :: Developers",
+        "Intended Audience :: System Administrators",
+        "License :: OSI Approved :: MIT License",
+        "Natural Language :: English",
+        "Programming Language :: Python :: 2.7",
+        "Programming Language :: Python :: 3.2",
+        "Programming Language :: Python :: 3.3",
+        "Programming Language :: Python :: 3.4",
+        "Topic :: Security",
+        "Topic :: Software Development :: Libraries",
+    )
+)
diff --git a/src/libcharon/plugins/vici/python/vici/__init__.py b/src/libcharon/plugins/vici/python/vici/__init__.py
new file mode 100644 (file)
index 0000000..d314325
--- /dev/null
@@ -0,0 +1 @@
+from .session import Session
diff --git a/src/libcharon/plugins/vici/python/vici/compat.py b/src/libcharon/plugins/vici/python/vici/compat.py
new file mode 100644 (file)
index 0000000..b5f4699
--- /dev/null
@@ -0,0 +1,14 @@
+# Help functions for compatibility between python version 2 and 3
+
+
+# From http://legacy.python.org/dev/peps/pep-0469
+try:
+    dict.iteritems
+except AttributeError:
+    # python 3
+    def iteritems(d):
+        return iter(d.items())
+else:
+    # python 2
+    def iteritems(d):
+        return d.iteritems()
diff --git a/src/libcharon/plugins/vici/python/vici/exception.py b/src/libcharon/plugins/vici/python/vici/exception.py
new file mode 100644 (file)
index 0000000..36384e5
--- /dev/null
@@ -0,0 +1,10 @@
+"""Exception types that may be thrown by this library."""
+
+class DeserializationException(Exception):
+    """Encountered an unexpected byte sequence or missing element type."""
+
+class SessionException(Exception):
+    """Session request exception."""
+
+class CommandException(Exception):
+    """Command result exception."""
diff --git a/src/libcharon/plugins/vici/python/vici/protocol.py b/src/libcharon/plugins/vici/python/vici/protocol.py
new file mode 100644 (file)
index 0000000..855a7b2
--- /dev/null
@@ -0,0 +1,196 @@
+import io
+import socket
+import struct
+
+from collections import namedtuple
+from collections import OrderedDict
+
+from .compat import iteritems
+from .exception import DeserializationException
+
+
+class Transport(object):
+    HEADER_LENGTH = 4
+    MAX_SEGMENT = 512 * 1024
+
+    def __init__(self, sock):
+        self.socket = sock
+
+    def send(self, packet):
+        self.socket.sendall(struct.pack("!I", len(packet)) + packet)
+
+    def receive(self):
+        raw_length = self.socket.recv(self.HEADER_LENGTH)
+        length, = struct.unpack("!I", raw_length)
+        payload = self.socket.recv(length)
+        return payload
+
+    def close(self):
+        self.socket.shutdown(socket.SHUT_RDWR)
+        self.socket.close()
+
+
+class Packet(object):
+    CMD_REQUEST = 0         # Named request message
+    CMD_RESPONSE = 1        # Unnamed response message for a request
+    CMD_UNKNOWN = 2         # Unnamed response if requested command is unknown
+    EVENT_REGISTER = 3      # Named event registration request
+    EVENT_UNREGISTER = 4    # Named event de-registration request
+    EVENT_CONFIRM = 5       # Unnamed confirmation for event (de-)registration
+    EVENT_UNKNOWN = 6       # Unnamed response if event (de-)registration failed
+    EVENT = 7               # Named event message
+
+    ParsedPacket = namedtuple(
+        "ParsedPacket",
+        ["response_type", "payload"]
+    )
+
+    ParsedEventPacket = namedtuple(
+        "ParsedEventPacket",
+        ["response_type", "event_type", "payload"]
+    )
+
+    @classmethod
+    def _named_request(cls, request_type, request, message=None):
+        request = request.encode()
+        payload = struct.pack("!BB", request_type, len(request)) + request
+        if message is not None:
+            return payload + message
+        else:
+            return payload
+
+    @classmethod
+    def request(cls, command, message=None):
+        return cls._named_request(cls.CMD_REQUEST, command, message)
+
+    @classmethod
+    def register_event(cls, event_type):
+        return cls._named_request(cls.EVENT_REGISTER, event_type)
+
+    @classmethod
+    def unregister_event(cls, event_type):
+        return cls._named_request(cls.EVENT_UNREGISTER, event_type)
+
+    @classmethod
+    def parse(cls, packet):
+        stream = FiniteStream(packet)
+        response_type, = struct.unpack("!B", stream.read(1))
+
+        if response_type == cls.EVENT:
+            length, = struct.unpack("!B", stream.read(1))
+            event_type = stream.read(length)
+            return cls.ParsedEventPacket(response_type, event_type, stream)
+        else:
+            return cls.ParsedPacket(response_type, stream)
+
+
+class Message(object):
+    SECTION_START = 1       # Begin a new section having a name
+    SECTION_END = 2         # End a previously started section
+    KEY_VALUE = 3           # Define a value for a named key in the section
+    LIST_START = 4          # Begin a named list for list items
+    LIST_ITEM = 5           # Define an unnamed item value in the current list
+    LIST_END = 6            # End a previously started list
+
+    @classmethod
+    def serialize(cls, message):
+        def encode_named_type(marker, name):
+            name = name.encode()
+            return struct.pack("!BB", marker, len(name)) + name
+
+        def encode_blob(value):
+            if not isinstance(value, bytes):
+                value = str(value).encode()
+            return struct.pack("!H", len(value)) + value
+
+        def serialize_list(lst):
+            segment = bytes()
+            for item in lst:
+                segment += struct.pack("!B", cls.LIST_ITEM) + encode_blob(item)
+            return segment
+
+        def serialize_dict(d):
+            segment = bytes()
+            for key, value in iteritems(d):
+                if isinstance(value, dict):
+                    segment += (
+                        encode_named_type(cls.SECTION_START, key)
+                        + serialize_dict(value)
+                        + struct.pack("!B", cls.SECTION_END)
+                    )
+                elif isinstance(value, list):
+                    segment += (
+                        encode_named_type(cls.LIST_START, key)
+                        + serialize_list(value)
+                        + struct.pack("!B", cls.LIST_END)
+                    )
+                else:
+                    segment += (
+                        encode_named_type(cls.KEY_VALUE, key)
+                        + encode_blob(value)
+                    )
+            return segment
+
+        return serialize_dict(message)
+
+    @classmethod
+    def deserialize(cls, stream):
+        def decode_named_type(stream):
+            length, = struct.unpack("!B", stream.read(1))
+            return stream.read(length).decode()
+
+        def decode_blob(stream):
+            length, = struct.unpack("!H", stream.read(2))
+            return stream.read(length)
+
+        def decode_list_item(stream):
+            marker, = struct.unpack("!B", stream.read(1))
+            while marker == cls.LIST_ITEM:
+                yield decode_blob(stream)
+                marker, = struct.unpack("!B", stream.read(1))
+
+            if marker != cls.LIST_END:
+                raise DeserializationException(
+                    "Expected end of list at {pos}".format(pos=stream.tell())
+                )
+
+        section = OrderedDict()
+        section_stack = []
+        while stream.has_more():
+            element_type, = struct.unpack("!B", stream.read(1))
+            if element_type == cls.SECTION_START:
+                section_name = decode_named_type(stream)
+                new_section = OrderedDict()
+                section[section_name] = new_section
+                section_stack.append(section)
+                section = new_section
+
+            elif element_type == cls.LIST_START:
+                list_name = decode_named_type(stream)
+                section[list_name] = [item for item in decode_list_item(stream)]
+
+            elif element_type == cls.KEY_VALUE:
+                key = decode_named_type(stream)
+                section[key] = decode_blob(stream)
+
+            elif element_type == cls.SECTION_END:
+                if len(section_stack):
+                    section = section_stack.pop()
+                else:
+                    raise DeserializationException(
+                        "Unexpected end of section at {pos}".format(
+                            pos=stream.tell()
+                        )
+                    )
+
+        if len(section_stack):
+            raise DeserializationException("Expected end of section")
+        return section
+
+
+class FiniteStream(io.BytesIO):
+    def __len__(self):
+        return len(self.getvalue())
+
+    def has_more(self):
+        return self.tell() < len(self)
diff --git a/src/libcharon/plugins/vici/python/vici/session.py b/src/libcharon/plugins/vici/python/vici/session.py
new file mode 100644 (file)
index 0000000..dee5869
--- /dev/null
@@ -0,0 +1,327 @@
+import collections
+import socket
+
+from .exception import SessionException, CommandException
+from .protocol import Transport, Packet, Message
+
+
+class Session(object):
+    def __init__(self, sock=None):
+        if sock is None:
+            sock = socket.socket(socket.AF_UNIX)
+            sock.connect("/var/run/charon.vici")
+        self.handler = SessionHandler(Transport(sock))
+
+    def version(self):
+        """Retrieve daemon and system specific version information.
+
+        :return: daemon and system specific version information
+        :rtype: dict
+        """
+        return self.handler.request("version")
+
+    def stats(self):
+        """Retrieve IKE daemon statistics and load information.
+
+        :return: IKE daemon statistics and load information
+        :rtype: dict
+        """
+        return self.handler.request("stats")
+
+    def reload_settings(self):
+        """Reload strongswan.conf settings and any plugins supporting reload.
+        """
+        self.handler.request("reload-settings")
+
+    def initiate(self, sa):
+        """Initiate an SA.
+
+        :param sa: the SA to initiate
+        :type sa: dict
+        :return: generator for logs emitted as dict
+        :rtype: generator
+        """
+        return self.handler.streamed_request("initiate", "control-log", sa)
+
+    def terminate(self, sa):
+        """Terminate an SA.
+
+        :param sa: the SA to terminate
+        :type sa: dict
+        :return: generator for logs emitted as dict
+        :rtype: generator
+        """
+        return self.handler.streamed_request("terminate", "control-log", sa)
+
+    def install(self, policy):
+        """Install a trap, drop or bypass policy defined by a CHILD_SA config.
+
+        :param policy: policy to install
+        :type policy: dict
+        """
+        self.handler.request("install", policy)
+
+    def uninstall(self, policy):
+        """Uninstall a trap, drop or bypass policy defined by a CHILD_SA config.
+
+        :param policy: policy to uninstall
+        :type policy: dict
+        """
+        self.handler.request("uninstall", policy)
+
+    def list_sas(self, filters=None):
+        """Retrieve active IKE_SAs and associated CHILD_SAs.
+
+        :param filters: retrieve only matching IKE_SAs (optional)
+        :type filters: dict
+        :return: generator for active IKE_SAs and associated CHILD_SAs as dict
+        :rtype: generator
+        """
+        return self.handler.streamed_request("list-sas", "list-sa", filters)
+
+    def list_policies(self, filters=None):
+        """Retrieve installed trap, drop and bypass policies.
+
+        :param filters: retrieve only matching policies (optional)
+        :type filters: dict
+        :return: generator for installed trap, drop and bypass policies as dict
+        :rtype: generator
+        """
+        return self.handler.streamed_request("list-policies", "list-policy",
+                                             filters)
+
+    def list_conns(self, filters=None):
+        """Retrieve loaded connections.
+
+        :param filters: retrieve only matching configuration names (optional)
+        :type filters: dict
+        :return: generator for loaded connections as dict
+        :rtype: generator
+        """
+        return self.handler.streamed_request("list-conns", "list-conn",
+                                             filters)
+
+    def get_conns(self):
+        """Retrieve connection names loaded exclusively over vici.
+
+        :return: connection names
+        :rtype: dict
+        """
+        return self.handler.request("get-conns")
+
+    def list_certs(self, filters=None):
+        """Retrieve loaded certificates.
+
+        :param filters: retrieve only matching certificates (optional)
+        :type filters: dict
+        :return: generator for loaded certificates as dict
+        :rtype: generator
+        """
+        return self.handler.streamed_request("list-certs", "list-cert", filters)
+
+    def load_conn(self, connection):
+        """Load a connection definition into the daemon.
+
+        :param connection: connection definition
+        :type connection: dict
+        """
+        self.handler.request("load-conn", connection)
+
+    def unload_conn(self, name):
+        """Unload a connection definition.
+
+        :param name: connection definition name
+        :type name: dict
+        """
+        self.handler.request("unload-conn", name)
+
+    def load_cert(self, certificate):
+        """Load a certificate into the daemon.
+
+        :param certificate: PEM or DER encoded certificate
+        :type certificate: dict
+        """
+        self.handler.request("load-cert", certificate)
+
+    def load_key(self, private_key):
+        """Load a private key into the daemon.
+
+        :param private_key: PEM or DER encoded key
+        """
+        self.handler.request("load-key", private_key)
+
+    def load_shared(self, secret):
+        """Load a shared IKE PSK, EAP or XAuth secret into the daemon.
+
+        :param secret: shared IKE PSK, EAP or XAuth secret
+        :type secret: dict
+        """
+        self.handler.request("load-shared", secret)
+
+    def clear_creds(self):
+        """Clear credentials loaded over vici.
+
+        Clear all loaded certificate, private key and shared key credentials.
+        This affects only credentials loaded over vici, but additionally
+        flushes the credential cache.
+        """
+        self.handler.request("clear-creds")
+
+    def load_pool(self, pool):
+        """Load a virtual IP pool.
+
+        Load an in-memory virtual IP and configuration attribute pool.
+        Existing pools with the same name get updated, if possible.
+
+        :param pool: virtual IP and configuration attribute pool
+        :type pool: dict
+        """
+        return self.handler.request("load-pool", pool)
+
+    def unload_pool(self, pool_name):
+        """Unload a virtual IP pool.
+
+        Unload a previously loaded virtual IP and configuration attribute pool.
+        Unloading fails for pools with leases currently online.
+
+        :param pool_name: pool by name
+        :type pool_name: dict
+        """
+        self.handler.request("unload-pool", pool_name)
+
+    def get_pools(self):
+        """Retrieve loaded pools.
+
+        :return: loaded pools
+        :rtype: dict
+        """
+        return self.handler.request("get-pools")
+
+
+class SessionHandler(object):
+    """Handles client command execution requests over vici."""
+
+    def __init__(self, transport):
+        self.transport = transport
+
+    def _communicate(self, packet):
+        """Send packet over transport and parse response.
+
+        :param packet: packet to send
+        :type packet: :py:class:`vici.protocol.Packet`
+        :return: parsed packet in a tuple with message type and payload
+        :rtype: :py:class:`collections.namedtuple`
+        """
+        self.transport.send(packet)
+        return Packet.parse(self.transport.receive())
+
+    def request(self, command, message=None):
+        """Send request with an optional message.
+
+        :param command: command to send
+        :type command: str
+        :param message: message (optional)
+        :type message: str
+        :return: command result
+        :rtype: dict
+        """
+        if message is not None:
+            message = Message.serialize(message)
+        packet = Packet.request(command, message)
+        response = self._communicate(packet)
+
+        if response.response_type != Packet.CMD_RESPONSE:
+            raise SessionException(
+                "Unexpected response type {type}, "
+                "expected '{response}' (CMD_RESPONSE)".format(
+                    type=response.response_type,
+                    response=Packet.CMD_RESPONSE
+                )
+            )
+
+        command_response = Message.deserialize(response.payload)
+        if "success" in command_response:
+            if command_response["success"] != b"yes":
+                raise CommandException(
+                    "Command failed: {errmsg}".format(
+                        errmsg=command_response["errmsg"]
+                    )
+                )
+
+        return command_response
+
+    def streamed_request(self, command, event_stream_type, message=None):
+        """Send command request and collect and return all emitted events.
+
+        :param command: command to send
+        :type command: str
+        :param event_stream_type: event type emitted on command execution
+        :type event_stream_type: str
+        :param message: message (optional)
+        :type message: str
+        :return: generator for streamed event responses as dict
+        :rtype: generator
+        """
+        if message is not None:
+            message = Message.serialize(message)
+
+        # subscribe to event stream
+        packet = Packet.register_event(event_stream_type)
+        response = self._communicate(packet)
+
+        if response.response_type != Packet.EVENT_CONFIRM:
+            raise SessionException(
+                "Unexpected response type {type}, "
+                "expected '{confirm}' (EVENT_CONFIRM)".format(
+                    type=response.response_type,
+                    confirm=Packet.EVENT_CONFIRM,
+                )
+            )
+
+        # issue command, and read any event messages
+        packet = Packet.request(command, message)
+        self.transport.send(packet)
+        exited = False
+        while True:
+            response = Packet.parse(self.transport.receive())
+            if response.response_type == Packet.EVENT:
+                if not exited:
+                    try:
+                        yield Message.deserialize(response.payload)
+                    except GeneratorExit:
+                        exited = True
+                        pass
+            else:
+                break
+
+        if response.response_type == Packet.CMD_RESPONSE:
+            command_response = Message.deserialize(response.payload)
+        else:
+            raise SessionException(
+                "Unexpected response type {type}, "
+                "expected '{response}' (CMD_RESPONSE)".format(
+                    type=response.response_type,
+                    response=Packet.CMD_RESPONSE
+                )
+            )
+
+        # unsubscribe from event stream
+        packet = Packet.unregister_event(event_stream_type)
+        response = self._communicate(packet)
+        if response.response_type != Packet.EVENT_CONFIRM:
+            raise SessionException(
+                "Unexpected response type {type}, "
+                "expected '{confirm}' (EVENT_CONFIRM)".format(
+                    type=response.response_type,
+                    confirm=Packet.EVENT_CONFIRM,
+                )
+            )
+
+        # evaluate command result, if any
+        if "success" in command_response:
+            if command_response["success"] != b"yes":
+                raise CommandException(
+                    "Command failed: {errmsg}".format(
+                        errmsg=command_response["errmsg"]
+                    )
+                )
diff --git a/src/libcharon/plugins/vici/python/vici/test/__init__.py b/src/libcharon/plugins/vici/python/vici/test/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/libcharon/plugins/vici/python/vici/test/test_protocol.py b/src/libcharon/plugins/vici/python/vici/test/test_protocol.py
new file mode 100644 (file)
index 0000000..a1f202d
--- /dev/null
@@ -0,0 +1,144 @@
+import pytest
+
+from ..protocol import Packet, Message, FiniteStream
+from ..exception import DeserializationException
+
+
+class TestPacket(object):
+    # test data definitions for outgoing packet types
+    cmd_request = b"\x00\x0c" b"command_type"
+    cmd_request_msg = b"\x00\x07" b"command" b"payload"
+    event_register = b"\x03\x0a" b"event_type"
+    event_unregister = b"\x04\x0a" b"event_type"
+
+    # test data definitions for incoming packet types
+    cmd_response = b"\x01" b"reply"
+    cmd_unknown = b"\x02"
+    event_confirm = b"\x05"
+    event_unknown = b"\x06"
+    event = b"\x07\x03" b"log" b"message"
+
+    def test_request(self):
+        assert Packet.request("command_type") == self.cmd_request
+        assert Packet.request("command", b"payload") == self.cmd_request_msg
+
+    def test_register_event(self):
+        assert Packet.register_event("event_type") == self.event_register
+
+    def test_unregister_event(self):
+        assert Packet.unregister_event("event_type") == self.event_unregister
+
+    def test_parse(self):
+        parsed_cmd_response = Packet.parse(self.cmd_response)
+        assert parsed_cmd_response.response_type == Packet.CMD_RESPONSE
+        assert parsed_cmd_response.payload.getvalue() == self.cmd_response
+
+        parsed_cmd_unknown = Packet.parse(self.cmd_unknown)
+        assert parsed_cmd_unknown.response_type == Packet.CMD_UNKNOWN
+        assert parsed_cmd_unknown.payload.getvalue() == self.cmd_unknown
+
+        parsed_event_confirm = Packet.parse(self.event_confirm)
+        assert parsed_event_confirm.response_type == Packet.EVENT_CONFIRM
+        assert parsed_event_confirm.payload.getvalue() == self.event_confirm
+
+        parsed_event_unknown = Packet.parse(self.event_unknown)
+        assert parsed_event_unknown.response_type == Packet.EVENT_UNKNOWN
+        assert parsed_event_unknown.payload.getvalue() == self.event_unknown
+
+        parsed_event = Packet.parse(self.event)
+        assert parsed_event.response_type == Packet.EVENT
+        assert parsed_event.payload.getvalue() == self.event
+
+
+class TestMessage(object):
+    """Message (de)serialization test."""
+
+    # data definitions for test of de(serialization)
+    # serialized messages holding a section
+    ser_sec_unclosed = b"\x01\x08unclosed"
+    ser_sec_single = b"\x01\x07section\x02"
+    ser_sec_nested = b"\x01\x05outer\x01\x0asubsection\x02\x02"
+
+    # serialized messages holding a list
+    ser_list_invalid = b"\x04\x07invalid\x05\x00\x02e1\x02\x03sec\x06"
+    ser_list_0_item = b"\x04\x05empty\x06"
+    ser_list_1_item = b"\x04\x01l\x05\x00\x02e1\x06"
+    ser_list_2_item = b"\x04\x01l\x05\x00\x02e1\x05\x00\x02e2\x06"
+
+    # serialized messages with key value pairs
+    ser_kv_pair = b"\x03\x03key\x00\x05value"
+    ser_kv_zero = b"\x03\x0azerolength\x00\x00"
+
+    # deserialized messages holding a section
+    des_sec_single = { "section": {} }
+    des_sec_nested = { "outer": { "subsection": {} } }
+
+    # deserialized messages holding a list
+    des_list_0_item = { "empty": [] }
+    des_list_1_item = { "l": [ b"e1" ] }
+    des_list_2_item = { "l": [ b"e1", b"e2" ] }
+
+    # deserialized messages with key value pairs
+    des_kv_pair = { "key": b"value" }
+    des_kv_zero = { "zerolength": b"" }
+
+    def test_section_serialization(self):
+        assert Message.serialize(self.des_sec_single) == self.ser_sec_single
+        assert Message.serialize(self.des_sec_nested) == self.ser_sec_nested
+
+    def test_list_serialization(self):
+        assert Message.serialize(self.des_list_0_item) == self.ser_list_0_item
+        assert Message.serialize(self.des_list_1_item) == self.ser_list_1_item
+        assert Message.serialize(self.des_list_2_item) == self.ser_list_2_item
+
+    def test_key_serialization(self):
+        assert Message.serialize(self.des_kv_pair) == self.ser_kv_pair
+        assert Message.serialize(self.des_kv_zero) == self.ser_kv_zero
+
+    def test_section_deserialization(self):
+        single = Message.deserialize(FiniteStream(self.ser_sec_single))
+        nested = Message.deserialize(FiniteStream(self.ser_sec_nested))
+
+        assert single == self.des_sec_single
+        assert nested == self.des_sec_nested
+
+        with pytest.raises(DeserializationException):
+            Message.deserialize(FiniteStream(self.ser_sec_unclosed))
+
+    def test_list_deserialization(self):
+        l0 = Message.deserialize(FiniteStream(self.ser_list_0_item))
+        l1 = Message.deserialize(FiniteStream(self.ser_list_1_item))
+        l2 = Message.deserialize(FiniteStream(self.ser_list_2_item))
+
+        assert l0 == self.des_list_0_item
+        assert l1 == self.des_list_1_item
+        assert l2 == self.des_list_2_item
+
+        with pytest.raises(DeserializationException):
+            Message.deserialize(FiniteStream(self.ser_list_invalid))
+
+    def test_key_deserialization(self):
+        pair = Message.deserialize(FiniteStream(self.ser_kv_pair))
+        zerolength = Message.deserialize(FiniteStream(self.ser_kv_zero))
+
+        assert pair == self.des_kv_pair
+        assert zerolength == self.des_kv_zero
+
+    def test_roundtrip(self):
+        message = {
+            "key1": "value1",
+            "section1": {
+                "sub-section": {
+                    "key2": b"value2",
+                },
+                "list1": [ "item1", "item2" ],
+            },
+        }
+        serialized_message = FiniteStream(Message.serialize(message))
+        deserialized_message = Message.deserialize(serialized_message)
+
+        # ensure that list items and key values remain as undecoded bytes
+        deserialized_section = deserialized_message["section1"]
+        assert deserialized_message["key1"] == b"value1"
+        assert deserialized_section["sub-section"]["key2"] == b"value2"
+        assert deserialized_section["list1"] == [ b"item1", b"item2" ]
index c10705d..3e12f86 100644 (file)
@@ -5,7 +5,7 @@ vici.gemspec: $(srcdir)/vici.gemspec.in
        -e "s:@GEM_VERSION@:$(PACKAGE_VERSION):" \
        $(srcdir)/vici.gemspec.in > $@
 
-vici-$(PACKAGE_VERSION).gem: vici.gemspec
+vici-$(PACKAGE_VERSION).gem: vici.gemspec $(EXTRA_DIST)
        (cd $(srcdir); $(GEM) build $(abs_builddir)/vici.gemspec)
        [ "$(srcdir)" = "$(builddir)" ] || \
                mv $(srcdir)/vici-$(PACKAGE_VERSION).gem $(builddir)
index 852bcb6..f87e46e 100644 (file)
@@ -390,7 +390,10 @@ module Vici
   # during encoding.
   class Connection
 
-    def initialize(socket)
+    def initialize(socket = nil)
+      if socket == nil
+        socket = UNIXSocket.new("/var/run/charon.vici")
+      end
       @transp = Transport.new(socket)
     end
 
index 62d1763..82f12c9 100644 (file)
@@ -84,6 +84,8 @@ METHOD(vici_builder_t, add, void,
 
        if (value.len > 0xffff)
        {
+               DBG1(DBG_ENC, "vici value exceeds size limit (%zu > %u)",
+                        value.len, 0xffff);
                this->error++;
                return;
        }
@@ -125,22 +127,56 @@ METHOD(vici_builder_t, add, void,
        }
 }
 
-METHOD(vici_builder_t, vadd_kv, void,
-       private_vici_builder_t *this, char *key, char *fmt, va_list args)
+/**
+ * Add a list item or a key/value, if key given
+ */
+static void vadd_kv_or_li(private_vici_builder_t *this, char *key,
+                                                 char *fmt, va_list args)
 {
-       char buf[2048];
+       u_char buf[512];
+       chunk_t value;
        ssize_t len;
+       va_list copy;
 
-       len = vsnprintf(buf, sizeof(buf), fmt, args);
-       if (len < 0 || len >= sizeof(buf))
+       va_copy(copy, args);
+       len = vsnprintf(buf, sizeof(buf), fmt, copy);
+       va_end(copy);
+       if (len >= sizeof(buf))
+       {
+               value = chunk_alloc(len + 1);
+               len = vsnprintf(value.ptr, value.len, fmt, args);
+       }
+       else
        {
-               DBG1(DBG_ENC, "vici builder format buffer exceeds limit");
+               value = chunk_create(buf, len);
+       }
+
+       if (len < 0)
+       {
+               DBG1(DBG_ENC, "vici builder format print failed");
                this->error++;
        }
        else
        {
-               add(this, VICI_KEY_VALUE, key, chunk_create(buf, len));
+               if (key)
+               {
+                       add(this, VICI_KEY_VALUE, key, value);
+               }
+               else
+               {
+                       add(this, VICI_LIST_ITEM, value);
+               }
        }
+       if (value.ptr != buf)
+       {
+               free(value.ptr);
+       }
+}
+
+METHOD(vici_builder_t, vadd_kv, void,
+       private_vici_builder_t *this, char *key, char *fmt, va_list args)
+{
+       vadd_kv_or_li(this, key, fmt, args);
 }
 
 METHOD(vici_builder_t, add_kv, void,
@@ -153,23 +189,10 @@ METHOD(vici_builder_t, add_kv, void,
        va_end(args);
 }
 
-
 METHOD(vici_builder_t, vadd_li, void,
        private_vici_builder_t *this, char *fmt, va_list args)
 {
-       char buf[2048];
-       ssize_t len;
-
-       len = vsnprintf(buf, sizeof(buf), fmt, args);
-       if (len < 0 || len >= sizeof(buf))
-       {
-               DBG1(DBG_ENC, "vici builder format buffer exceeds limit");
-               this->error++;
-       }
-       else
-       {
-               add(this, VICI_LIST_ITEM, chunk_create(buf, len));
-       }
+       vadd_kv_or_li(this, NULL, fmt, args);
 }
 
 METHOD(vici_builder_t, add_li, void,
index 7e4e241..84fc23c 100644 (file)
@@ -508,11 +508,14 @@ static void build_auth_cfgs(peer_cfg_t *peer_cfg, bool local, vici_builder_t *b)
                certificate_t *cert;
                char *str;
        } v;
+       char buf[32];
+       int i = 0;
 
        enumerator = peer_cfg->create_auth_cfg_enumerator(peer_cfg, local);
        while (enumerator->enumerate(enumerator, &auth))
        {
-               b->begin_section(b, local ? "local" : "remote");
+               snprintf(buf, sizeof(buf), "%s-%d", local ? "local" : "remote", ++i);
+               b->begin_section(b, buf);
 
                rules = auth->create_enumerator(auth);
                while (rules->enumerate(rules, &rule, &v))
index 31ab9c4..019c888 100644 (file)
@@ -103,7 +103,7 @@ CALLBACK(conn_sn, int,
        {
                return vici_parse_cb(res, children_sn, NULL, NULL, NULL);
        }
-       if (streq(name, "local") || streq(name, "remote"))
+       if (strpfx(name, "local") || strpfx(name, "remote"))
        {
                hashtable_t *auth;
 
@@ -112,7 +112,8 @@ CALLBACK(conn_sn, int,
                if (ret == 0)
                {
                        printf("  %s %s authentication:\n",
-                               name, auth->get(auth, "class") ?: "unspecified");
+                               strpfx(name, "local") ? "local" : "remote",
+                               auth->get(auth, "class") ?: "unspecified");
                        if (auth->get(auth, "id"))
                        {
                                printf("    id: %s\n", auth->get(auth, "id"));