From c0b54602a45fa05ac837d28f1e3618f06295395a Mon Sep 17 00:00:00 2001 From: Tytus Kurek Date: Tue, 16 Oct 2018 15:42:11 +0200 Subject: [PATCH] CAA DNS records This patchset adds support for DNS CAA (Certification Authority Authorization) Resource Record which is described in RFC 6844 (https://tools.ietf.org/html/rfc6844) Change-Id: If9619096f1706d1123895b63b9129b9ffd4fb320 Closes-Bug: 1787552 --- contrib/archive/backends/impl_ipa/__init__.py | 5 +- designate/__init__.py | 2 +- designate/objects/__init__.py | 1 + designate/objects/fields.py | 54 ++++++++++++++++++ designate/objects/rrdata_caa.py | 54 ++++++++++++++++++ .../versions/102_support_caa_records.py | 44 +++++++++++++++ designate/storage/impl_sqlalchemy/tables.py | 3 +- designate/tests/test_central/test_service.py | 2 +- .../tests/test_objects/test_caa_object.py | 55 +++++++++++++++++++ doc/source/contributor/sourcedoc/objects.rst | 9 +++ 10 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 designate/objects/rrdata_caa.py create mode 100644 designate/storage/impl_sqlalchemy/migrate_repo/versions/102_support_caa_records.py create mode 100644 designate/tests/test_objects/test_caa_object.py diff --git a/contrib/archive/backends/impl_ipa/__init__.py b/contrib/archive/backends/impl_ipa/__init__.py index 20d7b9546..4576e76d1 100644 --- a/contrib/archive/backends/impl_ipa/__init__.py +++ b/contrib/archive/backends/impl_ipa/__init__.py @@ -63,7 +63,10 @@ rectype2iparectype = {'A': ('arecord', '%(data)s'), 'PTR': ('ptrrecord', '%(data)s'), 'SPF': ('spfrecord', '%(data)s'), 'SSHFP': ('sshfprecord', '%(data)s'), - 'NAPTR': ('naptrrecord', '%(data)s')} + 'NAPTR': ('naptrrecord', '%(data)s'), + 'CAA': ('caarecord', '%(data)s'), + } + IPA_INVALID_DATA = 3009 IPA_NOT_FOUND = 4001 diff --git a/designate/__init__.py b/designate/__init__.py index 0239fe538..4dd3ceb42 100644 --- a/designate/__init__.py +++ b/designate/__init__.py @@ -69,7 +69,7 @@ designate_opts = [ # Supported record types cfg.ListOpt('supported-record-type', help='Supported record types', default=['A', 'AAAA', 'CNAME', 'MX', 'SRV', 'TXT', 'SPF', 'NS', - 'PTR', 'SSHFP', 'SOA', 'NAPTR']), + 'PTR', 'SSHFP', 'SOA', 'NAPTR', 'CAA']), ] # Set some Oslo Log defaults diff --git a/designate/objects/__init__.py b/designate/objects/__init__.py index 5176295ad..451231a6c 100644 --- a/designate/objects/__init__.py +++ b/designate/objects/__init__.py @@ -49,6 +49,7 @@ from designate.objects.zone_export import ZoneExport, ZoneExportList # noqa from designate.objects.rrdata_a import A, AList # noqa from designate.objects.rrdata_aaaa import AAAA, AAAAList # noqa +from designate.objects.rrdata_caa import CAA, CAAList # noqa from designate.objects.rrdata_cname import CNAME, CNAMEList # noqa from designate.objects.rrdata_mx import MX, MXList # noqa from designate.objects.rrdata_naptr import NAPTR, NAPTRList # noqa diff --git a/designate/objects/fields.py b/designate/objects/fields.py index 397594245..6fa9a6913 100644 --- a/designate/objects/fields.py +++ b/designate/objects/fields.py @@ -101,6 +101,9 @@ class StringFields(ovoo_fields.StringField): RE_NAPTR_FLAGS = r'^(?!.*(.).*\1)[APSU]+$' RE_NAPTR_SERVICE = r'^([A-Za-z]([A-Za-z0-9]*)(\+[A-Za-z]([A-Za-z0-9]{0,31}))*)?' # noqa RE_NAPTR_REGEXP = r'^([^0-9i\\])(.*)\1((.+)|(\\[1-9]))\1(i?)' + RE_KVP = r'^\s[A-Za-z0-9]+=[A-Za-z0-9]+' + RE_URL_MAIL = r'^mailto:[A-Za-z0-9_\-]+@.*' + RE_URL_HTTP = r'^http(s)?://.*/' def __init__(self, nullable=False, read_only=False, default=ovoo_fields.UnspecifiedDefault, description='', @@ -337,6 +340,57 @@ class NaptrRegexpField(StringFields): return value +class CaaPropertyField(StringFields): + def __init__(self, **kwargs): + super(CaaPropertyField, self).__init__(**kwargs) + + def coerce(self, obj, attr, value): + value = super(CaaPropertyField, self).coerce(obj, attr, value) + prpt = value.split(' ', 1) + tag = prpt[0] + val = prpt[1] + if (tag == 'issue' or tag == 'issuewild'): + entries = val.split(';') + idn = entries.pop(0) + domain = idn.split('.') + for host in domain: + if len(host) > 63: + raise ValueError("Host %s is too long" % host) + idn_with_dot = idn + '.' + if not re.match(self.RE_ZONENAME, idn_with_dot): + raise ValueError("Domain %s does not match" % idn) + for entry in entries: + if not re.match(self.RE_KVP, entry): + raise ValueError("%s is not valid key-value pair" % entry) + elif tag == 'iodef': + if re.match(self.RE_URL_MAIL, val): + parts = val.split('@') + idn = parts[1] + domain = idn.split('.') + for host in domain: + if len(host) > 63: + raise ValueError("Host %s is too long" % host) + idn_with_dot = idn + '.' + if not re.match(self.RE_ZONENAME, idn_with_dot): + raise ValueError("Domain %s does not match" % idn) + elif re.match(self.RE_URL_HTTP, val): + parts = val.split('/') + idn = parts[2] + domain = idn.split('.') + for host in domain: + if len(host) > 63: + raise ValueError("Host %s is too long" % host) + idn_with_dot = idn + '.' + if not re.match(self.RE_ZONENAME, idn_with_dot): + raise ValueError("Domain %s does not match" % idn) + else: + raise ValueError("%s is not valid URL" % val) + else: + raise ValueError("Property tag %s must be 'issue', 'issuewild'" + " or 'iodef'" % value) + return value + + class Any(ovoo_fields.FieldType): @staticmethod def coerce(obj, attr, value): diff --git a/designate/objects/rrdata_caa.py b/designate/objects/rrdata_caa.py new file mode 100644 index 000000000..fe5c6d929 --- /dev/null +++ b/designate/objects/rrdata_caa.py @@ -0,0 +1,54 @@ +# Copyright 2018 Canonical Ltd. +# +# Author: Tytus Kurek +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from designate.objects.record import Record +from designate.objects.record import RecordList +from designate.objects import base +from designate.objects import fields + + +@base.DesignateRegistry.register +class CAA(Record): + """ + CAA Resource Record Type + Defined in: RFC6844 + """ + fields = { + 'flags': fields.IntegerFields(minimum=0, maximum=1), + 'prpt': fields.CaaPropertyField() + } + + def _to_string(self): + return ("%(flag)s %(prpt)s" % self) + + def _from_string(self, v): + flags, prpt = v.split(' ', 1) + self.flags = int(flags) + self.prpt = prpt + + # The record type is defined in the RFC. This will be used when the record + # is sent by mini-dns. + RECORD_TYPE = 257 + + +@base.DesignateRegistry.register +class CAAList(RecordList): + + LIST_ITEM_TYPE = CAA + + fields = { + 'objects': fields.ListOfObjectsField('CAA'), + } diff --git a/designate/storage/impl_sqlalchemy/migrate_repo/versions/102_support_caa_records.py b/designate/storage/impl_sqlalchemy/migrate_repo/versions/102_support_caa_records.py new file mode 100644 index 000000000..706117b3c --- /dev/null +++ b/designate/storage/impl_sqlalchemy/migrate_repo/versions/102_support_caa_records.py @@ -0,0 +1,44 @@ +# Copyright 2018 Canonical Ltd. +# +# Author: Tytus Kurek +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from sqlalchemy import MetaData, Table, Enum + +meta = MetaData() + + +def upgrade(migrate_engine): + meta.bind = migrate_engine + + RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'SRV', 'TXT', 'SPF', 'NS', + 'PTR', 'SSHFP', 'SOA', 'CAA'] + + records_table = Table('recordsets', meta, autoload=True) + records_table.columns.type.alter(name='type', type=Enum(*RECORD_TYPES)) + + +def downgrade(migrate_engine): + meta.bind = migrate_engine + + RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'SRV', 'TXT', 'SPF', 'NS', + 'PTR', 'SSHFP', 'SOA'] + + records_table = Table('recordsets', meta, autoload=True) + + # Delete all CAA records + records_table.filter_by(name='type', type='CAA').delete() + + # Remove CAA from the ENUM + records_table.columns.type.alter(type=Enum(*RECORD_TYPES)) diff --git a/designate/storage/impl_sqlalchemy/tables.py b/designate/storage/impl_sqlalchemy/tables.py index 22bf5118e..8549cfc53 100644 --- a/designate/storage/impl_sqlalchemy/tables.py +++ b/designate/storage/impl_sqlalchemy/tables.py @@ -29,7 +29,8 @@ CONF = cfg.CONF RESOURCE_STATUSES = ['ACTIVE', 'PENDING', 'DELETED', 'ERROR'] RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'SRV', 'TXT', 'SPF', 'NS', 'PTR', - 'SSHFP', 'SOA', 'NAPTR'] + 'SSHFP', 'SOA', 'NAPTR', 'CAA'] + TASK_STATUSES = ['ACTIVE', 'PENDING', 'DELETED', 'ERROR', 'COMPLETE'] TSIG_ALGORITHMS = ['hmac-md5', 'hmac-sha1', 'hmac-sha224', 'hmac-sha256', 'hmac-sha384', 'hmac-sha512'] diff --git a/designate/tests/test_central/test_service.py b/designate/tests/test_central/test_service.py index 5828c1720..c4f03762c 100644 --- a/designate/tests/test_central/test_service.py +++ b/designate/tests/test_central/test_service.py @@ -1823,7 +1823,7 @@ class CentralServiceTest(CentralTestCase): def test_update_recordset_immutable_type(self): zone = self.create_zone() # ['A', 'AAAA', 'CNAME', 'MX', 'SRV', 'TXT', 'SPF', 'NS', 'PTR', - # 'SSHFP', 'SOA', 'NAPTR'] + # 'SSHFP', 'SOA', 'NAPTR', 'CAA'] # Create a recordset recordset = self.create_recordset(zone) cname_recordset = self.create_recordset(zone, type='CNAME') diff --git a/designate/tests/test_objects/test_caa_object.py b/designate/tests/test_objects/test_caa_object.py new file mode 100644 index 000000000..4fa205737 --- /dev/null +++ b/designate/tests/test_objects/test_caa_object.py @@ -0,0 +1,55 @@ +# Copyright 2018 Canonical Ltd. +# +# Author: Tytus Kurek +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_log import log as logging +import oslotest.base + +from designate import objects + +LOG = logging.getLogger(__name__) + + +def debug(*a, **kw): + for v in a: + LOG.debug(repr(v)) + + for k in sorted(kw): + LOG.debug("%s: %s", k, repr(kw[k])) + + +class CAARecordTest(oslotest.base.BaseTestCase): + + def test_parse_caa_issue(self): + caa_record = objects.CAA() + caa_record._from_string('0 issue ca.example.net') + + self.assertEqual(0, caa_record.flags) + self.assertEqual('issue ca.example.net', caa_record.prpt) + + def test_parse_caa_issuewild(self): + caa_record = objects.CAA() + caa_record._from_string('1 issuewild ca.example.net; policy=ev') + + self.assertEqual(1, caa_record.flags) + self.assertEqual('issuewild ca.example.net; policy=ev', + caa_record.prpt) + + def test_parse_caa_iodef(self): + caa_record = objects.CAA() + caa_record._from_string('0 iodef https://example.net/') + + self.assertEqual(0, caa_record.flags) + self.assertEqual('iodef https://example.net/', caa_record.prpt) diff --git a/doc/source/contributor/sourcedoc/objects.rst b/doc/source/contributor/sourcedoc/objects.rst index d7c00583b..1934f4634 100644 --- a/doc/source/contributor/sourcedoc/objects.rst +++ b/doc/source/contributor/sourcedoc/objects.rst @@ -186,3 +186,12 @@ Objects NAPTR Record :members: :undoc-members: :show-inheritance: + + +Objects CAA Record +==================== +.. automodule:: designate.objects.rrdata_caa + + :members: + :undoc-members: + :show-inheritance: