Skip to content

Commit

Permalink
add RcodeZero (https://www.rcodezero.at) provider (#438)
Browse files Browse the repository at this point in the history
* add provider for rcodezero (https://my.rcodezero.at/api-doc)

* updatet  CODEOWNERS

* fixed formating issues

* update README.md

* fix pylint issue in providers/aliyun.py

* Reload CI

* fixed comment

* generate add shorter record id

* exclude session and csrf-protection cookies from cassettes
  • Loading branch information
MikeAT authored and adferrand committed Oct 23, 2019
1 parent 09058cd commit c8522e7
Show file tree
Hide file tree
Showing 30 changed files with 6,472 additions and 0 deletions.
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ lexicon/providers/pointhq.py @analogj
lexicon/providers/powerdns.py @insertjokehere @splashx
lexicon/providers/rackspace.py @rmarscher
lexicon/providers/rage4.py @analogj
lexicon/providers/rcodezero.py @MikeAT
lexicon/providers/route53.py @eadmundo
lexicon/providers/sakuracloud.py @chibiegg
lexicon/providers/safedns.py @miff2000
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ The current supported providers are:
- PowerDNS ([docs](https://doc.powerdns.com/md/httpapi/api_spec/))
- Rackspace ([docs](https://developer.rackspace.com/docs/cloud-dns/v1/developer-guide/))
- Rage4 ([docs](https://gbshouse.uservoice.com/knowledgebase/articles/109834-rage4-dns-developers-api))
- RcodeZero ([docs](https://my.rcodezero.at/api-doc))
- Sakura Cloud by SAKURA Internet Inc. ([docs](https://developer.sakura.ad.jp/cloud/api/1.1/))
- SafeDNS by UKFast ([docs](https://developers.ukfast.io/documentation/safedns))
- SoftLayer ([docs](https://sldn.softlayer.com/article/REST#HTTP_Request_Types))
Expand Down
216 changes: 216 additions & 0 deletions lexicon/providers/rcodezero.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"""Module provider for RcodeZero"""
from __future__ import absolute_import
import hashlib
import json
import logging

import requests
from lexicon.providers.base import Provider as BaseProvider


LOGGER = logging.getLogger(__name__)

NAMESERVER_DOMAINS = ['rcode0.net']


def provider_parser(subparser):
"""Return the parser for this provider"""
subparser.add_argument(
"--auth-token", help="specify token for authentication")


class Provider(BaseProvider):
"""Provider class for Cloudflare"""

def __init__(self, config):
super(Provider, self).__init__(config)
self.domain_id = None
self._zone_data = None
self.api_endpoint = 'https://my.rcodezero.at/api/v1'

def _authenticate(self):
if self._zone_data is None:
self._zone_data = self._get('/zones/' + self.domain)

self.domain_id = self.domain

# Create record.

def _create_record(self, rtype, name, content):
rname = self._fqdn_name(name)
newcontent = self._clean_content(rtype, content)

updated_data = {
'name': rname,
'type': rtype,
'records': [],
'ttl': self._get_lexicon_option('ttl') or 600,
'changetype': 'ADD'
}

updated_data['records'].append(
{'content': newcontent, 'disabled': False})

payload = self._get(
'/zones/{0}/rrsets?page_size=-1'.format(self.domain_id))

for rrset in payload['data']:
if rrset['name'] == rname and rrset['type'] == rtype:
updated_data['ttl'] = rrset['ttl']

for record in rrset['records']:
if record['content'] != newcontent:
updated_data['records'].append(
{
'content': record['content'],
'disabled': record['disabled']
})
updated_data['changetype'] = 'UPDATE'
break

request = [updated_data]
LOGGER.debug('request: %s', request)

self._patch('/zones/' + self.domain + '/rrsets', data=request)
return True

# 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, rtype=None, name=None, content=None):
filter_obj = {'per_page': 100}
if rtype:
filter_obj['type'] = rtype
if name:
filter_obj['name'] = self._full_name(name)
if content:
filter_obj['content'] = content

payload = self._get(
'/zones/{0}/rrsets?page_size=-1'.format(self.domain_id))

records = []
for rrset in payload['data']:
if (name is None or self._fqdn_name(rrset['name']) == self._fqdn_name(
name)) and (rtype is None or rrset['type'] == rtype):
for record in rrset['records']:
if content is None or record['content'] == self._clean_content(rtype, content):
# rcode0 does not have a record id, so lets create one
processed_record = {
'type': rrset['type'],
'name': self._full_name(rrset['name']),
'ttl': rrset['ttl'],
'content': self._unclean_content(rrset['type'], record['content']),
'id': self._make_identifier(rrset['type'],
rrset['name'], record['content'])
}
records.append(processed_record)

LOGGER.debug('list_records: %s', records)
return records

def _update_record(self, identifier, rtype=None, name=None, content=None):
self._delete_record(identifier, rtype, name, None)
return self._create_record(rtype, name, content)

# Delete an existing record.
# If record does not exist, do nothing.
def _delete_record(self, identifier=None, rtype=None, name=None, content=None):

LOGGER.debug("delete %s %s %s %s", identifier, rtype, name, content)
if identifier is None and (rtype is None or name is None):
raise Exception("Must specify at least id or both rtype and name")

payload = self._get(
'/zones/{0}/rrsets?page_size=-1'.format(self.domain_id))

if identifier is not None:
rtype, name, content = self._parse_identifier(identifier, payload)

update_data = None
for rrset in payload['data']:
if rrset['type'] == rtype and self._fqdn_name(rrset['name']) == self._fqdn_name(name):
update_data = rrset

if content is None:
update_data['records'] = []
update_data['changetype'] = 'DELETE'
else:
new_record_list = []
for record in update_data['records']:
if self._clean_content(rrset['type'], content) != record['content']:
new_record_list.append(record)

update_data['records'] = new_record_list
if new_record_list:
update_data['changetype'] = 'UPDATE'
else:
update_data['changetype'] = 'DELETE'
break

if update_data is not None:
request = [update_data]
LOGGER.debug('request: %s', request)
self._patch('/zones/' + self.domain + '/rrsets', data=request)

return True

# Helpers
def _request(self, action='GET', url='/', data=None, query_params=None):
if data is None:
data = {}
if query_params is None:
query_params = {}
response = requests.request(action, self.api_endpoint + url, params=query_params,
data=json.dumps(data),
headers={
'Authorization': 'Bearer ' +
self._get_provider_option(
'auth_token'),
'Content-Type': 'application/json'
})
# if the request fails for any reason, throw an error.
if response.status_code >= 400:
LOGGER.error('Bad Request: %s', response.text)
response.raise_for_status()
return response.json()

# generate a unique id for a give record
def _make_identifier(self, rtype, name, content): # pylint: disable=no-self-use
sha256 = hashlib.sha256()
sha256.update(('type=' + rtype + ',').encode('utf-8'))
sha256.update(('name=' + name + ',').encode('utf-8'))
sha256.update(('content=' + content + ',').encode('utf-8'))
return sha256.hexdigest()[0:7]

def _parse_identifier(self, identifier, payload): # pylint: disable=no-self-use

for rrset in payload['data']:
for record in rrset['records']:
if self._make_identifier(
rrset['type'], rrset['name'], record['content']) == identifier:
rtype = rrset['type']
name = self._full_name(rrset['name'])
content = self._unclean_content(
rrset['type'], record['content'])
return rtype, name, content

raise Exception("Record with ID {} not found ".format(identifier))


def _clean_content(self, rtype, content):
if rtype in ("TXT", "LOC"):
if content[0] != '"':
content = '"' + content
if content[-1] != '"':
content += '"'
elif rtype == "CNAME":
content = self._fqdn_name(content)
return content

def _unclean_content(self, rtype, content):
if rtype in ("TXT", "LOC"):
content = content.strip('"')
elif rtype == "CNAME":
content = self._full_name(content)
return content
15 changes: 15 additions & 0 deletions lexicon/tests/providers/test_rcodezero.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Integration tests for RcodeZero"""
from unittest import TestCase
from lexicon.tests.providers.integration_tests import IntegrationTests


# Hook into testing framework by inheriting unittest.TestCase and reuse
# the tests which *each and every* implementation of the interface must
# pass, by inheritance from integration_tests.IntegrationTests
class RcodezeroProviderTests(TestCase, IntegrationTests):
"""TestCase for RcodeZero"""
provider_name = 'rcodezero'
domain = 'lexicon-test.at'

def _filter_headers(self):
return ['Authorization']
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
interactions:
- request:
body: !!python/unicode '{}'
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '2'
Content-Type:
- application/json
User-Agent:
- python-requests/2.22.0
method: GET
uri: https://my.rcodezero.at/api/v1/zones/lexicon-test.at
response:
body:
string: !!python/unicode '{"id":544219,"domain":"lexicon-test.at","type":"MASTER","masters":[""],"serial":2019093201,"created":"2019-09-30T12:57:51Z","last_check":null,"dnssec_status":"Unsigned","dnssec_status_detail":"Unsigned"}'
headers:
cache-control:
- no-cache, private
connection:
- Keep-Alive
content-length:
- '203'
content-type:
- application/json
date:
- Mon, 30 Sep 2019 13:25:39 GMT
keep-alive:
- timeout=5, max=100
server:
- Apache
strict-transport-security:
- max-age=15768000
vary:
- Authorization,Accept-Encoding
x-frame-options:
- SAMEORIGIN
x-ratelimit-limit:
- '200'
x-ratelimit-remaining:
- '187'
status:
code: 200
message: OK
version: 1
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
interactions:
- request:
body: !!python/unicode '{}'
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '2'
Content-Type:
- application/json
User-Agent:
- python-requests/2.22.0
method: GET
uri: https://my.rcodezero.at/api/v1/zones/thisisadomainidonotown.com
response:
body:
string: !!python/unicode '{"status":"failed","message":"Zone not found"}'
headers:
cache-control:
- no-cache, private
connection:
- Keep-Alive
content-length:
- '46'
content-type:
- application/json
date:
- Mon, 30 Sep 2019 13:25:40 GMT
keep-alive:
- timeout=5, max=100
server:
- Apache
strict-transport-security:
- max-age=15768000
vary:
- Authorization
x-frame-options:
- SAMEORIGIN
x-ratelimit-limit:
- '200'
x-ratelimit-remaining:
- '186'
status:
code: 404
message: Not Found
version: 1
Loading

0 comments on commit c8522e7

Please sign in to comment.