1# Copyright (c) 2018 Cisco and/or its affiliates. 2# 3# This file is part of Ansible 4# 5# Ansible is free software: you can redistribute it and/or modify 6# it under the terms of the GNU General Public License as published by 7# the Free Software Foundation, either version 3 of the License, or 8# (at your option) any later version. 9# 10# Ansible is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU General Public License for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with Ansible. If not, see <http://www.gnu.org/licenses/>. 17# 18 19from __future__ import (absolute_import, division, print_function) 20 21__metaclass__ = type 22 23DOCUMENTATION = """ 24--- 25author: Ansible Networking Team 26httpapi : ftd 27short_description: HttpApi Plugin for Cisco ASA Firepower device 28description: 29 - This HttpApi plugin provides methods to connect to Cisco ASA firepower 30 devices over a HTTP(S)-based api. 31version_added: "2.7" 32options: 33 token_path: 34 type: str 35 description: 36 - Specifies the api token path of the FTD device 37 vars: 38 - name: ansible_httpapi_ftd_token_path 39 spec_path: 40 type: str 41 description: 42 - Specifies the api spec path of the FTD device 43 default: '/apispec/ngfw.json' 44 vars: 45 - name: ansible_httpapi_ftd_spec_path 46""" 47 48import json 49import os 50import re 51 52from ansible import __version__ as ansible_version 53 54from ansible.module_utils.basic import to_text 55from ansible.errors import AnsibleConnectionFailure 56from ansible.module_utils.network.ftd.fdm_swagger_client import FdmSwaggerParser, SpecProp, FdmSwaggerValidator 57from ansible.module_utils.network.ftd.common import HTTPMethod, ResponseParams 58from ansible.module_utils.six.moves.urllib.error import HTTPError 59from ansible.module_utils.six.moves.urllib.parse import urlencode 60from ansible.plugins.httpapi import HttpApiBase 61from urllib3 import encode_multipart_formdata 62from urllib3.fields import RequestField 63from ansible.module_utils.connection import ConnectionError 64 65BASE_HEADERS = { 66 'Content-Type': 'application/json', 67 'Accept': 'application/json', 68 'User-Agent': 'FTD Ansible/%s' % ansible_version 69} 70 71TOKEN_EXPIRATION_STATUS_CODE = 408 72UNAUTHORIZED_STATUS_CODE = 401 73API_TOKEN_PATH_OPTION_NAME = 'token_path' 74TOKEN_PATH_TEMPLATE = '/api/fdm/{0}/fdm/token' 75GET_API_VERSIONS_PATH = '/api/versions' 76DEFAULT_API_VERSIONS = ['v2', 'v1'] 77 78INVALID_API_TOKEN_PATH_MSG = ('The API token path is incorrect. Please, check correctness of ' 79 'the `ansible_httpapi_ftd_token_path` variable in the inventory file.') 80MISSING_API_TOKEN_PATH_MSG = ('Ansible could not determine the API token path automatically. Please, ' 81 'specify the `ansible_httpapi_ftd_token_path` variable in the inventory file.') 82 83 84class HttpApi(HttpApiBase): 85 def __init__(self, connection): 86 super(HttpApi, self).__init__(connection) 87 self.connection = connection 88 self.access_token = None 89 self.refresh_token = None 90 self._api_spec = None 91 self._api_validator = None 92 self._ignore_http_errors = False 93 94 def login(self, username, password): 95 def request_token_payload(username, password): 96 return { 97 'grant_type': 'password', 98 'username': username, 99 'password': password 100 } 101 102 def refresh_token_payload(refresh_token): 103 return { 104 'grant_type': 'refresh_token', 105 'refresh_token': refresh_token 106 } 107 108 if self.refresh_token: 109 payload = refresh_token_payload(self.refresh_token) 110 elif username and password: 111 payload = request_token_payload(username, password) 112 else: 113 raise AnsibleConnectionFailure('Username and password are required for login in absence of refresh token') 114 115 response = self._lookup_login_url(payload) 116 117 try: 118 self.refresh_token = response['refresh_token'] 119 self.access_token = response['access_token'] 120 self.connection._auth = {'Authorization': 'Bearer %s' % self.access_token} 121 except KeyError: 122 raise ConnectionError( 123 'Server returned response without token info during connection authentication: %s' % response) 124 125 def _lookup_login_url(self, payload): 126 """ Try to find correct login URL and get api token using this URL. 127 128 :param payload: Token request payload 129 :type payload: dict 130 :return: token generation response 131 """ 132 preconfigured_token_path = self._get_api_token_path() 133 if preconfigured_token_path: 134 token_paths = [preconfigured_token_path] 135 else: 136 token_paths = self._get_known_token_paths() 137 138 for url in token_paths: 139 try: 140 response = self._send_login_request(payload, url) 141 142 except ConnectionError as e: 143 self.connection.queue_message('vvvv', 'REST:request to %s failed because of connection error: %s ' % ( 144 url, e)) 145 # In the case of ConnectionError caused by HTTPError we should check response code. 146 # Response code 400 returned in case of invalid credentials so we should stop attempts to log in and 147 # inform the user. 148 if hasattr(e, 'http_code') and e.http_code == 400: 149 raise 150 else: 151 if not preconfigured_token_path: 152 self._set_api_token_path(url) 153 return response 154 155 raise ConnectionError(INVALID_API_TOKEN_PATH_MSG if preconfigured_token_path else MISSING_API_TOKEN_PATH_MSG) 156 157 def _send_login_request(self, payload, url): 158 self._display(HTTPMethod.POST, 'login', url) 159 response, response_data = self._send_auth_request( 160 url, json.dumps(payload), method=HTTPMethod.POST, headers=BASE_HEADERS 161 ) 162 self._display(HTTPMethod.POST, 'login:status_code', response.getcode()) 163 164 response = self._response_to_json(self._get_response_value(response_data)) 165 return response 166 167 def logout(self): 168 auth_payload = { 169 'grant_type': 'revoke_token', 170 'access_token': self.access_token, 171 'token_to_revoke': self.refresh_token 172 } 173 174 url = self._get_api_token_path() 175 176 self._display(HTTPMethod.POST, 'logout', url) 177 response, dummy = self._send_auth_request(url, json.dumps(auth_payload), method=HTTPMethod.POST, 178 headers=BASE_HEADERS) 179 self._display(HTTPMethod.POST, 'logout:status_code', response.getcode()) 180 181 self.refresh_token = None 182 self.access_token = None 183 184 def _send_auth_request(self, path, data, **kwargs): 185 error_msg_prefix = 'Server returned an error during authentication request' 186 return self._send_service_request(path, error_msg_prefix, data=data, **kwargs) 187 188 def _send_service_request(self, path, error_msg_prefix, data=None, **kwargs): 189 try: 190 self._ignore_http_errors = True 191 return self.connection.send(path, data, **kwargs) 192 except HTTPError as e: 193 # HttpApi connection does not read the error response from HTTPError, so we do it here and wrap it up in 194 # ConnectionError, so the actual error message is displayed to the user. 195 error_msg = self._response_to_json(to_text(e.read())) 196 raise ConnectionError('%s: %s' % (error_msg_prefix, error_msg), http_code=e.code) 197 finally: 198 self._ignore_http_errors = False 199 200 def update_auth(self, response, response_data): 201 # With tokens, authentication should not be checked and updated on each request 202 return None 203 204 def send_request(self, url_path, http_method, body_params=None, path_params=None, query_params=None): 205 url = construct_url_path(url_path, path_params, query_params) 206 data = json.dumps(body_params) if body_params else None 207 try: 208 self._display(http_method, 'url', url) 209 if data: 210 self._display(http_method, 'data', data) 211 212 response, response_data = self.connection.send(url, data, method=http_method, headers=BASE_HEADERS) 213 214 value = self._get_response_value(response_data) 215 self._display(http_method, 'response', value) 216 217 return { 218 ResponseParams.SUCCESS: True, 219 ResponseParams.STATUS_CODE: response.getcode(), 220 ResponseParams.RESPONSE: self._response_to_json(value) 221 } 222 # Being invoked via JSON-RPC, this method does not serialize and pass HTTPError correctly to the method caller. 223 # Thus, in order to handle non-200 responses, we need to wrap them into a simple structure and pass explicitly. 224 except HTTPError as e: 225 error_msg = to_text(e.read()) 226 self._display(http_method, 'error', error_msg) 227 return { 228 ResponseParams.SUCCESS: False, 229 ResponseParams.STATUS_CODE: e.code, 230 ResponseParams.RESPONSE: self._response_to_json(error_msg) 231 } 232 233 def upload_file(self, from_path, to_url): 234 url = construct_url_path(to_url) 235 self._display(HTTPMethod.POST, 'upload', url) 236 with open(from_path, 'rb') as src_file: 237 rf = RequestField('fileToUpload', src_file.read(), os.path.basename(src_file.name)) 238 rf.make_multipart() 239 body, content_type = encode_multipart_formdata([rf]) 240 241 headers = dict(BASE_HEADERS) 242 headers['Content-Type'] = content_type 243 headers['Content-Length'] = len(body) 244 245 dummy, response_data = self.connection.send(url, data=body, method=HTTPMethod.POST, headers=headers) 246 value = self._get_response_value(response_data) 247 self._display(HTTPMethod.POST, 'upload:response', value) 248 return self._response_to_json(value) 249 250 def download_file(self, from_url, to_path, path_params=None): 251 url = construct_url_path(from_url, path_params=path_params) 252 self._display(HTTPMethod.GET, 'download', url) 253 response, response_data = self.connection.send(url, data=None, method=HTTPMethod.GET, headers=BASE_HEADERS) 254 255 if os.path.isdir(to_path): 256 filename = extract_filename_from_headers(response.info()) 257 to_path = os.path.join(to_path, filename) 258 259 with open(to_path, "wb") as output_file: 260 output_file.write(response_data.getvalue()) 261 self._display(HTTPMethod.GET, 'downloaded', to_path) 262 263 def handle_httperror(self, exc): 264 is_auth_related_code = exc.code == TOKEN_EXPIRATION_STATUS_CODE or exc.code == UNAUTHORIZED_STATUS_CODE 265 if not self._ignore_http_errors and is_auth_related_code: 266 self.connection._auth = None 267 self.login(self.connection.get_option('remote_user'), self.connection.get_option('password')) 268 return True 269 # False means that the exception will be passed further to the caller 270 return False 271 272 def _display(self, http_method, title, msg=''): 273 self.connection.queue_message('vvvv', 'REST:%s:%s:%s\n%s' % (http_method, self.connection._url, title, msg)) 274 275 @staticmethod 276 def _get_response_value(response_data): 277 return to_text(response_data.getvalue()) 278 279 def _get_api_spec_path(self): 280 return self.get_option('spec_path') 281 282 def _get_known_token_paths(self): 283 """Generate list of token generation urls based on list of versions supported by device(if exposed via API) or 284 default list of API versions. 285 286 :returns: list of token generation urls 287 :rtype: generator 288 """ 289 try: 290 api_versions = self._get_supported_api_versions() 291 except ConnectionError: 292 # API versions API is not supported we need to check all known version 293 api_versions = DEFAULT_API_VERSIONS 294 295 return [TOKEN_PATH_TEMPLATE.format(version) for version in api_versions] 296 297 def _get_supported_api_versions(self): 298 """ 299 Fetch list of API versions supported by device. 300 301 :return: list of API versions suitable for device 302 :rtype: list 303 """ 304 # Try to fetch supported API version 305 http_method = HTTPMethod.GET 306 response, response_data = self._send_service_request( 307 path=GET_API_VERSIONS_PATH, 308 error_msg_prefix="Can't fetch list of supported api versions", 309 method=http_method, 310 headers=BASE_HEADERS 311 ) 312 313 value = self._get_response_value(response_data) 314 self._display(http_method, 'response', value) 315 api_versions_info = self._response_to_json(value) 316 return api_versions_info["supportedVersions"] 317 318 def _get_api_token_path(self): 319 return self.get_option(API_TOKEN_PATH_OPTION_NAME) 320 321 def _set_api_token_path(self, url): 322 return self.set_option(API_TOKEN_PATH_OPTION_NAME, url) 323 324 @staticmethod 325 def _response_to_json(response_text): 326 try: 327 return json.loads(response_text) if response_text else {} 328 # JSONDecodeError only available on Python 3.5+ 329 except getattr(json.decoder, 'JSONDecodeError', ValueError): 330 raise ConnectionError('Invalid JSON response: %s' % response_text) 331 332 def get_operation_spec(self, operation_name): 333 return self.api_spec[SpecProp.OPERATIONS].get(operation_name, None) 334 335 def get_operation_specs_by_model_name(self, model_name): 336 if model_name: 337 return self.api_spec[SpecProp.MODEL_OPERATIONS].get(model_name, None) 338 else: 339 return None 340 341 def get_model_spec(self, model_name): 342 return self.api_spec[SpecProp.MODELS].get(model_name, None) 343 344 def validate_data(self, operation_name, data): 345 return self.api_validator.validate_data(operation_name, data) 346 347 def validate_query_params(self, operation_name, params): 348 return self.api_validator.validate_query_params(operation_name, params) 349 350 def validate_path_params(self, operation_name, params): 351 return self.api_validator.validate_path_params(operation_name, params) 352 353 @property 354 def api_spec(self): 355 if self._api_spec is None: 356 spec_path_url = self._get_api_spec_path() 357 response = self.send_request(url_path=spec_path_url, http_method=HTTPMethod.GET) 358 if response[ResponseParams.SUCCESS]: 359 self._api_spec = FdmSwaggerParser().parse_spec(response[ResponseParams.RESPONSE]) 360 else: 361 raise ConnectionError('Failed to download API specification. Status code: %s. Response: %s' % ( 362 response[ResponseParams.STATUS_CODE], response[ResponseParams.RESPONSE])) 363 return self._api_spec 364 365 @property 366 def api_validator(self): 367 if self._api_validator is None: 368 self._api_validator = FdmSwaggerValidator(self.api_spec) 369 return self._api_validator 370 371 372def construct_url_path(path, path_params=None, query_params=None): 373 url = path 374 if path_params: 375 url = url.format(**path_params) 376 if query_params: 377 url += "?" + urlencode(query_params) 378 return url 379 380 381def extract_filename_from_headers(response_info): 382 content_header_regex = r'attachment; ?filename="?([^"]+)' 383 match = re.match(content_header_regex, response_info.get('Content-Disposition')) 384 if match: 385 return match.group(1) 386 else: 387 raise ValueError("No appropriate Content-Disposition header is specified.") 388