-
-
Notifications
You must be signed in to change notification settings - Fork 305
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
22 changed files
with
5,477 additions
and
2,259 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,273 @@ | ||
"""Provide support to Lexicon for Gandi DNS changes. | ||
Lexicon provides a common interface for querying and managing DNS services | ||
through those services' APIs. This module implements the Lexicon interface | ||
against the Gandi API. | ||
The Gandi API is different from typical DNS APIs in that Gandi | ||
zone changes are atomic. You cannot edit the currently active | ||
configuration. Any changes require editing either a new or inactive | ||
configuration. Once the changes are committed, then the domain is switched | ||
to using the new zone configuration. This module makes no attempt to | ||
cleanup previous zone configurations. | ||
Note that Gandi domains can share zone configurations. In other words, | ||
I can have domain-a.com and domain-b.com which share the same zone | ||
configuration file. If I make changes to domain-a.com, those changes | ||
will only apply to domain-a.com, as domain-b.com will continue using | ||
the previous version of the zone configuration. This module makes no | ||
attempt to detect and account for that. | ||
""" | ||
from __future__ import print_function | ||
from __future__ import absolute_import | ||
import logging | ||
from .base import Provider as BaseProvider | ||
|
||
try: | ||
import xmlrpclib | ||
except ImportError: | ||
import xmlrpc.client as xmlrpclib | ||
|
||
LOGGER = logging.getLogger(__name__) | ||
|
||
def ProviderParser(subparser): | ||
"""Specify arguments for Gandi Lexicon Provider.""" | ||
subparser.add_argument('--auth-token', help="specify Gandi API key") | ||
|
||
|
||
class Provider(BaseProvider): | ||
"""Provide Gandi DNS API implementation of Lexicon Provider interface. | ||
The class will use the following environment variables to configure | ||
it instance. For more information, read the Lexicon documentation. | ||
- LEXICON_GANDI_API_ENDPOINT - the Gandi API endpoint to use | ||
The default is the production URL https://rpc.gandi.net/xmlrpc/. | ||
Set this environment variable to the OT&E URL for testing. | ||
""" | ||
|
||
def __init__(self, options, provider_options=None): | ||
"""Initialize Gandi DNS provider.""" | ||
|
||
super(Provider, self).__init__(options) | ||
|
||
if provider_options is None: | ||
provider_options = {} | ||
|
||
api_endpoint = provider_options.get('api_endpoint') or 'https://rpc.gandi.net/xmlrpc/' | ||
|
||
self.apikey = self.options['auth_token'] | ||
self.api = xmlrpclib.ServerProxy(api_endpoint, allow_none=True) | ||
|
||
self.default_ttl = 3600 | ||
|
||
# self.domain_id is required by test suite | ||
self.domain_id = None | ||
self.zone_id = None | ||
|
||
self.domain = self.options['domain'].lower() | ||
|
||
# Authenicate against provider, | ||
# Make any requests required to get the domain's id for this provider, | ||
# so it can be used in subsequent calls. Should throw an error if | ||
# authentication fails for any reason, or if the domain does not exist. | ||
def authenticate(self): | ||
"""Determine the current domain and zone IDs for the domain.""" | ||
|
||
try: | ||
payload = self.api.domain.info(self.apikey, self.domain) | ||
self.domain_id = payload['id'] | ||
self.zone_id = payload['zone_id'] | ||
|
||
except xmlrpclib.Fault as err: | ||
raise Exception("Failed to authenticate: '{0}'".format(err)) | ||
|
||
# Create record. If record already exists with the same content, do nothing' | ||
def create_record(self, type, name, content): | ||
"""Creates a record for the domain in a new Gandi zone.""" | ||
|
||
version = None | ||
ret = False | ||
|
||
name = self._relative_name(name) | ||
|
||
# This isn't quite "do nothing" if the record already exists. | ||
# In this case, no new record will be created, but a new zone version | ||
# will be created and set. | ||
try: | ||
version = self.api.domain.zone.version.new(self.apikey, self.zone_id) | ||
self.api.domain.zone.record.add(self.apikey, self.zone_id, version, | ||
{'type': type.upper(), | ||
'name': name, | ||
'value': content, | ||
'ttl': self.options.get('ttl') or self.default_ttl | ||
}) | ||
self.api.domain.zone.version.set(self.apikey, self.zone_id, version) | ||
ret = True | ||
|
||
finally: | ||
if not ret and version is not None: | ||
self.api.domain.zone.version.delete(self.apikey, self.zone_id, version) | ||
|
||
LOGGER.debug("create_record: %s", ret) | ||
return ret | ||
|
||
# List all records. Return an empty list if no records found | ||
# type, name and content are used to filter records. | ||
# If possible filter during the query, otherwise filter after response is received. | ||
def list_records(self, type=None, name=None, content=None): | ||
"""List all record for the domain in the active Gandi zone.""" | ||
|
||
opts = {} | ||
if type is not None: | ||
opts['type'] = type.upper() | ||
if name is not None: | ||
opts['name'] = self._relative_name(name) | ||
if content is not None: | ||
opts['value'] = self._txt_encode(content) if opts.get('type', '') == 'TXT' else content | ||
|
||
records = [] | ||
payload = self.api.domain.zone.record.list(self.apikey, self.zone_id, 0, opts) | ||
for record in payload: | ||
processed_record = { | ||
'type': record['type'], | ||
'name': self._full_name(record['name']), | ||
'ttl': record['ttl'], | ||
'content': record['value'], | ||
'id': record['id'] | ||
} | ||
|
||
# Gandi will add quotes to all TXT record strings | ||
if processed_record['type'] == 'TXT': | ||
processed_record['content'] = self._txt_decode(processed_record['content']) | ||
|
||
records.append(processed_record) | ||
|
||
LOGGER.debug("list_records: %s", records) | ||
return records | ||
|
||
# Update a record. Identifier must be specified. | ||
def update_record(self, identifier, type=None, name=None, content=None): | ||
"""Updates the specified record in a new Gandi zone.""" | ||
|
||
if not identifier: | ||
records = self.list_records(type, name) | ||
if len(records) == 1: | ||
identifier = records[0]['id'] | ||
elif len(records) > 1: | ||
raise Exception('Several record identifiers match the request') | ||
else: | ||
raise Exception('Record identifier could not be found') | ||
|
||
identifier = int(identifier) | ||
version = None | ||
|
||
# Gandi doesn't allow you to edit records on the active zone file. | ||
# Gandi also doesn't persist zone record identifiers when creating | ||
# a new zone file. To update by identifier, we lookup the record | ||
# by identifier, then use the record fields to find the record in | ||
# the newly created zone. | ||
records = self.api.domain.zone.record.list(self.apikey, self.zone_id, 0, {'id': identifier}) | ||
|
||
if len(records) == 1: | ||
rec = records[0] | ||
del rec['id'] | ||
|
||
try: | ||
version = self.api.domain.zone.version.new(self.apikey, self.zone_id) | ||
records = self.api.domain.zone.record.list(self.apikey, self.zone_id, version, rec) | ||
if len(records) != 1: | ||
raise GandiInternalError("expected one record") | ||
|
||
if type is not None: | ||
rec['type'] = type.upper() | ||
if name is not None: | ||
rec['name'] = self._relative_name(name) | ||
if content is not None: | ||
rec['value'] = self._txt_encode(content) if rec['type'] == 'TXT' else content | ||
|
||
records = self.api.domain.zone.record.update(self.apikey, | ||
self.zone_id, | ||
version, | ||
{'id': records[0]['id']}, | ||
rec) | ||
if len(records) != 1: | ||
raise GandiInternalError("expected one updated record") | ||
|
||
self.api.domain.zone.version.set(self.apikey, self.zone_id, version) | ||
ret = True | ||
|
||
except GandiInternalError: | ||
pass | ||
|
||
finally: | ||
if not ret and version is not None: | ||
self.api.domain.zone.version.delete(self.apikey, self.zone_id, version) | ||
|
||
LOGGER.debug("update_record: %s", ret) | ||
return ret | ||
|
||
# Delete an existing record. | ||
# If record does not exist, do nothing. | ||
# If an identifier is specified, use it, otherwise do a lookup using type, name and content. | ||
def delete_record(self, identifier=None, type=None, name=None, content=None): | ||
"""Removes the specified record in a new Gandi zone.""" | ||
|
||
version = None | ||
ret = False | ||
|
||
opts = {} | ||
if identifier is not None: | ||
opts['id'] = identifier | ||
else: | ||
opts['type'] = type.upper() | ||
opts['name'] = self._relative_name(name) | ||
opts["value"] = self._txt_encode(content) if opts['type'] == 'TXT' else content | ||
|
||
records = self.api.domain.zone.record.list(self.apikey, self.zone_id, 0, opts) | ||
if len(records) == 1: | ||
rec = records[0] | ||
del rec['id'] | ||
|
||
try: | ||
version = self.api.domain.zone.version.new(self.apikey, self.zone_id) | ||
cnt = self.api.domain.zone.record.delete(self.apikey, self.zone_id, version, rec) | ||
if cnt != 1: | ||
raise GandiInternalError("expected one deleted record") | ||
|
||
self.api.domain.zone.version.set(self.apikey, self.zone_id, version) | ||
ret = True | ||
|
||
except GandiInternalError: | ||
pass | ||
|
||
finally: | ||
if not ret and version is not None: | ||
self.api.domain.zone.version.delete(self.apikey, self.zone_id, version) | ||
|
||
LOGGER.debug("delete_record: %s", ret) | ||
return ret | ||
|
||
def _request(self, action='GET', url='/', data=None, query_params=None): | ||
# Not used here, as requests are handled by xmlrpc | ||
pass | ||
|
||
@staticmethod | ||
def _txt_encode(val): | ||
return ''.join(['"', val.replace('\\', '\\\\').replace('"', '\\"'), '"']) | ||
|
||
@staticmethod | ||
def _txt_decode(val): | ||
if len(val) > 1 and val[0:1] == '"': | ||
val = val[1:-1].replace('" "', '').replace('\\"', '"').replace('\\\\', '\\') | ||
return val | ||
|
||
|
||
# This exception is for cleaner handling of internal errors | ||
# within the Gandi provider codebase | ||
class GandiInternalError(Exception): | ||
"""Internal exception handling class for Gandi management errors""" | ||
pass |
Oops, something went wrong.