Skip to content

Commit

Permalink
Merge 8732f2d into 7c4599f
Browse files Browse the repository at this point in the history
  • Loading branch information
adferrand authored Jun 15, 2017
2 parents 7c4599f + 8732f2d commit 6583014
Show file tree
Hide file tree
Showing 22 changed files with 5,477 additions and 2,259 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ The current supported providers are:
- DNSPark ([docs](https://dnspark.zendesk.com/entries/31210577-REST-API-DNS-Documentation))
- DNSPod ([docs](https://support.dnspod.cn/Support/api))
- EasyDNS ([docs](http://docs.sandbox.rest.easydns.net/))
- Gandi ([docs](http://doc.rpc.gandi.net/))
- Glesys ([docs](https://github.com/glesys/API/wiki/functions_domain))
- LuaDNS ([docs](http://www.luadns.com/api.html))
- Memset ([docs](https://www.memset.com/apidocs/methods_dns.html))
Expand All @@ -51,7 +52,6 @@ Potential providers are as follows. If you would like to contribute one, please
- ~~DurableDNS ([docs](https://durabledns.com/wiki/doku.php/ddns))~~ <sub>Can't set TXT records</sub>
- ~~Dyn ([docs](https://help.dyn.com/dns-api-knowledge-base/))~~ <sub>Unable to test, requires paid account</sub>
- ~~EntryDNS ([docs](https://entrydns.net/help))~~ <sub>Unable to test, requires paid account</sub>
- Gandi ([docs](http://doc.rpc.gandi.net/)) <sub>was removed in [319ac2a46](https://github.com/AnalogJ/lexicon/commit/319ac2a4633586e60fcd32592bbd22032d8facc3) and [bf8ca76df61](https://github.com/AnalogJ/lexicon/commit/bf8ca76df616ce189dcd4c514063b4f3c8ab1439)</sub>
- Google Cloud DNS ([docs](https://cloud.google.com/dns/api/v1/))
- GoDaddy DNS ([docs](https://developer.godaddy.com/getstarted#access))
- ~~Host Virtual DNS ([docs](https://github.com/hostvirtual/hostvirtual-python-sdk/blob/master/hostvirtual.py))~~ <sub>Unable to test, requires paid account</sub>
Expand Down
273 changes: 273 additions & 0 deletions lexicon/providers/gandi.py
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
Loading

0 comments on commit 6583014

Please sign in to comment.