Repository: arista-eosplus/pyeapi

Issue:
API doesn't support authentication other than username/password
Please provide support for some method other than username/password in a text file to access the API. SSH support exists for SSH keys, maybe allow the same key to be used to generate client x.509 certificates.

Discussion:

Patch:
diff --git a/docs/configfile.rst b/docs/configfile.rst
index 49689c3..b34a304 100644
--- a/docs/configfile.rst
+++ b/docs/configfile.rst
@@ -39,13 +39,15 @@ The following configuration options are available for defining node entries:
       - http_local (available in EOS 4.14.5 or later)
       - http
       - https
+      - https_certs
 
 :port: Configures the port to use for the eAPI connection.  A default
     port is used if this parameter is absent, based on the transport setting
     using the following values:
 
       - transport: http, default port: 80
-      - transport: https, deafult port: 443
+      - transport: https, default port: 443
+      - transport: https_certs, default port: 443
       - transport: http_local, default port: 8080
       - transport: socket, default port: n/a
 
@@ -62,6 +64,7 @@ Transport   eapi.conf Required Script run from Authentication Required
 =========== ================== =============== ========================
 http        Yes                On/Off-switch   Yes
 https       Yes                On/Off-switch   Yes
+https_certs Yes                On/Off-switch   Yes (Auth done via certs, not un/pw)
 http_local  Yes                On-switch only  No
 socket      No                 On-switch only  No
 =========== ================== =============== ========================
@@ -169,6 +172,29 @@ As the table above indicates, a pyeapi configuration file is required in
   username: admin
   password: admin
 
+Using HTTPS with Certificates
+=============================
+
+The https_certs transport options allows users to do authentication for pyeapi
+with certificates instead of username/password. This requires functional
+certificate chains are setup, copied to the proper location and trusted by
+EOS beforehand. The ca_file parameter is optional. If provided the switches
+certificate will also be validated against the provided CA cert. If no CA cert
+is provided then no server side validation will be done.
+
+.. code-block:: console
+
+  [connection:veos01]
+  transport: https_certs
+  cert_file: /path/to/certificate/file
+  key_file: /path/to/private/key/file
+  ca_file: /path/to/CA/certificate/file
+
+  [connection:veos02]
+  transport: https_certs
+  cert_file: /path/to/certificate/file
+  key_file: /path/to/private/key/file
+
 *******************
 The DEFAULT Section
 *******************
diff --git a/docs/quickstart.rst b/docs/quickstart.rst
index 8934cd6..333bd54 100644
--- a/docs/quickstart.rst
+++ b/docs/quickstart.rst
@@ -19,6 +19,7 @@ include:
 
   - HTTP
   - HTTPS
+  - HTTPS Certificates
   - HTTP Local
   - Unix Socket
 
diff --git a/pyeapi/client.py b/pyeapi/client.py
index 52e80d6..8a0947e 100644
--- a/pyeapi/client.py
+++ b/pyeapi/client.py
@@ -106,6 +106,7 @@ except ImportError:
 from pyeapi.utils import load_module, make_iterable, debug
 
 from pyeapi.eapilib import HttpEapiConnection, HttpsEapiConnection
+from pyeapi.eapilib import HttpsEapiCertConnection
 from pyeapi.eapilib import SocketEapiConnection, HttpLocalEapiConnection
 from pyeapi.eapilib import CommandError
 
@@ -115,7 +116,8 @@ TRANSPORTS = {
     'socket': SocketEapiConnection,
     'http_local': HttpLocalEapiConnection,
     'http': HttpEapiConnection,
-    'https': HttpsEapiConnection
+    'https': HttpsEapiConnection,
+    'https_certs': HttpsEapiCertConnection
 }
 
 DEFAULT_TRANSPORT = 'https'
@@ -327,6 +329,7 @@ def load_config(filename):
     """
     return config.load(filename)
 
+
 def config_for(name):
     """ Function to get settings for named config
 
@@ -344,6 +347,7 @@ def config_for(name):
     """
     return config.get_connection(name)
 
+
 def hosts_for_tag(tag):
     """ Returns the hosts assocated with the specified tag
 
@@ -361,6 +365,7 @@ def hosts_for_tag(tag):
     """
     return config.tags.get(tag)
 
+
 def make_connection(transport, **kwargs):
     """ Creates a connection instance based on the transport
 
@@ -386,7 +391,8 @@ def make_connection(transport, **kwargs):
 
 
 def connect(transport=None, host='localhost', username='admin',
-            password='', port=None, timeout=60, return_node=False, **kwargs):
+            password='', port=None, key_file=None, cert_file=None,
+            ca_file=None, timeout=60, return_node=False, **kwargs):
     """ Creates a connection using the supplied settings
 
     This function will create a connection to an Arista EOS node using
@@ -405,6 +411,10 @@ def connect(transport=None, host='localhost', username='admin',
         port (int): The TCP port of the endpoint for the eAPI connection.  If
             this keyword is not specified, the default value is automatically
             determined by the transport type. (http=80, https=443)
+        key_file (str): Path to private key file for ssl validation
+        cert_file (str): Path to PEM formatted cert file for ssl validation
+        ca_file (str): Path to CA PEM formatted cert file for ssl validation
+        timeout (int): timeout
         return_node (bool): Returns a Node object if True, otherwise
             returns an EapiConnection object.
 
@@ -415,10 +425,13 @@ def connect(transport=None, host='localhost', username='admin',
     """
     transport = transport or DEFAULT_TRANSPORT
     connection = make_connection(transport, host=host, username=username,
-                                 password=password, port=port, timeout=timeout)
+                                 password=password, key_file=key_file,
+                                 cert_file=cert_file, ca_file=ca_file,
+                                 port=port, timeout=timeout)
     if return_node:
         return Node(connection, transport=transport, host=host,
-                    username=username, password=password, port=port, **kwargs)
+                    username=username, password=password, key_file=key_file,
+                    cert_file=cert_file, ca_file=ca_file, port=port, **kwargs)
     return connection
 
 
diff --git a/pyeapi/eapilib.py b/pyeapi/eapilib.py
index c6ce8f3..3e0f679 100644
--- a/pyeapi/eapilib.py
+++ b/pyeapi/eapilib.py
@@ -58,6 +58,7 @@ _LOGGER = logging.getLogger(__name__)
 DEFAULT_HTTP_PORT = 80
 DEFAULT_HTTPS_PORT = 443
 DEFAULT_HTTP_LOCAL_PORT = 8080
+DEFAULT_HTTPS_LOCAL_PORT = 8443
 DEFAULT_HTTP_PATH = '/command-api'
 DEFAULT_UNIX_SOCKET = '/var/run/command-api.sock'
 
@@ -207,6 +208,55 @@ class HttpsConnection(HTTPSConnection):
         return 'https://%s:%s/%s' % (self.host, self.port, self.path)
 
 
+class HTTPSCertConnection(HTTPSConnection):
+    """ Class to make a HTTPS connection, with support
+        for full client-based SSL Authentication.
+    """
+
+    def __init__(self, path, host, port, key_file, cert_file, ca_file,
+                 timeout=None):
+        HTTPSConnection.__init__(self, host, key_file=key_file,
+                                 cert_file=cert_file)
+        self.key_file = key_file
+        self.cert_file = cert_file
+        self.ca_file = ca_file
+        self.timeout = timeout
+        self.path = path
+        self.port = port
+
+    def __str__(self):
+        return 'https://%s:%s/%s - %s,%s' % (self.host, self.port, self.path,
+                                             self.key_file, self.cert_file)
+
+    def __repr__(self):
+        return 'https://%s:%s/%s - %s,%s' % (self.host, self.port, self.path,
+                                             self.key_file, self.cert_file)
+
+    def connect(self):
+        """ Connect to a host on a given (SSL) port.
+            If ca_file is pointing somewhere, use it
+            to check Server Certificate.
+
+            Redefined/copied and extended from httplib.py:1105 (Python 2.6.x).
+            This is needed to pass cert_reqs=ssl.CERT_REQUIRED as parameter
+            to ssl.wrap_socket(), which forces SSL to check server certificate
+            against our client certificate.
+        """
+        sock = socket.create_connection((self.host, self.port), self.timeout)
+        if self._tunnel_host:
+            self.sock = sock
+            self._tunnel()
+        # If there's no CA File, don't force Server Certificate Check
+        if self.ca_file:
+            self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file,
+                                        ca_certs=self.ca_file,
+                                        cert_reqs=ssl.CERT_REQUIRED)
+        else:
+            self.sock = ssl.wrap_socket(sock, self.key_file,
+                                        self.cert_file,
+                                        cert_reqs=ssl.CERT_NONE)
+
+
 class EapiConnection(object):
     """Creates a connection to eAPI for sending and receiving eAPI requests
 
@@ -551,7 +601,7 @@ class HttpsEapiConnection(EapiConnection):
     def disable_certificate_verification(self):
         # SSL/TLS certificate verification is enabled by default in latest
         # Python releases and causes self-signed certificates generated
-        # on EOS to fail validation (unless explicitely imported).
+        # on EOS to fail validation (unless explicitly imported).
         # Disable the SSL/TLS certificate verification for now.
         # Use the approach in PEP476 to disable certificate validation.
         # TODO:
@@ -560,3 +610,20 @@ class HttpsEapiConnection(EapiConnection):
         # temporary until a proper fix is implemented.
         if hasattr(ssl, '_create_unverified_context'):
             return ssl._create_unverified_context()
+
+
+class HttpsEapiCertConnection(EapiConnection):
+    def __init__(self, host, port=None, path=None, key_file=None,
+                 cert_file=None, ca_file=None, timeout=60, **kwargs):
+        if key_file is None or cert_file is None:
+            raise ValueError("For https_cert connections both a key_file and "
+                             "cert_file are required. A ca_file is also "
+                             "recommended")
+        super(HttpsEapiCertConnection, self).__init__()
+        port = port or DEFAULT_HTTPS_PORT
+        path = path or DEFAULT_HTTP_PATH
+
+        self.transport = HTTPSCertConnection(path, host, int(port),
+                                             key_file=key_file,
+                                             cert_file=cert_file,
+                                             ca_file=ca_file, timeout=timeout)


Test patch:
diff --git a/test/unit/test_client.py b/test/unit/test_client.py
index 1cf3977..b5c6ecc 100644
--- a/test/unit/test_client.py
+++ b/test/unit/test_client.py
@@ -310,7 +310,8 @@ class TestClient(unittest.TestCase):
     def test_connect_types(self, connection):
         transports = list(pyeapi.client.TRANSPORTS.keys())
         kwargs = dict(host='localhost', username='admin', password='',
-                      port=None, timeout=60)
+                      port=None, key_file=None, cert_file=None,
+                      ca_file=None, timeout=60)
 
         for transport in transports:
             pyeapi.client.connect(transport)
@@ -408,7 +409,8 @@ class TestClient(unittest.TestCase):
         with patch.dict(pyeapi.client.TRANSPORTS, {'https': transport}):
             pyeapi.client.connect()
             kwargs = dict(host='localhost', username='admin', password='',
-                          port=None, timeout=60)
+                          port=None, key_file=None, cert_file=None,
+                          ca_file=None, timeout=60)
             transport.assert_called_once_with(**kwargs)
 
     def test_connect_return_node(self):
@@ -420,7 +422,8 @@ class TestClient(unittest.TestCase):
                                          password='password', port=None,
                                          timeout=60, return_node=True)
             kwargs = dict(host='192.168.1.16', username='eapi',
-                          password='password', port=None, timeout=60)
+                          password='password', port=None, key_file=None,
+                          cert_file=None, ca_file=None, timeout=60)
             transport.assert_called_once_with(**kwargs)
             self.assertIsNone(node._enablepwd)
 
@@ -434,7 +437,8 @@ class TestClient(unittest.TestCase):
                                          timeout=60, enablepwd='enablepwd',
                                          return_node=True)
             kwargs = dict(host='192.168.1.16', username='eapi',
-                          password='password', port=None, timeout=60)
+                          password='password', port=None, key_file=None,
+                          cert_file=None, ca_file=None, timeout=60)
             transport.assert_called_once_with(**kwargs)
             self.assertEqual(node._enablepwd, 'enablepwd')
 
@@ -445,7 +449,8 @@ class TestClient(unittest.TestCase):
             pyeapi.client.load_config(filename=conf)
             node = pyeapi.client.connect_to('test1')
             kwargs = dict(host='192.168.1.16', username='eapi',
-                          password='password', port=None, timeout=60)
+                          password='password', port=None, key_file=None,
+                          cert_file=None, ca_file=None, timeout=60)
             transport.assert_called_once_with(**kwargs)
             self.assertEqual(node._enablepwd, 'enablepwd')