1# -*- coding: utf-8 -*- 2# 3# Copyright (c) 2017, F5 Networks Inc. 4# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 5 6from __future__ import absolute_import, division, print_function 7__metaclass__ = type 8 9 10import os 11 12try: 13 from StringIO import StringIO 14except ImportError: 15 from io import StringIO 16 17try: 18 from BytesIO import BytesIO 19except ImportError: 20 from io import BytesIO 21 22from ansible.module_utils.urls import urlparse 23from ansible.module_utils.urls import generic_urlparse 24from ansible.module_utils.urls import Request 25 26try: 27 import json as _json 28except ImportError: 29 import simplejson as _json 30 31try: 32 from library.module_utils.network.f5.common import F5ModuleError 33except ImportError: 34 from ansible.module_utils.network.f5.common import F5ModuleError 35 36 37"""An F5 REST API URI handler. 38 39Use this module to make calls to an F5 REST server. It is influenced by the same 40API that the Python ``requests`` tool uses, but the two are not the same, as the 41library here is **much** more simple and targeted specifically to F5's needs. 42 43The ``requests`` design was chosen due to familiarity with the tool. Internally, 44the classes contained herein use Ansible native libraries. 45 46The means by which you should use it are similar to ``requests`` basic usage. 47 48Authentication is not handled for you automatically by this library, however it *is* 49handled automatically for you in the supporting F5 module_utils code; specifically the 50different product module_util files (bigip.py, bigiq.py, etc). 51 52Internal (non-module) usage of this library looks like this. 53 54``` 55# Create a session instance 56mgmt = iControlRestSession() 57mgmt.verify = False 58 59server = '1.1.1.1' 60port = 443 61 62# Payload used for getting an initial authentication token 63payload = { 64 'username': 'admin', 65 'password': 'secret', 66 'loginProviderName': 'tmos' 67} 68 69# Create URL to call, injecting server and port 70url = f"https://{server}:{port}/mgmt/shared/authn/login" 71 72# Call the API 73resp = session.post(url, json=payload) 74 75# View the response 76print(resp.json()) 77 78# Update the session with the authentication token 79session.headers['X-F5-Auth-Token'] = resp.json()['token']['token'] 80 81# Create another URL to call, injecting server and port 82url = f"https://{server}:{port}/mgmt/tm/ltm/virtual/~Common~virtual1" 83 84# Call the API 85resp = session.get(url) 86 87# View the details of a virtual payload 88print(resp.json()) 89``` 90""" 91 92from ansible.module_utils.six.moves.urllib.error import HTTPError 93 94 95class Response(object): 96 def __init__(self): 97 self._content = None 98 self.status = None 99 self.headers = dict() 100 self.url = None 101 self.reason = None 102 self.request = None 103 self.msg = None 104 105 @property 106 def content(self): 107 return self._content 108 109 @property 110 def raw_content(self): 111 return self._content 112 113 def json(self): 114 return _json.loads(self._content or 'null') 115 116 @property 117 def ok(self): 118 if self.status is not None and int(self.status) > 400: 119 return False 120 try: 121 response = self.json() 122 if 'code' in response and response['code'] > 400: 123 return False 124 except ValueError: 125 pass 126 return True 127 128 129class iControlRestSession(object): 130 """Represents a session that communicates with a BigIP. 131 132 This acts as a loose wrapper around Ansible's ``Request`` class. We're doing 133 this as interim work until we move to the httpapi connector. 134 """ 135 def __init__(self, headers=None, use_proxy=True, force=False, timeout=120, 136 validate_certs=True, url_username=None, url_password=None, 137 http_agent=None, force_basic_auth=False, follow_redirects='urllib2', 138 client_cert=None, client_key=None, cookies=None): 139 self.request = Request( 140 headers=headers, 141 use_proxy=use_proxy, 142 force=force, 143 timeout=timeout, 144 validate_certs=validate_certs, 145 url_username=url_username, 146 url_password=url_password, 147 http_agent=http_agent, 148 force_basic_auth=force_basic_auth, 149 follow_redirects=follow_redirects, 150 client_cert=client_cert, 151 client_key=client_key, 152 cookies=cookies 153 ) 154 self.last_url = None 155 156 def get_headers(self, result): 157 try: 158 return dict(result.getheaders()) 159 except AttributeError: 160 return result.headers 161 162 def update_response(self, response, result): 163 response.headers = self.get_headers(result) 164 response._content = result.read() 165 response.status = result.getcode() 166 response.url = result.geturl() 167 response.msg = "OK (%s bytes)" % response.headers.get('Content-Length', 'unknown') 168 169 def send(self, method, url, **kwargs): 170 response = Response() 171 172 # Set the last_url called 173 # 174 # This is used by the object destructor to erase the token when the 175 # ModuleManager exits and destroys the iControlRestSession object 176 self.last_url = url 177 178 body = None 179 data = kwargs.pop('data', None) 180 json = kwargs.pop('json', None) 181 182 if not data and json is not None: 183 self.request.headers['Content-Type'] = 'application/json' 184 body = _json.dumps(json) 185 if not isinstance(body, bytes): 186 body = body.encode('utf-8') 187 if data: 188 body = data 189 if body: 190 kwargs['data'] = body 191 192 try: 193 result = self.request.open(method, url, **kwargs) 194 except HTTPError as e: 195 # Catch HTTPError delivered from Ansible 196 # 197 # The structure of this object, in Ansible 2.8 is 198 # 199 # HttpError { 200 # args 201 # characters_written 202 # close 203 # code 204 # delete 205 # errno 206 # file 207 # filename 208 # filename2 209 # fp 210 # getcode 211 # geturl 212 # hdrs 213 # headers 214 # info 215 # msg 216 # name 217 # reason 218 # strerror 219 # url 220 # with_traceback 221 # } 222 self.update_response(response, e) 223 return response 224 225 self.update_response(response, result) 226 return response 227 228 def delete(self, url, **kwargs): 229 return self.send('DELETE', url, **kwargs) 230 231 def get(self, url, **kwargs): 232 return self.send('GET', url, **kwargs) 233 234 def patch(self, url, data=None, **kwargs): 235 return self.send('PATCH', url, data=data, **kwargs) 236 237 def post(self, url, data=None, **kwargs): 238 return self.send('POST', url, data=data, **kwargs) 239 240 def put(self, url, data=None, **kwargs): 241 return self.send('PUT', url, data=data, **kwargs) 242 243 def __del__(self): 244 if self.last_url is None: 245 return 246 token = self.request.headers.get('X-F5-Auth-Token', None) 247 if not token: 248 return 249 try: 250 p = generic_urlparse(urlparse(self.last_url)) 251 uri = "https://{0}:{1}/mgmt/shared/authz/tokens/{2}".format( 252 p['hostname'], p['port'], token 253 ) 254 self.delete(uri) 255 except ValueError: 256 pass 257 258 259class TransactionContextManager(object): 260 def __init__(self, client, validate_only=False): 261 self.client = client 262 self.validate_only = validate_only 263 self.transid = None 264 265 def __enter__(self): 266 uri = "https://{0}:{1}/mgmt/tm/transaction/".format( 267 self.client.provider['server'], 268 self.client.provider['server_port'] 269 ) 270 resp = self.client.api.post(uri, json={}) 271 if resp.status not in [200]: 272 raise Exception 273 try: 274 response = resp.json() 275 except ValueError as ex: 276 raise F5ModuleError(str(ex)) 277 278 self.transid = response['transId'] 279 self.client.api.request.headers['X-F5-REST-Coordination-Id'] = self.transid 280 return self.client 281 282 def __exit__(self, exc_type, exc_value, exc_tb): 283 self.client.api.request.headers.pop('X-F5-REST-Coordination-Id') 284 if exc_tb is None: 285 uri = "https://{0}:{1}/mgmt/tm/transaction/{2}".format( 286 self.client.provider['server'], 287 self.client.provider['server_port'], 288 self.transid 289 ) 290 params = dict( 291 state="VALIDATING", 292 validateOnly=self.validate_only 293 ) 294 resp = self.client.api.patch(uri, json=params) 295 if resp.status not in [200]: 296 raise Exception 297 298 299def download_asm_file(client, url, dest): 300 """Download an ASM file from the remote device 301 302 This method handles issues with ASM file endpoints that allow 303 downloads of ASM objects on the BIG-IP. 304 305 Arguments: 306 client (object): The F5RestClient connection object. 307 url (string): The URL to download. 308 dest (string): The location on (Ansible controller) disk to store the file. 309 310 Returns: 311 bool: True on success. False otherwise. 312 """ 313 314 with open(dest, 'wb') as fileobj: 315 headers = { 316 'Content-Type': 'application/json' 317 } 318 data = {'headers': headers, 319 'verify': False 320 } 321 322 response = client.api.get(url, headers=headers, json=data) 323 if response.status == 200: 324 if 'Content-Length' not in response.headers: 325 error_message = "The Content-Length header is not present." 326 raise F5ModuleError(error_message) 327 328 length = response.headers['Content-Length'] 329 330 if int(length) > 0: 331 fileobj.write(response.content) 332 else: 333 error = "Invalid Content-Length value returned: %s ," \ 334 "the value should be greater than 0" % length 335 raise F5ModuleError(error) 336 337 338def download_file(client, url, dest): 339 """Download a file from the remote device 340 341 This method handles the chunking needed to download a file from 342 a given URL on the BIG-IP. 343 344 Arguments: 345 client (object): The F5RestClient connection object. 346 url (string): The URL to download. 347 dest (string): The location on (Ansible controller) disk to store the file. 348 349 Returns: 350 bool: True on success. False otherwise. 351 """ 352 with open(dest, 'wb') as fileobj: 353 chunk_size = 512 * 1024 354 start = 0 355 end = chunk_size - 1 356 size = 0 357 current_bytes = 0 358 359 while True: 360 content_range = "%s-%s/%s" % (start, end, size) 361 headers = { 362 'Content-Range': content_range, 363 'Content-Type': 'application/octet-stream' 364 } 365 data = { 366 'headers': headers, 367 'verify': False, 368 'stream': False 369 } 370 response = client.api.get(url, headers=headers, json=data) 371 if response.status == 200: 372 # If the size is zero, then this is the first time through 373 # the loop and we don't want to write data because we 374 # haven't yet figured out the total size of the file. 375 if size > 0: 376 current_bytes += chunk_size 377 fileobj.write(response.raw_content) 378 # Once we've downloaded the entire file, we can break out of 379 # the loop 380 if end == size: 381 break 382 crange = response.headers['Content-Range'] 383 # Determine the total number of bytes to read. 384 if size == 0: 385 size = int(crange.split('/')[-1]) - 1 386 # If the file is smaller than the chunk_size, the BigIP 387 # will return an HTTP 400. Adjust the chunk_size down to 388 # the total file size... 389 if chunk_size > size: 390 end = size 391 # ...and pass on the rest of the code. 392 continue 393 start += chunk_size 394 if (current_bytes + chunk_size) > size: 395 end = size 396 else: 397 end = start + chunk_size - 1 398 return True 399 400 401def upload_file(client, url, src, dest=None): 402 """Upload a file to an arbitrary URL. 403 404 This method is responsible for correctly chunking an upload request to an 405 arbitrary file worker URL. 406 407 Arguments: 408 client (object): The F5RestClient connection object. 409 url (string): The URL to upload a file to. 410 src (string): The file to be uploaded. 411 dest (string): The file name to create on the remote device. 412 413 Examples: 414 The ``dest`` may be either an absolute or relative path. The basename 415 of the path is used as the remote file name upon upload. For instance, 416 in the example below, ``BIGIP-13.1.0.8-0.0.3.iso`` would be the name 417 of the remote file. 418 419 The specified URL should be the full URL to where you want to upload a 420 file. BIG-IP has many different URLs that can be used to handle different 421 types of files. This is why a full URL is required. 422 423 >>> from ansible.module_utils.network.f5.icontrol import upload_client 424 >>> url = 'https://{0}:{1}/mgmt/cm/autodeploy/software-image-uploads'.format( 425 ... self.client.provider['server'], 426 ... self.client.provider['server_port'] 427 ... ) 428 >>> dest = '/path/to/BIGIP-13.1.0.8-0.0.3.iso' 429 >>> upload_file(self.client, url, dest) 430 True 431 432 Returns: 433 bool: True on success. False otherwise. 434 435 Raises: 436 F5ModuleError: Raised if ``retries`` limit is exceeded. 437 """ 438 if isinstance(src, StringIO) or isinstance(src, BytesIO): 439 fileobj = src 440 else: 441 fileobj = open(src, 'rb') 442 443 try: 444 size = os.stat(src).st_size 445 is_file = True 446 except TypeError: 447 src.seek(0, os.SEEK_END) 448 size = src.tell() 449 src.seek(0) 450 is_file = False 451 452 # This appears to be the largest chunk size that iControlREST can handle. 453 # 454 # The trade-off you are making by choosing a chunk size is speed, over size of 455 # transmission. A lower chunk size will be slower because a smaller amount of 456 # data is read from disk and sent via HTTP. Lots of disk reads are slower and 457 # There is overhead in sending the request to the BIG-IP. 458 # 459 # Larger chunk sizes are faster because more data is read from disk in one 460 # go, and therefore more data is transmitted to the BIG-IP in one HTTP request. 461 # 462 # If you are transmitting over a slow link though, it may be more reliable to 463 # transmit many small chunks that fewer large chunks. It will clearly take 464 # longer, but it may be more robust. 465 chunk_size = 1024 * 7168 466 start = 0 467 retries = 0 468 if dest is None and is_file: 469 basename = os.path.basename(src) 470 else: 471 basename = dest 472 url = '{0}/{1}'.format(url.rstrip('/'), basename) 473 474 while True: 475 if retries == 3: 476 # Retries are used here to allow the REST API to recover if you kill 477 # an upload mid-transfer. 478 # 479 # There exists a case where retrying a new upload will result in the 480 # API returning the POSTed payload (in bytes) with a non-200 response 481 # code. 482 # 483 # Retrying (after seeking back to 0) seems to resolve this problem. 484 raise F5ModuleError( 485 "Failed to upload file too many times." 486 ) 487 try: 488 file_slice = fileobj.read(chunk_size) 489 if not file_slice: 490 break 491 492 current_bytes = len(file_slice) 493 if current_bytes < chunk_size: 494 end = size 495 else: 496 end = start + current_bytes 497 headers = { 498 'Content-Range': '%s-%s/%s' % (start, end - 1, size), 499 'Content-Type': 'application/octet-stream' 500 } 501 502 # Data should always be sent using the ``data`` keyword and not the 503 # ``json`` keyword. This allows bytes to be sent (such as in the case 504 # of uploading ISO files. 505 response = client.api.post(url, headers=headers, data=file_slice) 506 507 if response.status != 200: 508 # When this fails, the output is usually the body of whatever you 509 # POSTed. This is almost always unreadable because it is a series 510 # of bytes. 511 # 512 # Therefore, including an empty exception here. 513 raise F5ModuleError() 514 start += current_bytes 515 except F5ModuleError: 516 # You must seek back to the beginning of the file upon exception. 517 # 518 # If this is not done, then you risk uploading a partial file. 519 fileobj.seek(0) 520 retries += 1 521 return True 522 523 524def tmos_version(client): 525 uri = "https://{0}:{1}/mgmt/tm/sys/".format( 526 client.provider['server'], 527 client.provider['server_port'], 528 ) 529 resp = client.api.get(uri) 530 531 try: 532 response = resp.json() 533 except ValueError as ex: 534 raise F5ModuleError(str(ex)) 535 536 if 'code' in response and response['code'] in [400, 403]: 537 if 'message' in response: 538 raise F5ModuleError(response['message']) 539 else: 540 raise F5ModuleError(resp.content) 541 542 to_parse = urlparse(response['selfLink']) 543 query = to_parse.query 544 version = query.split('=')[1] 545 return version 546 547 548def bigiq_version(client): 549 uri = "https://{0}:{1}/mgmt/shared/resolver/device-groups/cm-shared-all-big-iqs/devices".format( 550 client.provider['server'], 551 client.provider['server_port'], 552 ) 553 query = "?$select=version" 554 555 resp = client.api.get(uri + query) 556 557 try: 558 response = resp.json() 559 except ValueError as ex: 560 raise F5ModuleError(str(ex)) 561 562 if 'code' in response and response['code'] in [400, 403]: 563 if 'message' in response: 564 raise F5ModuleError(response['message']) 565 else: 566 raise F5ModuleError(resp.content) 567 568 if 'items' in response: 569 version = response['items'][0]['version'] 570 return version 571 572 raise F5ModuleError( 573 'Failed to retrieve BIGIQ version information.' 574 ) 575 576 577def module_provisioned(client, module_name): 578 provisioned = modules_provisioned(client) 579 if module_name in provisioned: 580 return True 581 return False 582 583 584def modules_provisioned(client): 585 """Returns a list of all provisioned modules 586 587 Args: 588 client: Client connection to the BIG-IP 589 590 Returns: 591 A list of provisioned modules in their short name for. 592 For example, ['afm', 'asm', 'ltm'] 593 """ 594 uri = "https://{0}:{1}/mgmt/tm/sys/provision".format( 595 client.provider['server'], 596 client.provider['server_port'] 597 ) 598 resp = client.api.get(uri) 599 600 try: 601 response = resp.json() 602 except ValueError as ex: 603 raise F5ModuleError(str(ex)) 604 605 if 'code' in response and response['code'] in [400, 403]: 606 if 'message' in response: 607 raise F5ModuleError(response['message']) 608 else: 609 raise F5ModuleError(resp.content) 610 if 'items' not in response: 611 return [] 612 return [x['name'] for x in response['items'] if x['level'] != 'none'] 613