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