diff --git a/.gitignore b/.gitignore index 6301be4..203f97f 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,7 @@ nosetests.xml # Mr Developer .mr.developer.cfg .project -.pydevproject \ No newline at end of file +.pydevproject + +# PyCharm +.idea/ \ No newline at end of file diff --git a/HISTORY.rst b/HISTORY.rst index 6766619..50aba18 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,5 +1,17 @@ Release History --------------- +1.1.0 (2014-09-16) +++++++++++++++++++ + +* Internally improved Python2/3 compaability with the intoduction of the dyn.compat module +* Timestamps for various report types are accepted as Python datetime.datetime instances +* Added qps report access to Zones +* Added __str__, __repr__, __unicode__, and __bytes__ methods to all API object types +* Added conditional password encryption to allow for better in-app security +* Added the ability for users to specify their own password encryption keys +* Added __getstate__ and __setstate__ methods to SessionEngine, allowing sessions to be serialized +* Misc bug fixes + 1.0.3 (2014-09-05) ++++++++++++++++++ @@ -9,7 +21,7 @@ Release History ++++++++++++++++++ * Added reports module -* Updated installation documentation. +* Updated installation documentation 1.0.1 (2014-08-06) ++++++++++++++++++ diff --git a/README.rst b/README.rst index a064c47..b6f396b 100644 --- a/README.rst +++ b/README.rst @@ -7,3 +7,30 @@ services. Requires Python 2.6 or higher, or the "simplejson" package. For full documentation and examples see the dyn module on `Read The Docs `_. + +Installation +------------ + +To install the dyn SDK, simply: + +.. code-block:: bash + + $ pip install dyn + + +Documentation +------------- + +Documentation is available on `Read The Docs`_ + +Contribute +---------- + +#. Check for open issues or open a new issue to start a discussion around a feature idea or a bug. +#. For bug reports especially it's encouraged for you to include a code snippet highlighting the bug. +#. For feature requests it's encouraged for you to include sample code highlighting a use case for the new feature. +#. Fork `the repository`_ on GitHub to start making your changes to the **master** branch (or branch off of it). +#. Send a pull request and bug the maintainer until it gets merged and published. :) + +.. _`the repository`: http://github.com/dyninc/dyn-python +.. _`Read The Docs`: http://dyn.readthedocs.org> diff --git a/docs/sessions.rst b/docs/advanced.rst similarity index 74% rename from docs/sessions.rst rename to docs/advanced.rst index d456012..439bd4c 100644 --- a/docs/sessions.rst +++ b/docs/advanced.rst @@ -1,7 +1,13 @@ .. _sessions: -Advanced Sessions Overview -========================== +Advanced Topics +=============== +This Section serves as a collective for advanced topics that most developers +using this library will never need to know about, but that may be useful for +developers who are destined to maintain this package + +Sessions +-------- The way in which sessions are handled in this library are designed to be super easy to use for developers who use this library, however, have become relatively @@ -10,7 +16,7 @@ mainly for developers who would like to contribute to this code base, or who are just curious as to what is actually going on under the hood. Parent Class ------------- +^^^^^^^^^^^^ Both :class:`dyn.tm.session.DynectSession` and :class:`dyn.mm.session.MMSession` are subclasses of :class:`dyn.core.SessionEngine`. The :class:`dyn.core.SessionEngine` provides an easy to use internal API for preparing, sending, and processing outbound @@ -18,7 +24,7 @@ API calls. This class was added in v1.0.0 and greatly reduced the amount of logi and duplicated code that made looking at these sessions so overly complex. Parent Type ------------ +^^^^^^^^^^^ Since v0.4.0 sessions had always been implemented as a Singleton type. At this point you're probably asing "Why?" And that's a bit of a complicated question. One of the main reasons that these sessions were implemented as a Singleton was to make it easier for @@ -120,3 +126,39 @@ other instances, since those instances are tied to the classes themselves instea of held in the *globals* of the session modules. In addition this allows users to have multiple active sessions across multiple threads, which was previously impossible in the prior implementation. + + +Password Encryption +------------------- +The DynECT REST API only accepts passwords in plain text, and currently there is +no way around that. However, for those of you that are particularly mindful of +security (and even those of you who aren't) can probably see some serious pitfalls +to this. As far as most users of this library are concerned the passwords stored in +their :class:`~dyn.tm.session.DynectSession` objects will only ever live in memory, +so it's really not a huge deal that their passwords are stored in plain text. However, +for users looking to do more advanced things, such as serialize and store their session +objects in something less secure, such as a database, then these plain text passwords +are far less than ideal. Because of this in version 1.1.0 we've added optional +AES-256 password encryption for all :class:`~dyn.tm.session.DynectSession` +instances. All you need to do to enable password encryption is install +`PyCrypto `_. The rest will happen +automatically. + +Key Generation +^^^^^^^^^^^^^^ +Also in version 1.1.0 an optional key field parameter was added to the +:class:`~dyn.tm.session.DynectSession` __init__ method. This field will allow +you to specify the key that your password will be encrypted using. However, +you may also choose to let the dyn module handle the key generation for you as +well using the :func:`~dyn.encrypt.generate_key` function which generates a, +nearly, random 50 character key that can be easily consumed by the +:class:`~dyn.encrypt.AESCipher` class (the class responsible for performing +the actual encryption and decryption. + +Encrypt Module +^^^^^^^^^^^^^^ +.. autofunction:: dyn.encrypt.generate_key + +.. autoclass:: dyn.encrypt.AESCipher + :members: + :undoc-members: diff --git a/docs/index.rst b/docs/index.rst index cc1d24a..2ee6070 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -37,7 +37,7 @@ Contents: quickstart tm mm - sessions + advanced Indices and tables diff --git a/docs/tm/reports.rst b/docs/tm/reports.rst index ec7bb1d..e482d68 100644 --- a/docs/tm/reports.rst +++ b/docs/tm/reports.rst @@ -14,4 +14,3 @@ List Functions .. autofunction:: dyn.tm.reports.get_rttm_rrset .. autofunction:: dyn.tm.reports.get_qps .. autofunction:: dyn.tm.reports.get_zone_notes - diff --git a/docs/tm/services/gslb/gslb.rst b/docs/tm/services/gslb/gslb.rst index 7e188fe..b5f75b5 100644 --- a/docs/tm/services/gslb/gslb.rst +++ b/docs/tm/services/gslb/gslb.rst @@ -66,3 +66,23 @@ the dyn.tm System and how to edit some of the same fields mentioned above. >>> fqdn = zone + '.' >>> gslb = GSLB(zone, fqdn) +Replacing a GSLB Monitor +^^^^^^^^^^^^^^^^^^^^^^^^ +If you'd like to create a brand new :class:`Monitor` for your GSLB service, rather +than update your existing one, the following example shows how simple it is to +accomplish this task +:: + + >>> from dyn.tm.services.gslb import GSLB, Monitor + >>> zone = 'example.com' + >>> fqdn = zone + '.' + >>> gslb = GSLB(zone, fqdn) + >>> gslb.monitor.protocol + 'HTTP' + >>> expected_text = "This is the text you're looking for." + >>> new_monitor = Monitor('HTTPS', 10, timeout=500, port=5005, + expected=expected_text) + >>> gslb.monitor = new_monitor + >>> gslb.monitor.protocol + 'HTTPS' + diff --git a/dyn/__init__.py b/dyn/__init__.py index 956fc03..548f31c 100644 --- a/dyn/__init__.py +++ b/dyn/__init__.py @@ -5,7 +5,7 @@ Requires Python 2.6 or higher, or the "simplejson" package. """ -version_info = (1, 0, 3) +version_info = (1, 1, 0) __name__ = 'dyn' __doc__ = 'A python wrapper for the DynDNS and DynEmail APIs' __author__ = 'Jonathan Nappi, Cole Tuininga' diff --git a/dyn/compat.py b/dyn/compat.py new file mode 100644 index 0000000..1351474 --- /dev/null +++ b/dyn/compat.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +"""python 2-3 compatability layer. The bulk of this was borrowed from +kennethreitz's requests module +""" +import sys + +# ------- +# Pythons +# ------- + +# Syntax sugar. +_ver = sys.version_info + +#: Python 2.x? +is_py2 = (_ver[0] == 2) + +#: Python 3.x? +is_py3 = (_ver[0] == 3) + +# ----------------- +# Version Specifics +# ----------------- + +if is_py2: + # If we have no JSON-esque module installed, we can't do anything + try: + import json + except ImportError as ex: + try: + import simplejson as json + except ImportError: + raise ex + from httplib import HTTPConnection, HTTPSConnection, HTTPException + from urllib import urlencode, pathname2url + + string_types = (str, unicode) + + def prepare_to_send(args): + return bytes(args) + + def prepare_for_loads(body, encoding): + return body + + def force_unicode(s, encoding='UTF-8'): + try: + s = unicode(s) + except UnicodeDecodeError: + s = str(s).decode(encoding, 'replace') + + return s + +elif is_py3: + from http.client import HTTPConnection, HTTPSConnection, HTTPException + from urllib.parse import urlencode + from urllib.request import pathname2url + import json + string_types = (str,) + + def prepare_to_send(args): + return bytes(args, 'UTF-8') + + def prepare_for_loads(body, encoding): + return body.decode(encoding) + + def force_unicode(s, encoding='UTF-8'): + return str(s) diff --git a/dyn/core.py b/dyn/core.py index 2caf759..55e8634 100644 --- a/dyn/core.py +++ b/dyn/core.py @@ -4,25 +4,15 @@ library, it is not recommened and could possible result in some strange behavior. """ -import sys import time import locale import logging import threading from datetime import datetime -try: - import json -except ImportError: - try: - import simplejson as json - except ImportError: - sys.exit('Could not find json or simplejson libraries.') -if sys.version_info[0] == 2: - from httplib import HTTPConnection, HTTPSConnection, HTTPException -elif sys.version_info[0] == 3: - from http.client import HTTPConnection, HTTPSConnection, HTTPException -# API Libs -from dyn import __version__ + +from . import __version__ +from .compat import (HTTPConnection, HTTPSConnection, HTTPException, json, + is_py2, is_py3, prepare_to_send, force_unicode) def cleared_class_dict(dict_obj): @@ -107,7 +97,7 @@ def close_session(cls): key = getattr(cls, '__metakey__') closed = cls._instances.get(key, {}).pop(cur_thread, None) if len(cls._instances.get(key, {})) == 0: - del cls._instances[key] + cls._instances.pop(key, None) return closed @property @@ -164,11 +154,7 @@ def _handle_response(self, response, uri, method, raw_args, final): if self.poll_incomplete: response, body = self.poll_response(response, body) self._last_response = response - ret_val = None - if sys.version_info[0] == 2: - ret_val = json.loads(body) - elif sys.version_info[0] == 3: - ret_val = json.loads(body.decode('UTF-8')) + ret_val = json.loads(body.decode('UTF-8')) self._meta_update(uri, method, ret_val) # Handle retrying if ZoneProp is blocking the current task @@ -225,15 +211,24 @@ def execute(self, uri, method, args=None, final=False): :param final: boolean flag representing whether or not we have already failed executing once or not """ + if self._conn is None: + self.connect() + uri = self._validate_uri(uri) # Make sure the method is valid self._validate_method(method) - self.logger.debug('uri: {}, method: {}, args: {}'.format(uri, method, - args)) # Prepare arguments to send to API raw_args, args, uri = self._prepare_arguments(args, method, uri) + + # Don't display password when debug logging + cleaned_args = json.loads(args) + if 'password' in cleaned_args: + cleaned_args['password'] = '*****' + + self.logger.debug('uri: {}, method: {}, args: {}'.format(uri, method, + cleaned_args)) # Send the command and deal with results self.send_command(uri, method, args) @@ -314,11 +309,7 @@ def send_command(self, uri, method, args): self._conn.putheader('Content-length', '%d' % len(args)) self._conn.endheaders() - if sys.version_info[0] == 2: - self._conn.send(bytes(args)) - elif sys.version_info[0] == 3: - # noinspection PyArgumentList - self._conn.send(bytes(args, 'UTF-8')) + self._conn.send(prepare_to_send(args)) def wait_for_job_to_complete(self, job_id, timeout=120): """When a response comes back with a status of "incomplete" we need to @@ -343,7 +334,27 @@ def wait_for_job_to_complete(self, job_id, timeout=120): response = self.execute(uri, 'GET', api_args) return response + def __getstate__(cls): + """Because HTTP/HTTPS connections are not serializeable, we need to + strip the connection instance out before we ship the pickled data + """ + d = cls.__dict__.copy() + d.pop('_conn') + return d + + def __setstate__(cls, state): + """Because the HTTP/HTTPS connection was stripped out in __getstate__ we + must manually re-enter it as None and let the sessions execute method + handle rebuilding it later + """ + cls.__dict__ = state + cls.__dict__['_conn'] = None + def __str__(self): """str override""" - return '<{}>'.format(self.name) + return force_unicode('<{}>').format(self.name) __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) diff --git a/dyn/encrypt.py b/dyn/encrypt.py new file mode 100644 index 0000000..125de6b --- /dev/null +++ b/dyn/encrypt.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +"""Encryption module for providing users an option to not store their DynECT DNS +passwords in plain-text, but rather to provide a means of automatic password +encryption. Note: password encryption requires nothing more than the the +installation of the `PyCrypto `_. +module. Users are free to not install PyCrypto, however, your passwords will not +be encrypted when stored in your session instance +""" +import base64 +import random +import hashlib + +__author__ = 'jnappi' +__all__ = ['generate_key', 'AESCipher'] + + +def generate_key(force=False): + """Generate a new, uniquely random, secret key. If we have already created + one, then return the already created key. You may override this behaviour + and force a new key to be generated by specifying *force* as *True* + + :param force: A Boolean flag specifying whether or not to force the + generation of a new key + """ + if generate_key.secret_key is not None and not force: + return generate_key.secret_key + + choices = 'abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)' + key = ''.join([random.SystemRandom().choice(choices) for i in range(50)]) + generate_key.secret_key = key + return generate_key.secret_key +generate_key.secret_key = None + + +try: + from Crypto import Random + from Crypto.Cipher import AES + + class AESCipher(object): + """An AES-256 password hasher""" + def __init__(self, key=None): + """Create a new AES-256 Cipher instance + + :param key: The secret key used to generate the password hashes + """ + self.bs = 32 + if key is None: + key = generate_key() + self.key = hashlib.sha256(key.encode()).digest() + + def encrypt(self, raw): + """Encrypt the provided password and return the encoded password + hash + + :param raw: The raw password string to encode + """ + raw = self._pad(raw) + iv = Random.new().read(AES.block_size) + cipher = AES.new(self.key, AES.MODE_CBC, iv) + return base64.b64encode(iv + cipher.encrypt(raw)) + + def decrypt(self, enc): + """Decrypt an encoded password hash using the secret key provided, + and return the decrypted string + + :param enc: The encoded AES-256 password hash + """ + enc = base64.b64decode(enc) + iv = enc[:AES.block_size] + cipher = AES.new(self.key, AES.MODE_CBC, iv) + return self._unpad(cipher.decrypt(enc[AES.block_size:])).decode('utf-8') + + def _pad(self, s): + return s + (self.bs - len(s) % self.bs) * chr(self.bs - len(s) % self.bs) + + @staticmethod + def _unpad(s): + return s[:-ord(s[len(s) - 1:])] + +except ImportError: + # If we don't have PyCrypto installed, we won't encrypt passwords + class AESCipher(object): + """An AES-256 password hasher""" + def __init__(self, key=None): + """Create a new AES-256 Cipher instance + + :param key: The secret key used to generate the password hashes + """ + self.key = key + + def encrypt(self, raw): + """Encrypt the provided password and return the encoded password + hash + + :param raw: The raw password string to encode + """ + return raw + + def decrypt(self, enc): + """Decrypt an encoded password hash using the secret key provided, + and return the decrypted string + + :param enc: The encoded AES-256 password hash + """ + return enc diff --git a/dyn/mm/session.py b/dyn/mm/session.py index 834257b..65e56af 100644 --- a/dyn/mm/session.py +++ b/dyn/mm/session.py @@ -4,21 +4,10 @@ methods that return various types of DynECT objects which will provide their own respective functionality. """ -import sys import locale -try: - import json -except ImportError: - try: - import simplejson as json - except ImportError: - sys.exit('Could not find json or simplejson libraries.') -if sys.version_info[0] == 2: - from urllib import urlencode, pathname2url -elif sys.version_info[0] == 3: - from urllib.parse import urlencode, pathname2url # API Libs from ..core import SessionEngine +from ..compat import urlencode, pathname2url, json, prepare_for_loads from .errors import * __author__ = 'jnappi' @@ -66,11 +55,7 @@ def _prepare_arguments(self, args, method, uri): def _handle_response(self, response, uri, method, raw_args, final): """Handle the processing of the API's response""" body = response.read() - ret_val = None - if sys.version_info[0] == 2: - ret_val = json.loads(body) - elif sys.version_info[0] == 3: - ret_val = json.loads(body.decode(self._encoding)) + ret_val = json.loads(prepare_for_loads(body, self._encoding)) return self._process_response(ret_val['response'], method, final) def _process_response(self, response, method, final=False): diff --git a/dyn/tm/accounts.py b/dyn/tm/accounts.py index 1e75abf..a434bbb 100644 --- a/dyn/tm/accounts.py +++ b/dyn/tm/accounts.py @@ -3,8 +3,10 @@ REST API """ import logging + from .errors import DynectInvalidArgumentError from .session import DynectSession +from ..compat import force_unicode __author__ = 'jnappi' __all__ = ['get_updateusers', 'get_users', 'get_permissions_groups', @@ -201,17 +203,17 @@ def _post(self, nickname, password): def _get(self, user_name): """Get an existing :class:`UpdateUser` from the DynECT System""" self._user_name = user_name - api_args = {} self.uri = '/UpdateUser/{}/'.format(self._user_name) - response = DynectSession.get_session().execute(self.uri, 'GET', api_args) + response = DynectSession.get_session().execute(self.uri, 'GET') self._build(response['data']) def _build(self, data): for key, val in data.items(): setattr(self, '_' + key, val) - def _update(self, api_args): - response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) + def _update(self, api_args=None): + response = DynectSession.get_session().execute(self.uri, 'PUT', + api_args) self._build(response['data']) @property @@ -246,7 +248,7 @@ def status(self): @status.setter def status(self, value): pass - + @property def password(self): """The current `password` for this :class:`UpdateUser`. An @@ -256,18 +258,17 @@ def password(self): return self._password @password.setter def password(self, new_password): - """Update this :class:`UpdateUser`'s password to be the provided + """Update this :class:`UpdateUser`'s password to be the provided password :param new_password: The new password to use """ - self._password = new_password - api_args = {'password': self._password} + api_args = {'password': new_password} self._update(api_args) def block(self): - """Set the status of this :class:`UpdateUser` to 'blocked'. This will - prevent this :class:`UpdateUser` from logging in until they are + """Set the status of this :class:`UpdateUser` to 'blocked'. This will + prevent this :class:`UpdateUser` from logging in until they are explicitly unblocked. """ api_args = {'block': True} @@ -293,8 +294,16 @@ def delete(self): """Delete this :class:`UpdateUser` from the DynECT System. It is important to note that this operation may not be undone. """ - api_args = {} - DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + DynectSession.get_session().execute(self.uri, 'DELETE') + + def __str__(self): + """Custom str method""" + return force_unicode(': {}').format(self.user_name) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) class User(object): @@ -394,12 +403,14 @@ def _post(self, password, email, first_name, last_name, nickname, def _get(self): """Get an existing :class:`User` object from the DynECT System""" api_args = {} - response = DynectSession.get_session().execute(self.uri, 'GET', api_args) + response = DynectSession.get_session().execute(self.uri, 'GET', + api_args) for key, val in response['data'].items(): setattr(self, '_' + key, val) - def _update(self, api_args): - response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) + def _update(self, api_args=None): + response = DynectSession.get_session().execute(self.uri, 'PUT', + api_args) for key, val in response['data'].items(): setattr(self, '_' + key, val) @@ -427,8 +438,7 @@ def email(self): return self._email @email.setter def email(self, value): - self._email = value - api_args = {'email': self._email} + api_args = {'email': value} self._update(api_args) @property @@ -437,8 +447,7 @@ def first_name(self): return self._first_name @first_name.setter def first_name(self, value): - self._first_name = value - api_args = {'first_name': self._first_name} + api_args = {'first_name': value} self._update(api_args) @property @@ -447,8 +456,7 @@ def last_name(self): return self._last_name @last_name.setter def last_name(self, value): - self._last_name = value - api_args = {'last_name': self._last_name} + api_args = {'last_name': value} self._update(api_args) @property @@ -457,8 +465,7 @@ def nickname(self): return self._nickname @nickname.setter def nickname(self, value): - self._nickname = value - api_args = {'nickname': self._nickname} + api_args = {'nickname': value} self._update(api_args) @property @@ -467,8 +474,7 @@ def organization(self): return self._organization @organization.setter def organization(self, value): - self._organization = value - api_args = {'organization': self._organization} + api_args = {'organization': value} self._update(api_args) @property @@ -481,8 +487,7 @@ def phone(self): return self._phone @phone.setter def phone(self, value): - self._phone = value - api_args = {'phone': self._phone} + api_args = {'phone': value} self._update(api_args) @property @@ -491,8 +496,7 @@ def address(self): return self._address @address.setter def address(self, value): - self._address = value - api_args = {'address': self._address} + api_args = {'address': value} self._update(api_args) @property @@ -501,8 +505,7 @@ def address_2(self): return self._address_2 @address_2.setter def address_2(self, value): - self._address_2 = value - api_args = {'address_2': self._address_2} + api_args = {'address_2': value} self._update(api_args) @property @@ -511,8 +514,7 @@ def city(self): return self._city @city.setter def city(self, value): - self._city = value - api_args = {'city': self._city} + api_args = {'city': value} self._update(api_args) @property @@ -521,8 +523,7 @@ def country(self): return self._country @country.setter def country(self, value): - self._country = value - api_args = {'country': self._country} + api_args = {'country': value} self._update(api_args) @property @@ -531,8 +532,7 @@ def fax(self): return self._fax @fax.setter def fax(self, value): - self._fax = value - api_args = {'fax': self._fax} + api_args = {'fax': value} self._update(api_args) @property @@ -542,8 +542,7 @@ def notify_email(self): return self._notify_email @notify_email.setter def notify_email(self, value): - self._notify_email = value - api_args = {'notify_email': self._notify_email} + api_args = {'notify_email': value} self._update(api_args) @property @@ -554,8 +553,7 @@ def pager_email(self): return self._pager_email @pager_email.setter def pager_email(self, value): - self._pager_email = value - api_args = {'pager_email': self._pager_email} + api_args = {'pager_email': value} self._update(api_args) @property @@ -564,8 +562,7 @@ def post_code(self): return self._post_code @post_code.setter def post_code(self, value): - self._post_code = value - api_args = {'post_code': self._post_code} + api_args = {'post_code': value} self._update(api_args) @property @@ -574,8 +571,7 @@ def group_name(self): return self._group_name @group_name.setter def group_name(self, value): - self._group_name = value - api_args = {'group_name': self._group_name} + api_args = {'group_name': value} self._update(api_args) @property @@ -584,8 +580,7 @@ def permission(self): return self._permission @permission.setter def permission(self, value): - self._permission = value - api_args = {'permission': self._permission} + api_args = {'permission': value} self._update(api_args) @property @@ -594,8 +589,7 @@ def zone(self): return self._zone @zone.setter def zone(self, value): - self._zone = value - api_args = {'zone': self._zone} + api_args = {'zone': value} self._update(api_args) @property @@ -605,8 +599,7 @@ def forbid(self): @forbid.setter def forbid(self, value): """Apply a new list of forbidden permissions for the :class:`User`""" - self._forbid = value - api_args = {'forbid': self._forbid} + api_args = {'forbid': value} self._update(api_args) @property @@ -615,8 +608,7 @@ def website(self): return self._website @website.setter def website(self, value): - self._website = value - api_args = {'website': self._website} + api_args = {'website': value} self._update(api_args) def block(self): @@ -627,7 +619,7 @@ def block(self): self._status = response['data']['status'] def unblock(self): - """Restores this :class:`User` to an active status and re-enables their + """Restores this :class:`User` to an active status and re-enables their log-in """ api_args = {'unblock': 'True'} @@ -640,10 +632,9 @@ def add_permission(self, permission): :param permission: the permission to add """ - api_args = {} self.permissions.append(permission) uri = '/UserPermissionEntry/{}/{}/'.format(self._user_name, permission) - DynectSession.get_session().execute(uri, 'POST', api_args) + DynectSession.get_session().execute(uri, 'POST') def replace_permissions(self, permissions=None): """Replaces the list of permissions for this :class:`User` @@ -665,27 +656,25 @@ def delete_permission(self, permission): :param permission: the permission to remove """ - api_args = {} if permission in self.permissions: self.permissions.remove(permission) uri = '/UserPermissionEntry/{}/{}/'.format(self._user_name, permission) - DynectSession.get_session().execute(uri, 'DELETE', api_args) + DynectSession.get_session().execute(uri, 'DELETE') def add_permissions_group(self, group): """Assigns the permissions group to this :class:`User` :param group: the permissions group to add to this :class:`User` """ - api_args = {} self.permission_groups.append(group) uri = '/UserGroupEntry/{}/{}/'.format(self._user_name, group) - DynectSession.get_session().execute(uri, 'POST', api_args) + DynectSession.get_session().execute(uri, 'POST') def replace_permissions_group(self, groups=None): """Replaces the list of permissions for this :class:`User` :param groups: A list of permissions groups. Pass an empty list or omit - the argument to clear the list of permissions groups of the + the argument to clear the list of permissions groups of the :class:`User` """ api_args = {} @@ -702,11 +691,10 @@ def delete_permissions_group(self, group): :param group: the permissions group to remove from this :class:`User` """ - api_args = {} if group in self.permissions: self.permission_groups.remove(group) uri = '/UserGroupEntry/{}/{}/'.format(self._user_name, group) - DynectSession.get_session().execute(uri, 'DELETE', api_args) + DynectSession.get_session().execute(uri, 'DELETE') def add_forbid_rule(self, permission, zone=None): """Adds the forbid rule to the :class:`User`'s permission group @@ -721,11 +709,11 @@ def add_forbid_rule(self, permission, zone=None): DynectSession.get_session().execute(uri, 'POST', api_args) def replace_forbid_rules(self, forbid=None): - """Replaces the list of forbidden permissions in the :class:`User`'s + """Replaces the list of forbidden permissions in the :class:`User`'s permissions group with a new list. - :param forbid: A list of rules to replace the forbidden rules on the - :class:`User`'s permission group. If empty or not passed in, the + :param forbid: A list of rules to replace the forbidden rules on the + :class:`User`'s permission group. If empty or not passed in, the :class:`User`'s forbid list will be cleared """ api_args = {} @@ -749,16 +737,18 @@ def delete_forbid_rule(self, permission, zone=None): def delete(self): """Delete this :class:`User` from the system""" - api_args = {} uri = '/User/{}/'.format(self._user_name) - DynectSession.get_session().execute(uri, 'DELETE', api_args) + DynectSession.get_session().execute(uri, 'DELETE') def __str__(self): """Custom str method""" - return 'User: <{}>'.format(self.user_name) - + return force_unicode(': {}').format(self.user_name) __repr__ = __unicode__ = __str__ + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) + class PermissionsGroup(object): """A DynECT System Permissions Group object""" @@ -824,8 +814,7 @@ def _post(self, description, group_type=None, all_users=None, def _get(self): """Get an existing :class:`PermissionsGroup` from the DynECT System""" - api_args = {} - response = DynectSession.get_session().execute(self.uri, 'GET', api_args) + response = DynectSession.get_session().execute(self.uri, 'GET') for key, val in response['data'].items(): if key == 'type': setattr(self, '_group_type', val) @@ -836,8 +825,9 @@ def _get(self): else: setattr(self, '_' + key, val) - def _update(self, api_args): - response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) + def _update(self, api_args=None): + response = DynectSession.get_session().execute(self.uri, 'PUT', + api_args) for key, val in response['data'].items(): if key == 'type': setattr(self, '_group_type', val) @@ -942,9 +932,8 @@ def zone(self, value): def delete(self): """Delete this permission group""" - api_args = {} uri = '/PermissionGroup/{}/'.format(self._group_name) - DynectSession.get_session().execute(uri, 'DELETE', api_args) + DynectSession.get_session().execute(uri, 'DELETE') def add_permission(self, permission): """Adds individual permissions to the user @@ -953,7 +942,7 @@ def add_permission(self, permission): """ uri = '/PermissionGroupPermissionEntry/{}/{}/'.format(self._group_name, permission) - DynectSession.get_session().execute(uri, 'POST', {}) + DynectSession.get_session().execute(uri, 'POST') self._permission.append(permission) def replace_permissions(self, permission=None): @@ -979,7 +968,7 @@ def remove_permission(self, permission): """ uri = '/PermissionGroupPermissionEntry/{}/{}/'.format(self._group_name, permission) - DynectSession.get_session().execute(uri, 'DELETE', {}) + DynectSession.get_session().execute(uri, 'DELETE') self._permission.remove(permission) def add_zone(self, zone, recurse='Y'): @@ -1001,10 +990,9 @@ def add_subgroup(self, name): :param name: The name of the :class:`PermissionsGroup` to be added to this :class:`PermissionsGroup`'s subgroups """ - api_args = {} uri = '/PermissionGroupSubgroupEntry/{}/{}/'.format(self._group_name, name) - DynectSession.get_session().execute(uri, 'POST', api_args) + DynectSession.get_session().execute(uri, 'POST') self._subgroup.append(name) def update_subgroup(self, subgroups): @@ -1023,12 +1011,20 @@ def delete_subgroup(self, name): :param name: The name of the :class:`PermissionsGroup` to be remoevd from this :class:`PermissionsGroup`'s subgroups """ - api_args = {} uri = '/PermissionGroupSubgroupEntry/{}/{}/'.format(self._group_name, name) - DynectSession.get_session().execute(uri, 'DELETE', api_args) + DynectSession.get_session().execute(uri, 'DELETE') self._subgroup.remove(name) + def __str__(self): + """Custom str method""" + return force_unicode(': {}').format(self.group_name) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) + class UserZone(object): """A DynECT system UserZoneEntry""" @@ -1089,6 +1085,15 @@ def delete(self): uri = '/UserZoneEntry/{}/{}/'.format(self._user_name, self._zone_name) DynectSession.get_session().execute(uri, 'DELETE', api_args) + def __str__(self): + """Custom str method""" + return force_unicode(': {}').format(self.user_name) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) + class Notifier(object): """DynECT System Notifier""" @@ -1132,16 +1137,16 @@ def _get(self, notifier_id): """Get an existing :class:`Notifier` object from the DynECT System""" self._notifier_id = notifier_id self.uri = '/Notifier/{}/'.format(self._notifier_id) - api_args = {} - response = DynectSession.get_session().execute(self.uri, 'GET', api_args) + response = DynectSession.get_session().execute(self.uri, 'GET') self._build(response['data']) def _build(self, data): for key, val in data.items(): setattr(self, '_' + key, val) - def _update(self, api_args): - response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) + def _update(self, api_args=None): + response = DynectSession.get_session().execute(self.uri, 'PUT', + api_args) self._build(response['data']) @property @@ -1184,8 +1189,16 @@ def services(self, value): def delete(self): """Delete this :class:`Notifier` from the Dynect System""" - api_args = {} - DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + DynectSession.get_session().execute(self.uri, 'DELETE') + + def __str__(self): + """Custom str method""" + return force_unicode(': {}').format(self.label) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) class Contact(object): @@ -1265,8 +1278,7 @@ def _post(self, email, first_name, last_name, organization, address=None, def _get(self): """Get an existing :class:`Contact` from the DynECT System""" - api_args = {} - response = DynectSession.get_session().execute(self.uri, 'GET', api_args) + response = DynectSession.get_session().execute(self.uri, 'GET') for key, val in response['data'].items(): setattr(self, '_' + key, val) @@ -1274,11 +1286,12 @@ def _build(self, data): for key, val in data.items(): setattr(self, '_' + key, val) - def _update(self, api_args): + def _update(self, api_args=None): """Private update method which handles building this :class:`Contact` object from the API JSON respnose """ - response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) + response = DynectSession.get_session().execute(self.uri, 'PUT', + api_args) self._build(response['data']) @property @@ -1450,5 +1463,13 @@ def website(self, value): def delete(self): """Delete this :class:`Contact` from the Dynect System""" - api_args = {} - DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + DynectSession.get_session().execute(self.uri, 'DELETE') + + def __str__(self): + """Custom str method""" + return force_unicode(': {}').format(self.nickname) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) diff --git a/dyn/tm/records.py b/dyn/tm/records.py index ff65296..1299e13 100644 --- a/dyn/tm/records.py +++ b/dyn/tm/records.py @@ -5,8 +5,10 @@ could also be created independently if passed valid zone, fqdn data """ import logging + from .errors import DynectInvalidArgumentError from .session import DynectSession +from ..compat import force_unicode __author__ = 'jnappi' __all__ = ['DNSRecord', 'ARecord', 'AAAARecord', 'CERTRecord', 'CNAMERecord', @@ -151,6 +153,15 @@ def ttl(self, value): self.api_args['ttl'] = self._ttl self._update_record(self.api_args) + def __str__(self): + """str override""" + return force_unicode('<{}>: {}').format(self._record_type, self._fqdn) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) + class ARecord(DNSRecord): """The IPv4 Address (A) Record forward maps a host name to an IPv4 address. diff --git a/dyn/tm/reports.py b/dyn/tm/reports.py index c1aa903..6eca34e 100644 --- a/dyn/tm/reports.py +++ b/dyn/tm/reports.py @@ -2,6 +2,9 @@ """This module contains interfaces for all Report generation features of the REST API """ +from datetime import datetime + +from .utils import unix_date from .session import DynectSession __author__ = 'elarochelle' @@ -31,38 +34,41 @@ def get_dnssec_timeline(zone_name, start_ts=None, end_ts=None): to perform. :param zone_name: The name of the zone with DNSSEC service - :param start_ts: UNIX timestamp identifying point in time for the - report - :param end_ts: UNIX timestamp indicating the end of the data range for - the report + :param start_ts: datetime.datetime instance identifying point in time for + the start of the timeline report + :param end_ts: datetime.datetime instance identifying point in time + for the end of the timeline report. Defaults to datetime.datetime.now() :return: A :class:`dict` containing log report data """ api_args = {'zone': zone_name} if start_ts is not None: - api_args['start_ts'] = start_ts + api_args['start_ts'] = unix_date(start_ts) if end_ts is not None: - api_args['end_ts'] = end_ts + api_args['end_ts'] = unix_date(end_ts) + elif end_ts is None and start_ts is not None: + api_args['end_ts'] = unix_date(datetime.now()) response = DynectSession.get_session().execute('/DNSSECTimelineReport/', 'POST', api_args) return response['data'] -def get_rttm_log(zone_name, fqdn, start_ts, end_ts): +def get_rttm_log(zone_name, fqdn, start_ts, end_ts=None): """Generates a report with information about changes to an existing RTTM service. :param zone_name: The name of the zone :param fqdn: The FQDN where RTTM is attached - :param start_ts: UNIX timestamp identifying point in time for the log - report - :param end_ts: UNIX timestamp indicating the end of the data range for - the report + :param start_ts: datetime.datetime instance identifying point in time for + the log report to start + :param end_ts: datetime.datetime instance indicating the end of the data + range for the report. Defaults to datetime.datetime.now() :return: A :class:`dict` containing log report data """ + end_ts = end_ts or datetime.now() api_args = {'zone': zone_name, 'fqdn': fqdn, - 'start_ts': start_ts, - 'end_ts': end_ts} + 'start_ts': unix_date(start_ts), + 'end_ts': unix_date(end_ts)} response = DynectSession.get_session().execute('/RTTMLogReport/', 'POST', api_args) return response['data'] @@ -74,25 +80,26 @@ def get_rttm_rrset(zone_name, fqdn, ts): :param zone_name: The name of the zone :param fqdn: The FQDN where RTTM is attached - :param ts: UNIX timestamp identifying point in time for the report + :param ts: datetime.datetime instance identifying point in time for the + report :return: A :class:`dict` containing rrset report data """ api_args = {'zone': zone_name, 'fqdn': fqdn, - 'ts': ts} + 'ts': unix_date(ts)} response = DynectSession.get_session().execute('/RTTMRRSetReport/', 'POST', api_args) return response['data'] -def get_qps(start_ts, end_ts, breakdown=None, hosts=None, rrecs=None, - zones = None): +def get_qps(start_ts, end_ts=None, breakdown=None, hosts=None, rrecs=None, + zones=None): """Generates a report with information about Queries Per Second (QPS). - :param start_ts: UNIX timestamp identifying point in time for the QPS - report - :param end_ts: UNIX timestamp indicating the end of the data range for - the report + :param start_ts: datetime.datetime instance identifying point in time for + the QPS report + :param end_ts: datetime.datetime instance indicating the end of the data + range for the report. Defaults to datetime.datetime.now() :param breakdown: By default, most data is aggregated together. Valid values ('hosts', 'rrecs', 'zones'). :param hosts: List of hosts to include in the report. @@ -100,8 +107,9 @@ def get_qps(start_ts, end_ts, breakdown=None, hosts=None, rrecs=None, :param zones: List of zones to include in report. :return: A :class:`str` with CSV data """ - api_args = {'start_ts': start_ts, - 'end_ts': end_ts} + end_ts = end_ts or datetime.now() + api_args = {'start_ts': unix_date(start_ts), + 'end_ts': unix_date(end_ts)} if breakdown is not None: api_args['breakdown'] = breakdown if hosts is not None: diff --git a/dyn/tm/services/active_failover.py b/dyn/tm/services/active_failover.py index 1f9e900..7f2a5e2 100644 --- a/dyn/tm/services/active_failover.py +++ b/dyn/tm/services/active_failover.py @@ -4,6 +4,7 @@ from ..utils import Active from ..errors import DynectInvalidArgumentError from ..session import DynectSession +from ...compat import force_unicode __author__ = 'jnappi' __all__ = ['HealthMonitor', 'ActiveFailover'] @@ -195,6 +196,15 @@ def expected(self, value): uri = '/Failover/{}/{}/'.format(self.zone, self.fqdn) DynectSession.get_session().execute(uri, 'PUT', api_args) + def __str__(self): + """str override""" + return force_unicode(': {}').format(self._protocol) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) + class ActiveFailover(object): """With Active Failover, we monitor your Primary IP. If a failover event @@ -509,3 +519,12 @@ def delete(self): """Delete this :class:`ActiveFailover` service from the Dynect System""" api_args = {} DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + + def __str__(self): + """str override""" + return force_unicode(': {}').format(self._fqdn) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) diff --git a/dyn/tm/services/ddns.py b/dyn/tm/services/ddns.py index 28a9d15..1e7389a 100644 --- a/dyn/tm/services/ddns.py +++ b/dyn/tm/services/ddns.py @@ -6,6 +6,7 @@ from ..utils import Active from ..session import DynectSession from ..accounts import User +from ...compat import force_unicode __author__ = 'jnappi' __all__ = ['DynamicDNS'] @@ -187,3 +188,12 @@ def delete(self): """Delete this Dynamic DNS service from the DynECT System""" api_args = {} DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + + def __str__(self): + """str override""" + return force_unicode(': {}').format(self._fqdn) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) diff --git a/dyn/tm/services/dnssec.py b/dyn/tm/services/dnssec.py index 71cb4e6..970076f 100644 --- a/dyn/tm/services/dnssec.py +++ b/dyn/tm/services/dnssec.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- import logging +from datetime import datetime -from ..utils import APIList, Active +from ..utils import APIList, Active, unix_date from ..errors import DynectInvalidArgumentError from ..session import DynectSession +from ...compat import force_unicode __author__ = 'jnappi' __all__ = ['get_all_dnssec', 'DNSSECKey', 'DNSSEC'] @@ -78,6 +80,15 @@ def _update(self, data): else: setattr(self, key, val) + def __str__(self): + """str override""" + return force_unicode(': {}').format(self.algorithm) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) + class DNSSEC(object): """A DynECT System DNSSEC Service""" @@ -255,12 +266,20 @@ def deactivate(self): def timeline_report(self, start_ts=None, end_ts=None): """Generates a report of events this :class:`DNSSEC` service has performed and has scheduled to perform + + :param start_ts: datetime.datetime instance identifying point in time + for the start of the timeline report + :param end_ts: datetime.datetime instance identifying point in time + for the end of the timeline report. Defaults to + datetime.datetime.now() """ api_args = {'zone': self._zone} if start_ts is not None: - api_args['start_ts'] = start_ts + api_args['start_ts'] = unix_date(start_ts) if end_ts is not None: - api_args['end_ts'] = end_ts + api_args['end_ts'] = unix_date(end_ts) + elif end_ts is None and start_ts is not None: + api_args['end_ts'] = unix_date(datetime.now()) uri = '/DNSSECTimelineReport/' response = DynectSession.get_session().execute(uri, 'POST', api_args) return response['data'] @@ -269,3 +288,12 @@ def delete(self): """Delete this :class:`DNSSEC` Service from the DynECT System""" api_args = {} DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + + def __str__(self): + """str override""" + return force_unicode(': {}').format(self._zone) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) diff --git a/dyn/tm/services/dsf.py b/dyn/tm/services/dsf.py index 9b9018c..63b01a2 100644 --- a/dyn/tm/services/dsf.py +++ b/dyn/tm/services/dsf.py @@ -7,6 +7,7 @@ from ..errors import DynectInvalidArgumentError from ..records import * from ..session import DynectSession +from ...compat import force_unicode __author__ = 'jnappi' __all__ = ['get_all_dsf_services', 'get_all_dsf_monitors', 'DSFARecord', @@ -2277,3 +2278,12 @@ def delete(self): """Delete this :class:`TrafficDirector` from the DynECT System""" api_args = {} DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + + def __str__(self): + """str override""" + return force_unicode(': {}').format(self._service_id) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) diff --git a/dyn/tm/services/gslb.py b/dyn/tm/services/gslb.py index 65f795e..5061e55 100644 --- a/dyn/tm/services/gslb.py +++ b/dyn/tm/services/gslb.py @@ -1,8 +1,10 @@ # -*- coding: utf-8 -*- import logging + from ..utils import APIList from ..errors import DynectInvalidArgumentError from ..session import DynectSession +from ...compat import force_unicode __author__ = 'jnappi' __all__ = ['Monitor', 'GSLBRegionPoolEntry', 'GSLBRegion', 'GSLB'] @@ -194,6 +196,15 @@ def expected(self, value): uri = '/Failover/{}/{}/'.format(self.zone, self.fqdn) DynectSession.get_session().execute(uri, 'PUT', api_args) + def __str__(self): + """str override""" + return force_unicode(': {}').format(self._protocol) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) + class GSLBRegionPoolEntry(object): """:class:`GSLBRegionPoolEntry`""" @@ -371,6 +382,16 @@ def delete(self): api_args = {} DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + def __str__(self): + """str override""" + s = force_unicode(': {}') + return s.format(self._region_code) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) + class GSLBRegion(object): """docstring for GSLBRegion""" @@ -580,6 +601,15 @@ def delete(self): api_args = {} DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + def __str__(self): + """str override""" + return force_unicode(': {}').format(self._region_code) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) + class GSLB(object): """A Global Server Load Balancing (GSLB) service""" @@ -903,12 +933,11 @@ def monitor(self): def monitor(self, value): # We're only going accept new monitors of type Monitor if isinstance(value, Monitor): - self._monitor = value - api_args = {'monitor': - self._monitor.to_json()} + api_args = {'monitor': value.to_json()} response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) self._build(response['data'], region=False) + self._monitor = value @property def contact_nickname(self): @@ -928,3 +957,12 @@ def delete(self): """Delete this :class:`GSLB` service from the DynECT System""" api_args = {} DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + + def __str__(self): + """str override""" + return force_unicode(': {}').format(self._fqdn) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) diff --git a/dyn/tm/services/reversedns.py b/dyn/tm/services/reversedns.py index ccb73d9..66fad6c 100644 --- a/dyn/tm/services/reversedns.py +++ b/dyn/tm/services/reversedns.py @@ -3,13 +3,14 @@ from ..utils import Active from ..session import DynectSession +from ...compat import force_unicode __author__ = 'jnappi' __all__ = ['ReverseDNS'] class ReverseDNS(object): - """docstring for ReverseDNS""" + """A DynECT ReverseDNS service""" def __init__(self, zone, fqdn, *args, **kwargs): """Create an new :class:`ReverseDNS` object instance @@ -202,3 +203,12 @@ def delete(self): """Delete this ReverseDNS service from the DynECT System""" api_args = {} DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + + def __str__(self): + """str override""" + return force_unicode(': {}').format(self._fqdn) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) diff --git a/dyn/tm/services/rttm.py b/dyn/tm/services/rttm.py index 71687fd..59f03ad 100644 --- a/dyn/tm/services/rttm.py +++ b/dyn/tm/services/rttm.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- import logging +from datetime import datetime -from ..utils import APIList, Active +from ..utils import APIList, Active, unix_date from ..errors import DynectInvalidArgumentError from ..session import DynectSession +from ...compat import force_unicode __author__ = 'jnappi' __all__ = ['Monitor', 'PerformanceMonitor', 'RegionPoolEntry', 'RTTMRegion', @@ -205,6 +207,15 @@ def expected(self, value): api_args = {'monitor': {'expected': self._expected}} self._update(api_args) + def __str__(self): + """str override""" + return force_unicode(': {}').format(self._protocol) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) + class PerformanceMonitor(Monitor): """A :class:`PerformanceMonitor` for RTTM Service.""" @@ -227,6 +238,15 @@ def _update(self, api_args): response = DynectSession.get_session().execute(uri, 'PUT', api_args) self._build(response['data']['performance_monitor']) + def __str__(self): + """str override""" + return force_unicode(': {}').format(self._protocol) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) + class RegionPoolEntry(object): """Creates a new RTTM service region pool entry in the zone/node @@ -353,6 +373,15 @@ def delete(self): self._address) DynectSession.get_session().execute(uri, 'DELETE', {}) + def __str__(self): + """str override""" + return force_unicode(': {}').format(self._address) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) + class RTTMRegion(object): """docstring for RTTMRegion""" @@ -414,7 +443,6 @@ def __init__(self, zone, fqdn, region_code, pool, autopopulate=None, def _post(self): """Create a new :class:`RTTMRegion` on the DynECT System""" - uri = '/RTTMRegion/{}/{}/'.format(self._zone, self._fqdn) api_args = {'region_code': self._region_code, 'pool': self._pool.to_json()} @@ -596,8 +624,16 @@ def delete(self): """Delete an existing :class:`RTTMRegion` object from the DynECT System """ - api_args = {} - DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + DynectSession.get_session().execute(self.uri, 'DELETE') + + def __str__(self): + """str override""" + return force_unicode(': {}').format(self._region_code) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) class RTTM(object): @@ -781,19 +817,21 @@ def get_rrset_report(self, ts): 'POST', api_args) return response['data'] - def get_log_report(self, start_ts, end_ts): + def get_log_report(self, start_ts, end_ts=None): """Generates a report with information about changes to an existing RTTM service - :param start_ts: UNIX timestamp identifying point in time for the log - report - :param end_ts: Name of zone where records will be retrieved + :param start_ts: datetime.datetime instance identifying point in time + for the start of the log report + :param end_ts: datetime.datetime instance identifying point in time + for the end of the log report. Defaults to datetime.datetime.now() :return: dictionary containing log report data """ + end_ts = end_ts or datetime.now() api_args = {'zone': self._zone, 'fqdn': self._fqdn, - 'start_ts': start_ts, - 'end_ts': end_ts} + 'start_ts': unix_date(start_ts), + 'end_ts': unix_date(end_ts)} response = DynectSession.get_session().execute('/RTTMLogReport/', 'POST', api_args) return response['data'] @@ -983,3 +1021,12 @@ def delete(self): """Delete this RTTM Service""" api_args = {} DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + + def __str__(self): + """str override""" + return force_unicode(': {}').format(self._fqdn) + __repr__ = __unicode__ = __str__ + + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) diff --git a/dyn/tm/session.py b/dyn/tm/session.py index 07eabad..9d7a58d 100644 --- a/dyn/tm/session.py +++ b/dyn/tm/session.py @@ -7,6 +7,8 @@ # API Libs from ..core import SessionEngine from .errors import * +from ..compat import force_unicode +from ..encrypt import AESCipher class DynectSession(SessionEngine): @@ -16,7 +18,8 @@ class DynectSession(SessionEngine): uri_root = '/REST' def __init__(self, customer, username, password, host='api.dynect.net', - port=443, ssl=True, api_version='current', auto_auth=True): + port=443, ssl=True, api_version='current', auto_auth=True, + key=None): """Initialize a Dynect Rest Session object and store the provided credentials @@ -28,13 +31,15 @@ def __init__(self, customer, username, password, host='api.dynect.net', :param username: DynECT Customer's username :param password: User's password :param auto_auth: declare whether or not to automatically log in - :return: DynectSession object + :param key: A valid AES-256 password encryption key to be used when + encrypting your password """ super(DynectSession, self).__init__(host, port, ssl) + self.__cipher = AESCipher(key) self.extra_headers = {'API-Version': api_version} self.customer = customer self.username = username - self.password = password + self.password = self.__cipher.encrypt(password) self.connect() if auto_auth: self.authenticate() @@ -47,7 +52,7 @@ def _handle_error(self, uri, method, raw_args): self._conn.close() self._conn.connect() # Need to get a new Session token - self.execute('/REST/Session/', 'POST', self.auth_data) + self.execute('/REST/Session/', 'POST', self.__auth_data) # Then try the current call again and Specify final as true so # if we fail again we can raise the actual error return self.execute(uri, method, raw_args, final=True) @@ -90,11 +95,10 @@ def update_password(self, new_password): :param new_password: The new password to use """ - self.password = new_password uri = '/PASSWORD/' - api_args = {'password': self.password} + api_args = {'password': new_password} self.execute(uri, 'PUT', api_args) - self.password = new_password + self.password = self.__cipher.encrypt(new_password) def user_permissions_report(self, user_name=None): """Returns information regarding the requested user's permission access @@ -128,7 +132,7 @@ def authenticate(self): credentials """ api_args = {'customer_name': self.customer, 'user_name': self.username, - 'password': self.password} + 'password': self.__cipher.decrypt(self.password)} try: response = self.execute('/Session/', 'POST', api_args) except IOError: @@ -145,12 +149,13 @@ def log_out(self): self.close_session() @property - def auth_data(self): + def __auth_data(self): """A dict of the authdata required to authenticate as this user""" - return {'customer': self.customer, 'username': self.username, - 'password': self.password} + return {'customer_name': self.customer, 'user_name': self.username, + 'password': self.__cipher.decrypt(self.password)} def __str__(self): """str override""" header = super(DynectSession, self).__str__() - return header + ': {}, {}'.format(self.customer, self.username) + return header + force_unicode(': {}, {}').format(self.customer, + self.username) diff --git a/dyn/tm/utils.py b/dyn/tm/utils.py index 559337a..706101d 100644 --- a/dyn/tm/utils.py +++ b/dyn/tm/utils.py @@ -1,7 +1,16 @@ +# -*- coding: utf-8 -*- """This module contains utilities to be used throughout the dyn.tm module""" -import sys +import calendar + +from ..compat import string_types, force_unicode __author__ = 'jnappi' +__all__ = ['unix_date', 'APIList', 'Active'] + + +def unix_date(date): + """Return a python datetime.datetime object as a UNIX timestamp""" + return calendar.timegm(date.timetuple()) class APIList(list): @@ -98,12 +107,7 @@ def __init__(self, inp): :param inp: If a string, must be one of 'Y' or 'N'. Otherwise a bool. """ self.value = None - types = tuple() - if sys.version_info[0] == 2: - types = (str, unicode) - elif sys.version_info[0] == 3: - types = str - if isinstance(inp, (str, types)): + if isinstance(inp, string_types): self.value = inp.upper() == 'Y' if isinstance(inp, bool): self.value = inp @@ -124,8 +128,10 @@ def __str__(self): 'N' depending on the value of ``self.value`` """ if self.value: - return 'Y' - return 'N' + return force_unicode('Y') + return force_unicode('N') + __repr__ = __unicode__ = __str__ - # __repr__ and __str__ return the same data - __repr__ = __str__ + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) diff --git a/dyn/tm/zones.py b/dyn/tm/zones.py index dbd510b..fdf8266 100644 --- a/dyn/tm/zones.py +++ b/dyn/tm/zones.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- """This module contains all Zone related API objects.""" -import logging import os +import logging from time import sleep +from datetime import datetime +from .utils import unix_date +from ..compat import force_unicode from .errors import * from .records import * from .session import DynectSession @@ -100,7 +103,8 @@ def _post(self, contact=None, ttl=60, serial_style='increment', 'rname': self._contact, 'ttl': self._ttl, 'serial_style': self._serial_style} - response = DynectSession.get_session().execute(self.uri, 'POST', api_args) + response = DynectSession.get_session().execute(self.uri, 'POST', + api_args) self._build(response['data']) def _post_with_file(self, file_name): @@ -113,7 +117,7 @@ def _post_with_file(self, file_name): file_size = os.path.getsize(full_path) if file_size > 1048576: raise DynectInvalidArgumentError('Zone File Size', file_size, - 'Under 1MB') + 'Under 1MB') else: uri = '/ZoneFile/{}/'.format(self.name) f = open(full_path, 'r') @@ -174,7 +178,8 @@ def __poll_for_get(self, n_loops=10, xfer=False, xfer_master_ip=None): def _get(self): """Get an existing :class:`Zone` object from the DynECT System""" api_args = {} - response = DynectSession.get_session().execute(self.uri, 'GET', api_args) + response = DynectSession.get_session().execute(self.uri, 'GET', + api_args) self._build(response['data']) def _build(self, data): @@ -265,7 +270,8 @@ def freeze(self): the zone until it is thawed. """ api_args = {'freeze': True} - response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) + response = DynectSession.get_session().execute(self.uri, 'PUT', + api_args) self._build(response['data']) if response['status'] == 'success': self._status = 'frozen' @@ -275,7 +281,8 @@ def thaw(self): changes to again be made to the zone. """ api_args = {'thaw': True} - response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) + response = DynectSession.get_session().execute(self.uri, 'PUT', + api_args) self._build(response['data']) if response['status'] == 'success': self._status = 'active' @@ -286,7 +293,8 @@ def publish(self): to the nameservers. """ api_args = {'publish': True} - response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) + response = DynectSession.get_session().execute(self.uri, 'PUT', + api_args) self._build(response['data']) def get_notes(self, offset=None, limit=None): @@ -319,6 +327,7 @@ def add_record(self, name, record_type='A', *args, **kwargs): :param kwargs: Keyword arguments to pass to the Record constructor """ fqdn = name + '.' + self.name + '.' + # noinspection PyCallingNonCallable rec = RECS[record_type](self.name, fqdn, *args, **kwargs) if record_type in self.records: self.records[record_type].append(rec) @@ -349,7 +358,13 @@ def add_service(self, name=None, service_type=None, *args, **kwargs): fqdn = self.name + '.' if name: fqdn = name + '.' + fqdn - service = constructors[service_type](self.name, fqdn, *args, **kwargs) + if service_type == 'DNSSEC': + # noinspection PyCallingNonCallable + service = constructors[service_type](self.name, *args, **kwargs) + else: + # noinspection PyCallingNonCallable + service = constructors[service_type](self.name, fqdn, *args, + **kwargs) if service_type in self.services: self.services[service_type].append(service) else: @@ -397,7 +412,8 @@ def get_all_records(self): for r_key, r_val in record['rdata'].items(): record[r_key] = r_val record['create'] = False - list_records.append(constructor(self._name, self.fqdn, **record)) + list_records.append(constructor(self._name, self.fqdn, + **record)) records[key] = list_records return records @@ -447,7 +463,8 @@ def get_any_records(self): uri = '/ANYRecord/{}/{}/'.format(self._name, self.fqdn) response = DynectSession.get_session().execute(uri, 'GET', api_args) # Strip out empty record_type lists - record_lists = {label: rec_list for label, rec_list in response['data'].items() if rec_list != []} + record_lists = {label: rec_list for label, rec_list in + response['data'].items() if rec_list != []} records = {} for key, record_list in record_lists.items(): search = key.split('_')[0].upper() @@ -460,7 +477,8 @@ def get_any_records(self): for r_key, r_val in record['rdata'].items(): record[r_key] = r_val record['create'] = False - list_records.append(constructor(self._name, self.fqdn, **record)) + list_records.append(constructor(self._name, self.fqdn, + **record)) records[key] = list_records return records @@ -507,10 +525,10 @@ def get_all_gslb(self): api_args = {'detail': 'Y'} response = DynectSession.get_session().execute(uri, 'GET', api_args) gslbs = [] - for gslb in response['data']: - del gslb['zone'] - del gslb['fqdn'] - gslbs.append(GSLB(self._name, self._fqdn, api=False, **gslb)) + for gslb_svc in response['data']: + del gslb_svc['zone'] + del gslb_svc['fqdn'] + gslbs.append(GSLB(self._name, self._fqdn, api=False, **gslb_svc)) return gslbs def get_all_rdns(self): @@ -539,12 +557,46 @@ def get_all_rttm(self): api_args = {'detail': 'Y'} response = DynectSession.get_session().execute(uri, 'GET', api_args) rttms = [] - for rttm in response['data']: - del rttm['zone'] - del rttm['fqdn'] - rttms.append(RTTM(self._name, self._fqdn, api=False, **rttm)) + for rttm_svc in response['data']: + del rttm_svc['zone'] + del rttm_svc['fqdn'] + rttms.append(RTTM(self._name, self._fqdn, api=False, **rttm_svc)) return rttms + def get_qps(self, start_ts, end_ts=None, breakdown=None, hosts=None, + rrecs=None): + """Generates a report with information about Queries Per Second (QPS) + for this zone + + :param start_ts: datetime.datetime instance identifying point in time + for the QPS report + :param end_ts: datetime.datetime instance indicating the end of the data + range for the report. Defaults to datetime.datetime.now() + :param breakdown: By default, most data is aggregated together. + Valid values ('hosts', 'rrecs', 'zones'). + :param hosts: List of hosts to include in the report. + :param rrecs: List of record types to include in report. + :return: A :class:`str` with CSV data + """ + end_ts = end_ts or datetime.now() + api_args = {'start_ts': unix_date(start_ts), + 'end_ts': unix_date(end_ts), + 'zones': [self.name]} + if breakdown is not None: + api_args['breakdown'] = breakdown + if hosts is not None: + api_args['hosts'] = hosts + if rrecs is not None: + api_args['rrecs'] = rrecs + response = DynectSession.get_session().execute('/QPSReport/', + 'POST', api_args) + return response['data'] + + def delete(self): + """Delete this :class:`Zone` and perform nessecary cleanups""" + api_args = {} + DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + def __eq__(self, other): """Equivalence operations for easily pulling a :class:`Zone` out of a list of :class:`Zone` objects @@ -557,28 +609,20 @@ def __eq__(self, other): def __ne__(self, other): """Non-Equivalence operator""" - if isinstance(other, str): - return other != self._name - elif isinstance(other, Zone): - return other.name != self._name - return False + return not self.__eq__(other) def __str__(self): """str override""" - return ': {}'.format(self._name) + return force_unicode(': {}').format(self._name) + __repr__ = __unicode__ = __str__ - def __repr__(self): - """repr override""" - return ': {}'.format(self._name) - - def delete(self): - """Delete this :class:`Zone` and perform nessecary cleanups""" - api_args = {} - DynectSession.get_session().execute(self.uri, 'DELETE', api_args) + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) class SecondaryZone(object): - """""" + """A class representing DynECT Secondary zones""" def __init__(self, zone, *args, **kwargs): """Create a :class:`SecondaryZone` object @@ -602,7 +646,8 @@ def __init__(self, zone, *args, **kwargs): def _get(self): """Get a :class:`SecondaryZone` object from the DynECT System""" api_args = {} - response = DynectSession.get_session().execute(self.uri, 'GET', api_args) + response = DynectSession.get_session().execute(self.uri, 'GET', + api_args) for key, val in response['data'].items(): setattr(self, '_' + key, val) @@ -616,7 +661,8 @@ def _post(self, masters, contact_nickname=None, tsig_key_name=None): api_args['contact_nickname'] = self._contact_nickname if tsig_key_name: api_args['tsig_key_name'] = self._tsig_key_name - response = DynectSession.get_session().execute(self.uri, 'POST', api_args) + response = DynectSession.get_session().execute(self.uri, 'POST', + api_args) for key, val in response['data'].items(): setattr(self, '_' + key, val) @@ -638,7 +684,8 @@ def masters(self): def masters(self, value): self._masters = value api_args = {'masters': self._masters} - response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) + response = DynectSession.get_session().execute(self.uri, 'PUT', + api_args) for key, val in response['data'].items(): setattr(self, '_' + key, val) @@ -652,7 +699,8 @@ def contact_nickname(self): def contact_nickname(self, value): self._contact_nickname = value api_args = {'contact_nickname': self._contact_nickname} - response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) + response = DynectSession.get_session().execute(self.uri, 'PUT', + api_args) for key, val in response['data'].items(): setattr(self, '_' + key, val) @@ -666,21 +714,24 @@ def tsig_key_name(self): def tsig_key_name(self, value): self._tsig_key_name = value api_args = {'tsig_key_name': self._tsig_key_name} - response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) + response = DynectSession.get_session().execute(self.uri, 'PUT', + api_args) for key, val in response['data'].items(): setattr(self, '_' + key, val) def activate(self): """Activates this secondary zone""" api_args = {'activate': True} - response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) + response = DynectSession.get_session().execute(self.uri, 'PUT', + api_args) for key, val in response['data'].items(): setattr(self, '_' + key, val) def deactivate(self): """Deactivates this secondary zone""" api_args = {'deactivate': True} - response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) + response = DynectSession.get_session().execute(self.uri, 'PUT', + api_args) for key, val in response['data'].items(): setattr(self, '_' + key, val) @@ -689,7 +740,8 @@ def retransfer(self): Dyn's Managed DNS """ api_args = {'retransfer': True} - response = DynectSession.get_session().execute(self.uri, 'PUT', api_args) + response = DynectSession.get_session().execute(self.uri, 'PUT', + api_args) for key, val in response['data'].items(): setattr(self, '_' + key, val) @@ -701,11 +753,12 @@ def delete(self): def __str__(self): """str override""" - return ': {}'.format(self._zone) + return force_unicode(': {}').format(self._zone) + __repr__ = __unicode__ = __str__ - def __repr__(self): - """repr override""" - return ': {}'.format(self._zone) + def __bytes__(self): + """bytes override""" + return bytes(self.__str__()) class Node(object): @@ -739,6 +792,7 @@ def add_record(self, record_type='A', *args, **kwargs): :param args: Non-keyword arguments to pass to the Record constructor :param kwargs: Keyword arguments to pass to the Record constructor """ + # noinspection PyCallingNonCallable rec = RECS[record_type](self.zone, self.fqdn, *args, **kwargs) if record_type in self.records: self.records[record_type].append(rec) @@ -762,6 +816,7 @@ def add_service(self, service_type=None, *args, **kwargs): 'GSLB': GSLB, 'RDNS': ReverseDNS, 'RTTM': RTTM} + # noinspection PyCallingNonCallable service = constructors[service_type](self.zone, self.fqdn, *args, **kwargs) self.services.append(service) @@ -779,7 +834,8 @@ def get_all_records(self): api_args = {'detail': 'Y'} response = DynectSession.get_session().execute(uri, 'GET', api_args) # Strip out empty record_type lists - record_lists = {label: rec_list for label, rec_list in response['data'].items() if rec_list != []} + record_lists = {label: rec_list for label, rec_list in + response['data'].items() if rec_list != []} records = {} for key, record_list in record_lists.items(): search = key.split('_')[0].upper() @@ -841,7 +897,8 @@ def get_any_records(self): uri = '/ANYRecord/{}/{}/'.format(self.zone, self.fqdn) response = DynectSession.get_session().execute(uri, 'GET', api_args) # Strip out empty record_type lists - record_lists = {label: rec_list for label, rec_list in response['data'].items() if rec_list != []} + record_lists = {label: rec_list for label, rec_list in + response['data'].items() if rec_list != []} records = {} for key, record_list in record_lists.items(): search = key.split('_')[0].upper() @@ -867,8 +924,9 @@ def delete(self): def __str__(self): """str override""" - return ': {}'.format(self.fqdn) + return force_unicode(': {}').format(self.fqdn) + __repr__ = __unicode__ = __str__ - def __repr__(self): - """repr override""" - return ': {}'.format(self.fqdn) + def __bytes__(self): + """bytes override""" + return bytes(self.__str__())