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