Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support value-domain.com #602

Closed
wants to merge 15 commits into from
Validating CODEOWNERS rules …
@@ -76,7 +76,8 @@ lexicon/providers/safedns.py @miff2000
lexicon/providers/softlayer.py @adherzog
lexicon/providers/subreg.py @oldium
lexicon/providers/transip.py @LordGaav @yorickvP
lexicon/providers/ultradns.py @abligh
lexicon/providers/ultradns.py @abligh
lexicon/providers/valuedomain.py @ledyba
lexicon/providers/vercel.py @adferrand
lexicon/providers/vultr.py @analogj
lexicon/providers/yandex.py @kharkevich
@@ -0,0 +1,227 @@
"""Module provider for Value Domain"""
from __future__ import absolute_import

import hashlib
import json
import logging
import re
import requests
from urllib.parse import urlencode
from lexicon.providers.base import Provider as BaseProvider

LOGGER = logging.getLogger(__name__)

NAMESERVER_DOMAINS = ["value-domain.com", "dnsv.jp"]


def provider_parser(subparser):
"""Generate a provider parser for Value Domain"""
subparser.add_argument(
"--auth-token", help="specify access token for authentication"
)


class Provider(BaseProvider):
"""Provider class for Value Domain"""

def __init__(self, config):
super(Provider, self).__init__(config)
self.nameserver = "valuedomain1"
Copy link
Collaborator

@adferrand adferrand Oct 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it make more sense to set the nameserver to None? We expect it to be set during the authentication phase, or the provider is not configured yet.

self.ttl = 3600
self.domain_id = None
self.api_endpoint = "https://api.value-domain.com/v1"

def _authenticate(self):
payload = self._get("/domains/{0}/dns".format(self.domain))
self.ttl = int(payload["results"]["ttl"])
self.nameserver = payload["results"]["ns_type"]

if payload["results"]["domainname"] == self.domain:
self.domain_id = payload["results"]["domainid"]
return

raise Exception("No domain found")

# Create record. If record already exists with the same content, do nothing'
def _create_record(self, rtype, name, content):
name = self._relative_name(name)
resource_record_sets = self._get_resource_record_sets()
index = self._find_resource_record_set(
resource_record_sets, identifier=None, rtype=rtype, name=name, content=content
)
if index >= 0:
LOGGER.debug("record already exists")
return True

resource_record_sets.append((rtype.lower(), name, self._bind_format_target(rtype, content)))
self._update_resource_record_sets(resource_record_sets)
LOGGER.debug("create_record: %s", True)
return True

@staticmethod
def _identifier(record):
Copy link
Collaborator

@adferrand adferrand Oct 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to myself. I really need to make this function generally available, since it is used in almost all providers that do not have ID native support.

sha256 = hashlib.sha256()
sha256.update(('type=' + record[0].lower()).encode('utf-8'))
sha256.update(('name=' + record[1]).encode('utf-8'))
sha256.update(('data=' + record[2]).encode('utf-8'))
return sha256.hexdigest()[0:7]

# 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):
records = []
full_name = None
if name:
full_name = self._full_name(name)

for record in self._get_resource_record_sets():
processed_record = {
"type": record[0].upper(),
"name": self._full_name(record[1]),
"ttl": self.ttl,
Copy link
Collaborator

@adferrand adferrand Oct 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the ttl value is not taken from the record?

"content": record[2],
'id': Provider._identifier(record),
}
if rtype and processed_record["type"].lower() != rtype.lower():
continue
if full_name and processed_record["name"] != full_name:
continue
if content and processed_record["content"] != content:
continue
records.append(processed_record)

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

# Create or update a record.
def _update_record(self, identifier=None, rtype=None, name=None, content=None):
Copy link
Collaborator

@adferrand adferrand Oct 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can make the function a little more flexible regarding the inputs provided.

First, as for delete action, we can check for identifier, and update the record, or try the other parameters to find the matching one(s).

Second, if an identifier is not provided, you can require only rtype and name to be set (not content). In this case, you need to handle the situation where two record would be retrieved for a given rtype+name (quite common with TXT entries):

  • either you warn the user and take the first
  • or you error out indicating to the user that more than one potential record to update was found.


if not (rtype and name and content):
raise Exception("rtype ,name and content must be specified.")

name = self._relative_name(name)
resource_record_sets = self._get_resource_record_sets()
index = self._find_resource_record_set(
resource_record_sets, identifier=identifier, rtype=rtype, name=name
)

record = (rtype.lower(), name, self._bind_format_target(rtype, content))
if index >= 0:
resource_record_sets[index] = record
else:
resource_record_sets.append(record)

self._update_resource_record_sets(resource_record_sets)
LOGGER.debug("create_record")

LOGGER.debug("update_record: %s", True)
return True

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

@adferrand adferrand Oct 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The general behavior expected in delete record is:

  • if identifier is set, then search for the record and proceed to deletion
  • otherwise take rtype, name and content, find the matching record(s) and proceed to deletion

It is still possible to have this logic with identifiers calculated on the fly on lexicon side. In this case, usually a provider list all records using list_record, then calculate the identifier on each record to compare with the provided identifier.

If you implement that, I think you will cover all the test cases and can remove the skip directive in the test module.

resource_record_sets = self._get_resource_record_sets()

if name is not None:
name = self._relative_name(name)
if content is not None:
content = self._bind_format_target(rtype, content)

filtered_records = []
for record in resource_record_sets:
if identifier and Provider._identifier(record) != identifier:
continue
if rtype and record[0].lower() != rtype.lower():
continue
if name and record[1] != name:
continue
if content and record[2] != content:
continue
filtered_records.append(record)

if not filtered_records:
LOGGER.debug("delete_record: %s", False)
return False

for record in filtered_records:
resource_record_sets.remove(record)

self._update_resource_record_sets(resource_record_sets)
LOGGER.debug("delete_record: %s", True)
return True

# Helpers
def _full_name(self, record_name):
if record_name == "@":
record_name = self.domain
return super(Provider, self)._full_name(record_name)

def _relative_name(self, record_name):
name = super(Provider, self)._relative_name(record_name)
if not name:
name = "@"
return name

def _bind_format_target(self, rtype, target):
if rtype == "CNAME" and not target.endswith("."):
target += "."
return target

def _find_resource_record_set(self, records, identifier=None, rtype=None, name=None, content=None):
for index, record in enumerate(records):
if identifier and Provider._identifier(record) == identifier:
return index
if rtype and record[0].lower() != rtype.lower():
continue
if name and record[1] != name:
continue
if content and record[2] != content:
continue
return index
return -1

def _get_resource_record_sets(self):
payload = self._get("/domains/{0}/dns".format(self.domain))
records = list(map(lambda line: tuple(re.split(r'\s+', line, 2)), payload["results"]["records"].splitlines()))
return records

def _update_resource_record_sets(self, resource_record_sets):
record_txt = "\n".join(map(lambda rec: " ".join(rec), resource_record_sets))
content = {
"ns_type": self.nameserver,
"records": record_txt,
"ttl": self._get_lexicon_option("ttl"),
}
return self._put("/domains/{0}/dns".format(self.domain), content)

def _request(self, action="GET", url="/", data=None, query_params=None):
if data is None:
data = {}
if query_params is None:
query_params = {}
default_headers = {
"Accept-Encoding": "identity",
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Bearer {0}".format(self._get_provider_option("auth_token")),
}

query_string = ""
if query_params:
query_string = urlencode(query_params)

response = requests.request(
action,
self.api_endpoint + url,
params=query_string,
data=json.dumps(data),
headers=default_headers,
)
try:
# if the request fails for any reason, throw an error.
response.raise_for_status()
except BaseException:
LOGGER.error(response.json().get("error_msg"))
raise
return response.json()
@@ -0,0 +1,23 @@
# Test for one implementation of the interface
import pytest

from lexicon.tests.providers.integration_tests import IntegrationTestsV2
from unittest import TestCase


# 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 ValuedomainProviderTests(TestCase, IntegrationTestsV2):
"""Integration tests for Value Domain provider"""
provider_name = 'valuedomain'
domain = '6io.org'

def _filter_headers(self):
return ['Authorization']

@pytest.mark.skip(reason="record id is not exists")
def test_provider_when_calling_delete_record_by_identifier_should_remove_record(
self,
):
return
@@ -0,0 +1,62 @@
interactions:
- request:
body: '{}'
headers:
Accept:
- application/json
Accept-Encoding:
- identity
Connection:
- keep-alive
Content-Length:
- '2'
Content-Type:
- application/json
User-Agent:
- python-requests/2.24.0
method: GET
uri: https://api.value-domain.com/v1/domains/6io.org/dns
response:
body:
string: '{"results":{"domainid":6020258,"domainname":"6io.org","ns_type":"valuedomain11","records":"a
localhost 127.0.0.1\ncname docs docs.example.com.\ntxt _acme-challenge.fqdn
challengetoken\ntxt _acme-challenge.full challengetoken\ntxt _acme-challenge.test
challengetoken\ntxt _acme-challenge.createrecordset challengetoken1\ntxt _acme-challenge.createrecordset
challengetoken2\ntxt _acme-challenge.noop challengetoken\ntxt _acme-challenge.deleterecordinset
challengetoken2","ttl":"3600"},"request_id":"202010180924022362860265666V","request":{"path":"\/v1\/domains\/6io.org\/dns","method":"GET","params":[]}}'
headers:
CF-Cache-Status:
- DYNAMIC
CF-RAY:
- 5e3e1d34ea5da528-NRT
Connection:
- keep-alive
Content-Type:
- application/json; charset=utf-8
Date:
- Sun, 18 Oct 2020 00:24:02 GMT
Expect-CT:
- max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
Server:
- cloudflare
Set-Cookie:
- __cfduid=d4e3cebe1fe8fbc83dac855fbb72de3e31602980642; expires=Tue, 17-Nov-20
00:24:02 GMT; path=/; domain=.value-domain.com; HttpOnly; SameSite=Lax
- u=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; path=/; SameSite=None; secure
- s=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; path=/; SameSite=None; secure
- ss=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; path=/; SameSite=None;
secure
Strict-Transport-Security:
- max-age=15552000; preload
Transfer-Encoding:
- chunked
X-Content-Type-Options:
- nosniff
alt-svc:
- h3-27=":443"; ma=86400, h3-28=":443"; ma=86400, h3-29=":443"; ma=86400
cf-request-id:
- 05dab0950d0000a528280ac000000001
status:
code: 200
message: OK
version: 1
@@ -0,0 +1,58 @@
interactions:
- request:
body: '{}'
headers:
Accept:
- application/json
Accept-Encoding:
- identity
Connection:
- keep-alive
Content-Length:
- '2'
Content-Type:
- application/json
User-Agent:
- python-requests/2.24.0
method: GET
uri: https://api.value-domain.com/v1/domains/thisisadomainidonotown.com/dns
response:
body:
string: '{"errors":{"domainid":0,"domainname":"thisisadomainidonotown.com","code":400,"message":"domainname
is invalid."},"request_id":"202010180924027056280265666V","request":{"path":"\/v1\/domains\/thisisadomainidonotown.com\/dns","method":"GET","params":[]}}'
headers:
CF-Cache-Status:
- DYNAMIC
CF-RAY:
- 5e3e1d36e882f8bb-NRT
Connection:
- keep-alive
Content-Type:
- application/json; charset=utf-8
Date:
- Sun, 18 Oct 2020 00:24:02 GMT
Expect-CT:
- max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
Server:
- cloudflare
Set-Cookie:
- __cfduid=d1bd378009056e8d4577743c59720c5341602980642; expires=Tue, 17-Nov-20
00:24:02 GMT; path=/; domain=.value-domain.com; HttpOnly; SameSite=Lax
- u=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; path=/; SameSite=None; secure
- s=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; path=/; SameSite=None; secure
- ss=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; path=/; SameSite=None;
secure
Strict-Transport-Security:
- max-age=15552000; preload
Transfer-Encoding:
- chunked
X-Content-Type-Options:
- nosniff
alt-svc:
- h3-27=":443"; ma=86400, h3-28=":443"; ma=86400, h3-29=":443"; ma=86400
cf-request-id:
- 05dab096500000f8bb3c227000000001
status:
code: 400
message: Bad Request
version: 1