1# Licensed to the Apache Software Foundation (ASF) under one or more
2# contributor license agreements.  See the NOTICE file distributed with
3# this work for additional information regarding copyright ownership.
4# The ASF licenses this file to You under the Apache License, Version 2.0
5# (the "License"); you may not use this file except in compliance with
6# the License.You may obtain a copy of the License at
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""
15    RcodeZero DNS Driver
16"""
17import json
18import hashlib
19import re
20
21from libcloud.common.base import ConnectionKey, JsonResponse
22from libcloud.common.exceptions import BaseHTTPError
23from libcloud.common.types import InvalidCredsError, MalformedResponseError
24from libcloud.dns.base import DNSDriver, Zone, Record
25from libcloud.dns.types import ZoneDoesNotExistError, ZoneAlreadyExistsError
26from libcloud.dns.types import Provider, RecordType
27from libcloud.utils.py3 import httplib
28
29API_HOST = 'my.rcodezero.at'
30
31__all__ = [
32    'RcodeZeroDNSDriver',
33]
34
35
36class RcodeZeroResponse(JsonResponse):
37
38    def success(self):
39        i = int(self.status)
40        return 200 <= i <= 299
41
42    def parse_error(self):
43        if self.status == httplib.UNAUTHORIZED:
44            raise InvalidCredsError(
45                'Invalid API key. Check https://my.rcodezero.at/enableapi')
46
47        errors = []
48        try:
49            body = self.parse_body()
50        except MalformedResponseError as e:
51            body = '%s: %s' % (e.value, e.body)
52        try:
53            errors = [body['message']]
54        except TypeError:
55            return '%s (HTTP Code: %d)' % (body, self.status)
56        except KeyError:
57            pass
58
59        return '%s (HTTP Code: %d)' % (' '.join(errors), self.status)
60
61
62class RcodeZeroConnection(ConnectionKey):
63    responseCls = RcodeZeroResponse
64
65    host = API_HOST
66
67    def add_default_headers(self, headers):
68        headers['Authorization'] = 'Bearer ' + self.key
69        headers['Accept'] = 'application/json'
70        return headers
71
72
73class RcodeZeroDNSDriver(DNSDriver):
74    type = Provider.RCODEZERO
75    name = 'RcodeZero DNS'
76    website = 'https://www.rcodezero.at/'
77    connectionCls = RcodeZeroConnection
78
79    RECORD_TYPE_MAP = {
80        RecordType.A: 'A',
81        RecordType.AAAA: 'AAAA',
82        RecordType.AFSDB: 'AFSDB',
83        RecordType.ALIAS: 'ALIAS',
84        RecordType.CERT: 'CERT',
85        RecordType.CNAME: 'CNAME',
86        RecordType.DNAME: 'DNAME',
87        RecordType.DNSKEY: 'DNSKEY',
88        RecordType.DS: 'DS',
89        RecordType.HINFO: 'HINFO',
90        RecordType.KEY: 'KEY',
91        RecordType.LOC: 'LOC',
92        RecordType.MX: 'MX',
93        RecordType.NAPTR: 'NAPTR',
94        RecordType.NS: 'NS',
95        RecordType.NSEC: 'NSEC',
96        RecordType.OPENPGPKEY: 'OPENPGPKEY',
97        RecordType.PTR: 'PTR',
98        RecordType.RP: 'RP',
99        RecordType.RRSIG: 'RRSIG',
100        RecordType.SOA: 'SOA',
101        RecordType.SPF: 'SPF',
102        RecordType.SRV: 'SRV',
103        RecordType.SSHFP: 'SSHFP',
104        RecordType.SRV: 'SRV',
105        RecordType.TLSA: 'TLSA',
106        RecordType.TXT: 'TXT',
107    }
108
109    def __init__(self, key, secret=None, secure=True, host=None,
110                 port=None, api_version='v1', **kwargs):
111        """
112        :param    key: API token to be used (required)
113        :type     key: ``str``
114
115        :param    secret: Password to be used, ignored by RcodeZero
116        :type     key: ``str``
117
118        :param    secure: Whether to use HTTPS (default) or HTTP.
119        :type     secure: ``bool``
120
121        :param    host: Hostname used for connections.
122        :type     host: ``str``
123
124        :param    port: Port used for connections.
125        :type     port: ``int``
126
127        :param    api_version: Specifies the API version to use.
128                               ``v1`` is currently the only valid
129                               option (and default)
130        :type     api_version: ``str``
131
132        :return: ``None``
133        """
134
135        if api_version == 'v1':
136            self.api_root = '/api/v1'
137        else:
138            raise NotImplementedError('Unsupported API version: %s' %
139                                      api_version)
140
141        super(RcodeZeroDNSDriver, self).__init__(key=key, secure=secure,
142                                                 host=host, port=port,
143                                                 **kwargs)
144
145    def create_record(self, name, zone, type, data, extra=None):
146        """
147        Create a new record in a given, existing zone.
148
149        :param name: name of the new record without the domain name,
150                     for example "www".
151        :type  name: ``str``
152
153        :param zone: Zone in which the requested record is created.
154        :type  zone: :class:`Zone`
155
156        :param type: DNS resource record type (A, AAAA, ...).
157        :type  type: :class:`RecordType`
158
159        :param data: Data for the record (depending on the record type).
160        :type  data: ``str``
161
162        :param extra: Extra attributes: 'ttl', 'disabled'
163        :type   extra: ``dict``
164
165        :rtype: :class:`Record`
166        """
167        action = '%s/zones/%s/rrsets' % (self.api_root, zone.id)
168
169        payload = self._to_patchrequest(
170            zone.id, None, name, type, data, extra, 'add')
171
172        try:
173            self.connection.request(action=action, data=json.dumps(payload),
174                                    method='PATCH')
175        except BaseHTTPError as e:
176            if e.code == httplib.UNPROCESSABLE_ENTITY and \
177               e.message.startswith('Could not find domain'):
178                raise ZoneDoesNotExistError(zone_id=zone.id, driver=self,
179                                            value=e.message)
180            raise e
181
182        if extra is not None and extra.get('ttl', None) is not None:
183            ttl = extra['ttl']
184        else:
185            ttl = None
186        return Record(id=None, name=name, data=data,
187                      type=type, zone=zone, ttl=ttl, driver=self)
188
189    def create_zone(self, domain, type='master', ttl=None, extra={}):
190        """
191        Create a new zone.
192
193        :param name: Zone domain name (e.g. example.com)
194        :type  name: ``str``
195
196        :param domain: Zone type ('master' / 'slave'). (required).
197        :type  domain: :class:`Zone`
198
199        :param ttl: TTL for new records. (optional). Ignored by RcodeZero.
200                    RcodeZero uses record specific TTLs.
201        :type  ttl: ``int``
202
203        :param extra: Extra attributes: 'masters' (for type=slave):
204                    ``extra={'masters': ['193.0.2.2','2001:db8::2']}``
205                    sets the Master nameservers for a type=slave zone.
206        :type extra: ``dict``
207
208        :rtype: :class:`Zone`
209        """
210        action = '%s/zones' % (self.api_root)
211        if type.lower() == 'slave' and (extra is None or
212                                        extra.get('masters', None) is None):
213            msg = 'Master IPs required for slave zones'
214            raise ValueError(msg)
215        payload = {'domain': domain.lower(), 'type': type.lower()}
216        payload.update(extra)
217        zone_id = domain + '.'
218        try:
219            self.connection.request(action=action, data=json.dumps(payload),
220                                    method='POST')
221        except BaseHTTPError as e:
222            if e.code == httplib.UNPROCESSABLE_ENTITY and \
223               e.message.find("Zone '%s' already configured for your account"
224                              % domain):
225                raise ZoneAlreadyExistsError(zone_id=zone_id, driver=self,
226                                             value=e.message)
227            raise e
228        return Zone(id=zone_id, domain=domain, type=type.lower(), ttl=None,
229                    driver=self, extra=extra)
230
231    def update_zone(self, zone, domain, type=None, ttl=None, extra=None):
232        """
233        Update an existing zone.
234
235        :param zone: Zone to update.
236        :type  zone: :class:`Zone`
237
238        :param domain: Zone domain name (e.g. example.com)
239        :type  domain: ``str``
240
241        :param type: Zone type ('master' / 'slave').
242        :type  type: ``str``
243
244        :param ttl: not supported. RcodeZero uses RRSet-specific TTLs
245        :type  ttl: ``int``
246
247        :param extra: Extra attributes: 'masters' (for type=slave)
248                     ``extra={'masters': ['193.0.2.2','2001:db8::2']}``
249                     sets the Master nameserver addresses for a type=slave zone
250        :type extra: ``dict``
251
252        :rtype: :class:`Zone`
253        """
254        action = '%s/zones/%s' % (self.api_root, domain)
255        if type is None:
256            type = zone.type
257
258        if type.lower() == 'slave' and (extra is None or
259                                        extra.get('masters', None) is None):
260            msg = 'Master IPs required for slave zones'
261            raise ValueError(msg)
262        payload = {'domain': domain.lower(), 'type': type.lower()}
263        if extra is not None:
264            payload.update(extra)
265        try:
266            self.connection.request(action=action, data=json.dumps(payload),
267                                    method='PUT')
268        except BaseHTTPError as e:
269            if e.code == httplib.UNPROCESSABLE_ENTITY and \
270               e.message.startswith("Domain '%s' update failed" % domain):
271                raise ZoneAlreadyExistsError(zone_id=zone.id, driver=self,
272                                             value=e.message)
273            raise e
274        return Zone(id=zone.id, domain=domain, type=type, ttl=None,
275                    driver=self, extra=extra)
276
277    def delete_record(self, record):
278        """
279        Delete a record in a given zone.
280
281        :param record: record to delete (record object)
282        :type record: `Record`
283
284        :rtype: ``bool``
285        """
286
287        action = '%s/zones/%s/rrsets' % (self.api_root, record.zone.id)
288
289        payload = self._to_patchrequest(
290            record.zone.id, None, record.name, record.type, record.data,
291            record.extra, 'delete')
292
293        try:
294            self.connection.request(action=action, data=json.dumps(payload),
295                                    method='PATCH')
296
297        except BaseHTTPError as e:
298            if e.code == httplib.UNPROCESSABLE_ENTITY and \
299               e.message.startswith('Could not find domain'):
300                raise ZoneDoesNotExistError(zone_id=record.zone.id,
301                                            driver=self, value=e.message)
302            raise e
303
304        return True
305
306    def delete_zone(self, zone):
307        """
308        Delete a zone and all its records.
309
310        :param zone: zone to delete
311        :type zone: `Zone`
312
313        :rtype: ``bool``
314        """
315        action = '%s/zones/%s' % (self.api_root,
316                                  zone.id)
317        try:
318            self.connection.request(action=action, method='DELETE')
319        except BaseHTTPError:
320            return False
321        return True
322
323    def get_zone(self, zone_id):
324        """
325        Get a Zone object.
326
327        :param zone_id: name of the zone, for
328                        example "example.com".
329        :type  zone_id: ``str``
330
331        :rtype: :class:`Zone`
332        :raises: ZoneDoesNotExistError: if zone could not be found.
333        """
334        action = '%s/zones/%s' % (self.api_root, zone_id)
335        try:
336            response = self.connection.request(action=action, method='GET')
337        except BaseHTTPError as e:
338            if e.code == httplib.NOT_FOUND:
339                raise ZoneDoesNotExistError(zone_id=zone_id, driver=self,
340                                            value=e.message)
341            raise e
342
343        return self._to_zone(response.object)
344
345    def get_record(self, zone_id, record_id):
346        """
347        Return a Record instance.
348
349        :param zone_id: ID of the required zone
350        :type  zone_id: ``str``
351
352        :param record_id: ID of the required record
353        :type  record_id: ``str``
354
355        :rtype: :class:`Record`
356        """
357        records = self.list_records(Zone(id=zone_id, domain=zone_id,
358                                         type=None, ttl=None, driver=self,
359                                         extra=None))
360
361        foundrecords = list(filter(lambda x: x.id == record_id, records))
362
363        if len(foundrecords) > 0:
364            return(foundrecords[0])
365        else:
366            return(None)
367
368    def list_records(self, zone):
369        """
370        Return a list of all record objects for the given zone.
371
372        :param zone: Zone object to list records for.
373        :type zone: :class:`Zone`
374
375        :return: ``list`` of :class:`Record`
376        """
377        action = '%s/zones/%s/rrsets?page_size=-1' % (self.api_root, zone.id)
378        try:
379            response = self.connection.request(action=action, method='GET')
380        except BaseHTTPError as e:
381            if e.code == httplib.UNPROCESSABLE_ENTITY and \
382               e.message.startswith('Could not find domain'):
383                raise ZoneDoesNotExistError(zone_id=zone.id, driver=self,
384                                            value=e.message)
385            raise e
386        return self._to_records(response.object['data'], zone)
387
388    def list_zones(self):
389        """
390        Return a list of zone objects for this account.
391
392        :return: ``list`` of :class:`Zone`
393        """
394        action = '%s/zones?page_size=-1' % (self.api_root)
395        response = self.connection.request(action=action, method='GET')
396        return self._to_zones(response.object['data'])
397
398    def update_record(self, record, name, type, data, extra=None):
399        """
400        Update an existing record.
401
402        :param record: Record object to update.
403        :type  record: :class:`Record`
404
405        :param name: name of the new record, for example "www".
406        :type  name: ``str``
407
408        :param type: DNS resource record type (A, AAAA, ...).
409        :type  type: :class:`RecordType`
410
411        :param data: Data for the record (depending on the record type).
412        :type  data: ``str``
413
414        :param extra: Extra attributes: 'ttl','disabled' (optional)
415        :type   extra: ``dict``
416
417        :rtype: :class:`Record`
418        """
419
420        action = '%s/zones/%s/rrsets' % (self.api_root, record.zone.id)
421
422        payload = self._to_patchrequest(
423            record.zone.id, record, name, type, data, record.extra, 'update')
424
425        try:
426            self.connection.request(action=action, data=json.dumps(payload),
427                                    method='PATCH')
428
429        except BaseHTTPError as e:
430            if e.code == httplib.UNPROCESSABLE_ENTITY and \
431               e.message.startswith('Could not find domain'):
432                raise ZoneDoesNotExistError(zone_id=record.zone.id,
433                                            driver=self, value=e.message)
434            raise e
435        if not (extra is None or extra.get('ttl', None) is None):
436            ttl = extra['ttl']
437        else:
438            ttl = record.ttl
439
440        return Record(id=hashlib.md5(str(name + ' ' +
441                                         data).encode('utf-8')).hexdigest(),
442                      name=name, data=data, type=type, zone=record.zone,
443                      driver=self, ttl=ttl, extra=extra)
444
445    def _to_zone(self, item):
446        extra = {}
447        for e in ['dnssec_status', 'dnssec_status_detail', 'dnssec_ksk_status',
448                  'dnssec_ksk_status_detail', 'dnssec_ds', 'dnssec_dnskey',
449                  'dnssec_safe_to_unsign', 'dnssec', 'masters', 'serial',
450                  'created', 'last_check']:
451            if e in item:
452                extra[e] = item[e]
453        return Zone(id=item['domain'], domain=item['domain'],
454                    type=item['type'].lower(), ttl=None, driver=self,
455                    extra=extra)
456
457    def _to_zones(self, items):
458        zones = []
459        for item in items:
460            zones.append(self._to_zone(item))
461        return zones
462
463    def _to_records(self, items, zone):
464        records = []
465        for item in items:
466            for record in item['records']:
467                extra = {}
468                extra['disabled'] = record['disabled']
469                # strip domain and trailing dot from recordname
470                recordname = re.sub('.' + zone.id + '$', '', item['name'][:-1])
471                records.append(
472                    Record(id=hashlib.md5(str(recordname + ' ' +
473                                              record['content']).
474                                          encode('utf-8')).hexdigest(),
475                           name=recordname, data=record['content'],
476                           type=item['type'], zone=zone,
477                           driver=self, ttl=item['ttl'], extra=extra))
478        return records
479
480    # rcodezero supports only rrset, so we must create rrsets from the given
481    # record
482    def _to_patchrequest(self, zone, record, name, type, data, extra, action):
483        rrset = {}
484
485        cur_records = self.list_records(
486            Zone(id=zone, domain=None, type=None, ttl=None, driver=self))
487
488        if name != '':
489            rrset['name'] = name + '.' + zone + '.'
490        else:
491            rrset['name'] = zone + '.'
492
493        rrset['type'] = type
494        rrset['changetype'] = action
495        rrset['records'] = []
496        if not (extra is None or extra.get('ttl', None) is None):
497            rrset['ttl'] = extra['ttl']
498
499        content = {}
500        if not action == 'delete':
501            content['content'] = data
502            if not (extra is None or extra.get('disabled', None) is None):
503                content['disabled'] = extra['disabled']
504            rrset['records'].append(content)
505        id = hashlib.md5(str(name + ' ' + data).encode('utf-8')).hexdigest()
506    # check if rrset contains more than one record. if yes we need to create an
507    # update request
508        for r in cur_records:
509            if action == 'update' and r.id == record.id:
510                # do not include records which should be updated in the update
511                # request
512                continue
513
514            if name == r.name and r.id != id:
515                # we have other records with the same name so make an update
516                # request
517                rrset['changetype'] = 'update'
518                content = {}
519                content['content'] = r.data
520                if not (r.extra is None or
521                        r.extra.get('disabled', None) is None):
522                    content['disabled'] = r.extra['disabled']
523                rrset['records'].append(content)
524        request = list()
525        request.append(rrset)
526        return request
527