summaryrefslogtreecommitdiffstats
path: root/ansible_collections/cisco/aci/plugins/httpapi/aci.py
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-18 05:52:22 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-18 05:52:22 +0000
commit38b7c80217c4e72b1d8988eb1e60bb6e77334114 (patch)
tree356e9fd3762877d07cde52d21e77070aeff7e789 /ansible_collections/cisco/aci/plugins/httpapi/aci.py
parentAdding upstream version 7.7.0+dfsg. (diff)
downloadansible-38b7c80217c4e72b1d8988eb1e60bb6e77334114.tar.xz
ansible-38b7c80217c4e72b1d8988eb1e60bb6e77334114.zip
Adding upstream version 9.4.0+dfsg.upstream/9.4.0+dfsg
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'ansible_collections/cisco/aci/plugins/httpapi/aci.py')
-rw-r--r--ansible_collections/cisco/aci/plugins/httpapi/aci.py340
1 files changed, 340 insertions, 0 deletions
diff --git a/ansible_collections/cisco/aci/plugins/httpapi/aci.py b/ansible_collections/cisco/aci/plugins/httpapi/aci.py
new file mode 100644
index 000000000..a0474576a
--- /dev/null
+++ b/ansible_collections/cisco/aci/plugins/httpapi/aci.py
@@ -0,0 +1,340 @@
+# Copyright (c) 2020 Cisco and/or its affiliates.
+# Copyright: (c) 2020, Shreyas Srish (@shrsr) <ssrish@cisco.com>
+# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from __future__ import absolute_import, division, print_function
+
+__metaclass__ = type
+
+DOCUMENTATION = """
+---
+name: aci
+author:
+- Shreyas Srish (@shrsr)
+short_description: Ansible ACI HTTPAPI Plugin.
+description:
+ - This ACI plugin provides the HTTPAPI methods needed to initiate
+ a connection to the APIC, send API requests and process the
+ response from the controller.
+"""
+
+import ast
+import base64
+import json
+import os
+import re
+
+from ansible.module_utils._text import to_text, to_native
+from ansible.module_utils.connection import ConnectionError
+from ansible.plugins.httpapi import HttpApiBase
+from copy import copy, deepcopy
+
+# Optional, only used for APIC signature-based authentication
+try:
+ from OpenSSL.crypto import FILETYPE_PEM, load_privatekey, sign
+
+ HAS_OPENSSL = True
+except ImportError:
+ HAS_OPENSSL = False
+
+# Signature-based authentication using cryptography
+try:
+ from cryptography.hazmat.primitives import serialization, hashes
+ from cryptography.hazmat.primitives.asymmetric import padding
+ from cryptography.hazmat.backends import default_backend
+
+ HAS_CRYPTOGRAPHY = True
+except ImportError:
+ HAS_CRYPTOGRAPHY = False
+
+CONNECTION_MAP = {"username": "remote_user", "timeout": "persistent_command_timeout"}
+RESET_KEYS = ["username", "password", "port"]
+CONNECTION_KEYS = RESET_KEYS + ["timeout", "use_proxy", "use_ssl", "validate_certs"]
+
+
+class HttpApi(HttpApiBase):
+ def __init__(self, *args, **kwargs):
+ super(HttpApi, self).__init__(*args, **kwargs)
+ self.params = None
+ self.result = {}
+ self.backup_hosts = None
+ self.connection_error_check = False
+ self.connection_parameters = {}
+ self.current_host = None
+ self.provided_hosts = None
+ self.inventory_hosts = None
+
+ def set_params(self, params):
+ self.params = params
+
+ # Login function is executed until connection to a host is established or until all the hosts in the list are exhausted
+ def login(self, username, password):
+ """Log in to APIC"""
+ # Perform login request
+ self.connection.queue_message("debug", "Establishing login for {0} to {1}".format(username, self.connection.get_option("host")))
+ method = "POST"
+ path = "/api/aaaLogin.json"
+ payload = {"aaaUser": {"attributes": {"name": username, "pwd": password}}}
+ data = json.dumps(payload)
+ self.connection._connected = True
+ try:
+ response, response_data = self.connection.send(path, data, method=method)
+ response_value = self._get_response_value(response_data)
+ self.connection._auth = {
+ "Cookie": "APIC-Cookie={0}".format(self._response_to_json(response_value).get("imdata")[0]["aaaLogin"]["attributes"]["token"])
+ }
+ self.connection.queue_message("debug", "Connection to {0} was successful".format(self.connection.get_option("host")))
+ except Exception as exc_login:
+ self.connection._connected = False
+ exc_login.path = path
+ raise
+
+ def logout(self):
+ method = "POST"
+ path = "/api/aaaLogout.json"
+ payload = {"aaaUser": {"attributes": {"name": self.connection.get_option("remote_user")}}}
+ data = json.dumps(payload)
+ try:
+ response, response_data = self.connection.send(path, data, method=method)
+ except Exception as exc_logout:
+ msg = "Error on attempt to logout from APIC. {0}".format(exc_logout)
+ raise ConnectionError(self._return_info("", method, path, msg))
+ self.connection._auth = None
+ self._verify_response(response, method, path, response_data)
+
+ def set_parameters(self):
+ connection_parameters = {}
+ for key in CONNECTION_KEYS:
+ value = self.params.get(key) if self.params.get(key) is not None else self.connection.get_option(CONNECTION_MAP.get(key, key))
+ if key == "username" and value is None:
+ value = "admin"
+ self.connection.set_option(CONNECTION_MAP.get(key, key), value)
+ if key == "timeout" and self.connection.get_option("persistent_connect_timeout") <= value:
+ self.connection.set_option("persistent_connect_timeout", value + 30)
+
+ connection_parameters[key] = value
+ if self.connection_parameters and value != self.connection_parameters.get(key) and key in RESET_KEYS:
+ self.connection._connected = False
+ self.connection.queue_message("debug", "Re-setting connection due to change in the {0}".format(key))
+
+ if self.params.get("private_key") is not None:
+ self.connection.set_option("session_key", None)
+ connection_parameters["certificate_name"] = self.params.get("certificate_name")
+ connection_parameters["private_key"] = self.params.get("private_key")
+ elif self.connection.get_option("session_key") is not None and self.params.get("password") is None:
+ connection_parameters["certificate_name"] = list(self.connection.get_option("session_key").keys())[0]
+ connection_parameters["private_key"] = list(self.connection.get_option("session_key").values())[0]
+ else:
+ if self.connection_parameters.get("private_key") is not None:
+ self.connection._connected = False
+ self.connection.queue_message(
+ "debug", "Re-setting connection due to change from private/session key authentication to password authentication"
+ )
+ self.connection.set_option("session_key", None)
+ connection_parameters["private_key"] = None
+ connection_parameters["certificate_name"] = None
+
+ if self.connection_parameters != connection_parameters:
+ self.connection_parameters = copy(connection_parameters)
+
+ self.set_hosts()
+
+ def set_hosts(self):
+ if self.params.get("host") is not None:
+ hosts = ast.literal_eval(self.params.get("host")) if "[" in self.params.get("host") else self.params.get("host").split(",")
+ else:
+ if self.inventory_hosts is None:
+ self.inventory_hosts = re.sub(r"[[\]]", "", self.connection.get_option("host")).split(",")
+ hosts = self.inventory_hosts
+
+ if self.provided_hosts is None:
+ self.provided_hosts = deepcopy(hosts)
+ self.connection.queue_message("debug", "Provided Hosts: {0}".format(self.provided_hosts))
+ self.backup_hosts = deepcopy(hosts)
+ self.current_host = self.backup_hosts.pop(0)
+ self.connection.queue_message("debug", "Initializing operation on {0}".format(self.current_host))
+ elif self.provided_hosts != hosts:
+ self.provided_hosts = deepcopy(hosts)
+ self.connection.queue_message("debug", "Provided Hosts have changed: {0}".format(self.provided_hosts))
+ self.backup_hosts = deepcopy(hosts)
+ try:
+ self.backup_hosts.pop(self.backup_hosts.index(self.current_host))
+ self.connection.queue_message("debug", "Connected host {0} found in the provided hosts. Continuing with it.".format(self.current_host))
+ except Exception:
+ self.current_host = self.backup_hosts.pop(0)
+ self.connection._connected = False
+ self.connection.queue_message("debug", "Initializing operation on {0}".format(self.current_host))
+ self.connection.set_option("host", self.current_host)
+
+ # One API call is made via each call to send_request from aci.py in module_utils
+ # As long as a host is active in the list the API call will go through
+ def send_request(self, method, path, data):
+ """This method handles all APIC REST API requests other than login"""
+
+ self.set_parameters()
+
+ if self.connection_parameters.get("private_key") is not None:
+ try:
+ self.connection._auth = {"Cookie": "{0}".format(self.cert_auth(method, path, data).get("Cookie"))}
+ self.connection._connected = True
+ except Exception as exc_response:
+ self.connection._connected = False
+ return self._return_info("", method, self.validate_url(self.connection._url + path), str(exc_response))
+
+ try:
+ if self.connection._connected is False:
+ self.login(self.connection.get_option("remote_user"), self.connection.get_option("password"))
+ self.connection.queue_message("debug", "Sending {0} request to {1}".format(method, self.connection._url + path))
+ response, response_data = self.connection.send(path, data, method=method)
+ self.connection.queue_message(
+ "debug", "Received response from {0} for {1} operation with HTTP: {2}".format(self.connection.get_option("host"), method, response.getcode())
+ )
+ except Exception as exc_response:
+ self.connection.queue_message("debug", "Connection to {0} has failed: {1}".format(self.connection.get_option("host"), exc_response))
+ if len(self.backup_hosts) == 0:
+ self.provided_hosts = None
+ self.connection._connected = False
+ error = dict(
+ code=-1, text="No hosts left in the cluster to continue operation! Error on final host {0}".format(self.connection.get_option("host"))
+ )
+ if "path" in dir(exc_response):
+ path = exc_response.path
+ return self._return_info("", method, self.validate_url(self.connection._url + path), str(exc_response), error=error)
+ else:
+ self.current_host = self.backup_hosts.pop(0)
+ self.connection.queue_message("debug", "Switching host from {0} to {1}".format(self.connection.get_option("host"), self.current_host))
+ self.connection.set_option("host", self.current_host)
+ # recurse through function for retrying the request
+ return self.send_request(method, path, data)
+ # return statement executed upon each successful response from the request function
+ return self._verify_response(response, method, path, response_data)
+
+ # Built-in-function
+ def handle_httperror(self, exc):
+ self.connection.queue_message("debug", "Failed to receive response from {0} with {1}".format(self.connection.get_option("host"), exc))
+ if exc.code == 401:
+ raise ConnectionError(exc)
+ elif exc.code == 403 and self.connection_parameters.get("private_key") is None:
+ self.connection._auth = None
+ self.login(self.connection.get_option("remote_user"), self.connection.get_option("password"))
+ return True
+ return exc
+
+ def validate_url(self, url):
+ validated_url = re.match(r"^.*?\.json|^.*?\.xml", url).group(0)
+ if self.connection_parameters.get("port") is None:
+ return validated_url.replace(re.match(r"(https?:\/\/.*)(:\d*)\/?(.*)", url).group(2), "")
+ else:
+ return validated_url
+
+ def _verify_response(self, response, method, path, response_data):
+ """Process the return code and response object from APIC"""
+ response_value = self._get_response_value(response_data)
+ response_code = response.getcode()
+ path = self.validate_url(response.url)
+ # Response check to remain consistent with fetch_url's response
+ if str(response) == "HTTP Error 400: Bad Request":
+ msg = "{0}".format(response)
+ else:
+ msg = "{0} ({1} bytes)".format(response.msg, len(response_value))
+ return self._return_info(response_code, method, path, msg, respond_data=response_value)
+
+ def _get_response_value(self, response_data):
+ """Extract string data from response_data returned from APIC"""
+ return to_text(response_data.getvalue())
+
+ def _response_to_json(self, response_text):
+ """Convert response_text to json format"""
+ try:
+ return json.loads(response_text) if response_text else {}
+ # JSONDecodeError only available on Python 3.5+
+ except Exception:
+ return "Invalid JSON response: {0}".format(response_text)
+
+ def _return_info(self, response_code, method, path, msg, respond_data=None, error=None):
+ """Format success/error data and return with consistent format"""
+ info = {}
+ info["status"] = response_code
+ info["method"] = method
+ info["url"] = path
+ info["msg"] = msg
+ if error is not None:
+ info["error"] = error
+ else:
+ info["error"] = {}
+ # Response check to trigger key error if response_data is invalid
+ if respond_data is not None:
+ info["body"] = respond_data
+ return info
+
+ def cert_auth(self, method, path, payload=""):
+ """Perform APIC signature-based authentication, not the expected SSL client certificate authentication."""
+
+ if payload is None:
+ payload = ""
+
+ headers = dict()
+
+ try:
+ if HAS_CRYPTOGRAPHY:
+ key = self.connection_parameters.get("private_key").encode()
+ sig_key = serialization.load_pem_private_key(
+ key,
+ password=None,
+ backend=default_backend(),
+ )
+ else:
+ sig_key = load_privatekey(FILETYPE_PEM, self.connection_parameters.get("private_key"))
+ except Exception:
+ if os.path.exists(self.connection_parameters.get("private_key")):
+ try:
+ permission = "r"
+ if HAS_CRYPTOGRAPHY:
+ permission = "rb"
+ with open(self.connection_parameters.get("private_key"), permission) as fh:
+ private_key_content = fh.read()
+ except Exception:
+ raise ConnectionError("Cannot open private key file {0}".format(self.connection_parameters.get("private_key")))
+ try:
+ if HAS_CRYPTOGRAPHY:
+ sig_key = serialization.load_pem_private_key(private_key_content, password=None, backend=default_backend())
+ else:
+ sig_key = load_privatekey(FILETYPE_PEM, private_key_content)
+ except Exception:
+ raise ConnectionError("Cannot load private key file {0}".format(self.connection_parameters.get("private_key")))
+ if self.connection_parameters.get("certificate_name") is None:
+ self.connection_parameters["certificate_name"] = os.path.basename(os.path.splitext(self.connection_parameters.get("private_key"))[0])
+ else:
+ raise ConnectionError(
+ "Provided private key {0} does not appear to be a private key or provided file does not exist.".format(
+ self.connection_parameters.get("private_key")
+ )
+ )
+ if self.connection_parameters.get("certificate_name") is None:
+ self.connection_parameters["certificate_name"] = self.connection.get_option("remote_user")
+ sig_request = method + path + payload
+ if HAS_CRYPTOGRAPHY:
+ sig_signature = sig_key.sign(sig_request.encode(), padding.PKCS1v15(), hashes.SHA256())
+ else:
+ sig_signature = sign(sig_key, sig_request, "sha256")
+ sig_dn = "uni/userext/user-{0}/usercert-{1}".format(self.connection.get_option("remote_user"), self.connection_parameters.get("certificate_name"))
+ headers["Cookie"] = (
+ "APIC-Certificate-Algorithm=v1.0; "
+ + "APIC-Certificate-DN={0}; ".format(sig_dn)
+ + "APIC-Certificate-Fingerprint=fingerprint; "
+ + "APIC-Request-Signature={0}".format(to_native(base64.b64encode(sig_signature)))
+ )
+ return headers