-
-
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.
add RcodeZero (https://www.rcodezero.at) provider (#438)
* 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
Showing
30 changed files
with
6,472 additions
and
0 deletions.
There are no files selected for viewing
Validating CODEOWNERS rules …
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
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,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 |
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,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'] |
50 changes: 50 additions & 0 deletions
50
tests/fixtures/cassettes/rcodezero/IntegrationTests/test_provider_authenticate.yaml
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,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 |
50 changes: 50 additions & 0 deletions
50
...dezero/IntegrationTests/test_provider_authenticate_with_unmanaged_domain_should_fail.yaml
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,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 |
Oops, something went wrong.