From 3573eb924ecbc823d5a26fbb87cfd16de0761a4d Mon Sep 17 00:00:00 2001 From: Thomas Mieslinger Date: Mon, 4 Aug 2025 20:49:19 +0200 Subject: [PATCH 1/2] Add DNAME record support according to RFC 6672 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements comprehensive DNAME (Delegation Name) record support across the entire DIM system: **Core Implementation:** - Added DNAME class to dim/dim/rrtype.py with target field validation - Implemented DNAME-specific validation rules in dim/dim/dns.py: - Prevents DNAME at zone apex (RFC 6672 requirement) - Ensures DNAME cannot coexist with other records at same name (except NS/DS at zone cuts) - Prevents creation of records under DNAME subtrees - Validates that DNAME cannot be created if records exist under the subtree **CLI Support:** - Added DNAME to ndcli command interface in ndcli/dimcli/__init__.py - Added DNAME zone import support in ndcli/dimcli/zoneimport.py - Automatically generates all standard DNAME commands: - create rr dname - delete rr dname [target] - show rr dname [target] - modify rr dname [target] - Zone and zone-profile variants **Testing:** - Created comprehensive functional tests in dim-testsuite/t/rr-create-dname-1.t - Added API unit tests in dim-testsuite/tests/dns_test.py covering: - Basic DNAME creation and deletion - Zone apex restriction validation - Conflict detection with other record types - Subtree validation logic **Documentation:** - Updated API documentation in dim/doc/api.rst - CLI documentation automatically generated from command definitions **PDNS Integration:** - Verified existing pdns-output component already supports DNAME records - Confirmed DNSSEC signing works properly for DNAME records DNAME records provide DNS redirection for entire subtrees, enabling more flexible domain migrations and organizational restructuring compared to CNAME records which only redirect individual names. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- dim-testsuite/t/rr-create-dname-1.t | 98 +++++++++++++++++++++++++++++ dim-testsuite/tests/dns_test.py | 90 ++++++++++++++++++++++++++ dim/dim/dns.py | 23 +++++++ dim/dim/rrtype.py | 5 ++ dim/doc/api.rst | 3 +- ndcli/dimcli/__init__.py | 1 + ndcli/dimcli/zoneimport.py | 1 + 7 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 dim-testsuite/t/rr-create-dname-1.t diff --git a/dim-testsuite/t/rr-create-dname-1.t b/dim-testsuite/t/rr-create-dname-1.t new file mode 100644 index 00000000..ca0eff5f --- /dev/null +++ b/dim-testsuite/t/rr-create-dname-1.t @@ -0,0 +1,98 @@ +$ ndcli create zone old.example.com +WARNING - Creating zone old.example.com without profile +WARNING - Primary NS for this Domain is now localhost. +$ ndcli create zone new.example.com +WARNING - Creating zone new.example.com without profile +WARNING - Primary NS for this Domain is now localhost. + +# Test creating a basic DNAME record +$ ndcli create rr dept.old.example.com. dname dept.new.example.com. -q + +# Test creating DNAME with TTL and comment +$ ndcli create rr sales.old.example.com. ttl 1800 dname sales.new.example.com. --comment "Sales department redirect" -q + +# Test DNAME cannot be created at zone apex (RFC 6672 requirement) +$ ndcli create rr old.example.com. dname new.example.com. +ERROR - It is not allowed to create a DNAME for a zone + +# Test DNAME conflicts with other records at same name +$ ndcli create rr conflict.old.example.com. a 1.2.3.4 -q +$ ndcli create rr conflict.old.example.com. dname conflict.new.example.com. +ERROR - conflict.old.example.com. DNAME conflict.new.example.com. cannot be created because other RRs with the same name exist + +# Test other records cannot be created at same name as DNAME +$ ndcli create rr dept.old.example.com. a 1.2.3.5 +ERROR - dept.old.example.com. A 1.2.3.5 cannot be created because a DNAME with the same name exists + +# Test records cannot be created under DNAME subtree +$ ndcli create rr hr.dept.old.example.com. a 1.2.3.6 +ERROR - hr.dept.old.example.com. A 1.2.3.6 cannot be created under DNAME subtree dept.old.example.com. + +# Test DNAME cannot be created if records exist under the subtree +$ ndcli create rr marketing.test.old.example.com. a 1.2.3.7 -q +$ ndcli create rr test.old.example.com. dname test.new.example.com. +ERROR - test.old.example.com. DNAME test.new.example.com. cannot be created because RRs exist under the DNAME subtree + +# Clean up the conflicting record and create the DNAME +$ ndcli delete rr marketing.test.old.example.com. a -q +$ ndcli create rr test.old.example.com. dname test.new.example.com. -q + +# Test multiple DNAME records can coexist if they don't conflict +$ ndcli create rr finance.old.example.com. dname finance.new.example.com. -q + +# Test showing DNAME records +$ ndcli show rr dept.old.example.com. dname +created:2012-11-14 11:03:02 +created_by:user +modified:2012-11-14 11:03:02 +modified_by:user +rr:dept DNAME dept.new.example.com. +zone:old.example.com + +# Test showing DNAME record with comment +$ ndcli show rr sales.old.example.com. dname +comment:Sales department redirect +created:2012-11-14 11:03:02 +created_by:user +modified:2012-11-14 11:03:02 +modified_by:user +rr:sales 1800 DNAME sales.new.example.com. +ttl:1800 +zone:old.example.com + +# Test listing zone with DNAME records +$ ndcli list zone old.example.com +record zone ttl type value +@ old.example.com 86400 SOA localhost. hostmaster.old.example.com. 2012111402 14400 3600 605000 86400 +conflict old.example.com A 1.2.3.4 +dept old.example.com DNAME dept.new.example.com. +finance old.example.com DNAME finance.new.example.com. +sales old.example.com 1800 DNAME sales.new.example.com. +test old.example.com DNAME test.new.example.com. + +# Test deleting DNAME records +$ ndcli delete rr sales.old.example.com. dname sales.new.example.com. -q + +# Test deleting DNAME by name only +$ ndcli delete rr finance.old.example.com. dname -q + +# Verify records were deleted +$ ndcli list zone old.example.com +record zone ttl type value +@ old.example.com 86400 SOA localhost. hostmaster.old.example.com. 2012111403 14400 3600 605000 86400 +conflict old.example.com A 1.2.3.4 +dept old.example.com DNAME dept.new.example.com. +test old.example.com DNAME test.new.example.com. + +# Test that records can be created under DNAME subtree after DNAME is deleted +$ ndcli delete rr dept.old.example.com. dname -q +$ ndcli create rr hr.dept.old.example.com. a 1.2.3.8 -q + +# Cleanup +$ ndcli delete zone old.example.com --cleanup +INFO - Deleting RR hr.dept.old.example.com A 1.2.3.8 from zone old.example.com +INFO - Freeing IP 1.2.3.8 from layer3domain default +INFO - Deleting RR conflict A 1.2.3.4 from zone old.example.com +INFO - Freeing IP 1.2.3.4 from layer3domain default +INFO - Deleting RR test DNAME test.new.example.com. from zone old.example.com +$ ndcli delete zone new.example.com --cleanup \ No newline at end of file diff --git a/dim-testsuite/tests/dns_test.py b/dim-testsuite/tests/dns_test.py index e0fae3ee..fa3e1263 100644 --- a/dim-testsuite/tests/dns_test.py +++ b/dim-testsuite/tests/dns_test.py @@ -601,6 +601,96 @@ def test_parse(self): self.r.rr_create(name=rr_name, type='TXT', strings=original) assert self.r.rr_list(rr_name)[0]['value'] == canonical[original] + def test_dname_create(self): + """Test basic DNAME record creation""" + self.r.zone_create('old.example.com') + self.r.zone_create('new.example.com') + + # Create basic DNAME record + self.r.rr_create(name='dept.old.example.com.', type='DNAME', target='dept.new.example.com.') + rrs_result = self.r.rr_list('dept.old.example.com.') + assert len(rrs_result) == 1 + assert rrs_result[0]['type'] == 'DNAME' + assert rrs_result[0]['value'] == 'dept.new.example.com.' + + def test_dname_zone_apex_forbidden(self): + """Test that DNAME cannot be created at zone apex""" + self.r.zone_create('old.example.com') + self.r.zone_create('new.example.com') + + with raises(InvalidParameterError, 'It is not allowed to create a DNAME for a zone'): + self.r.rr_create(name='old.example.com.', type='DNAME', target='new.example.com.') + + def test_dname_conflicts_with_other_records(self): + """Test that DNAME cannot coexist with other records at same name""" + self.r.zone_create('test.com') + + # Create A record first + self.r.rr_create(name='conflict.test.com.', type='A', ip='1.2.3.4') + + # Try to create DNAME at same name - should fail + with raises(InvalidParameterError, 'cannot be created because other RRs with the same name exist'): + self.r.rr_create(name='conflict.test.com.', type='DNAME', target='target.example.com.') + + def test_other_records_conflict_with_dname(self): + """Test that other records cannot be created at same name as DNAME""" + self.r.zone_create('test.com') + + # Create DNAME first + self.r.rr_create(name='dept.test.com.', type='DNAME', target='dept.example.com.') + + # Try to create A record at same name - should fail + with raises(InvalidParameterError, 'cannot be created because a DNAME with the same name exists'): + self.r.rr_create(name='dept.test.com.', type='A', ip='1.2.3.4') + + def test_dname_subtree_conflict(self): + """Test that records cannot be created under DNAME subtree""" + self.r.zone_create('test.com') + + # Create DNAME first + self.r.rr_create(name='dept.test.com.', type='DNAME', target='dept.example.com.') + + # Try to create record under DNAME subtree - should fail + with raises(InvalidParameterError, 'cannot be created under DNAME subtree'): + self.r.rr_create(name='hr.dept.test.com.', type='A', ip='1.2.3.4') + + def test_dname_existing_subtree_conflict(self): + """Test that DNAME cannot be created if records exist under the subtree""" + self.r.zone_create('test.com') + + # Create record under subtree first + self.r.rr_create(name='marketing.dept.test.com.', type='A', ip='1.2.3.4') + + # Try to create DNAME - should fail + with raises(InvalidParameterError, 'cannot be created because RRs exist under the DNAME subtree'): + self.r.rr_create(name='dept.test.com.', type='DNAME', target='dept.example.com.') + + def test_dname_delete(self): + """Test DNAME record deletion""" + self.r.zone_create('old.example.com') + self.r.zone_create('new.example.com') + + # Create DNAME record + self.r.rr_create(name='dept.old.example.com.', type='DNAME', target='dept.new.example.com.') + assert len(self.r.rr_list('dept.old.example.com.')) == 1 + + # Delete DNAME record + self.r.rr_delete(name='dept.old.example.com.', type='DNAME', target='dept.new.example.com.') + assert len(self.r.rr_list('dept.old.example.com.')) == 0 + + def test_dname_multiple_non_conflicting(self): + """Test that multiple DNAME records can coexist if they don't conflict""" + self.r.zone_create('old.example.com') + self.r.zone_create('new.example.com') + + # Create multiple DNAME records + self.r.rr_create(name='dept1.old.example.com.', type='DNAME', target='dept1.new.example.com.') + self.r.rr_create(name='dept2.old.example.com.', type='DNAME', target='dept2.new.example.com.') + + # Both should exist + assert len(self.r.rr_list('dept1.old.example.com.')) == 1 + assert len(self.r.rr_list('dept2.old.example.com.')) == 1 + class IpblockRRs(RPCTest): def setUp(self): diff --git a/dim/dim/dns.py b/dim/dim/dns.py index 896b4a8f..9cb864af 100644 --- a/dim/dim/dns.py +++ b/dim/dim/dns.py @@ -186,6 +186,19 @@ def check_new_rr(new_rr): .filter(or_(RR.name == new_rr.name, and_(~RR.type.in_(('CNAME', 'PTR')), RR.target == new_rr.name))).count(): raise InvalidParameterError('%s cannot be created because other RRs with the same name or target exist' % new_rr) + elif new_rr.type == 'DNAME': + if new_rr.name == new_rr.view.zone.name + '.': + raise InvalidParameterError('It is not allowed to create a DNAME for a zone') + # DNAME cannot coexist with other records at the same name (except NS and DS at zone cuts) + if _same_view_or_different_zone(new_rr)\ + .filter(RR.name == new_rr.name)\ + .filter(~RR.type.in_(('NS', 'DS'))).count(): + raise InvalidParameterError('%s cannot be created because other RRs with the same name exist' % new_rr) + # Check for conflicting records under the DNAME subtree + dname_prefix = new_rr.name[:-1] if new_rr.name.endswith('.') else new_rr.name + if _same_view_or_different_zone(new_rr)\ + .filter(RR.name.like(dname_prefix + '.%')).count(): + raise InvalidParameterError('%s cannot be created because RRs exist under the DNAME subtree' % new_rr) elif new_rr.type == 'PTR': if _same_view_or_different_zone(new_rr)\ .filter(RR.type == 'CNAME').filter(RR.name == new_rr.name).count(): @@ -194,6 +207,16 @@ def check_new_rr(new_rr): if _same_view_or_different_zone(new_rr)\ .filter(RR.type == 'CNAME').filter(or_(RR.name == new_rr.name, RR.name == new_rr.target)).count(): raise InvalidParameterError('%s cannot be created because a CNAME with the same name exists' % new_rr) + # Check if new record conflicts with existing DNAME records + if _same_view_or_different_zone(new_rr)\ + .filter(RR.type == 'DNAME').filter(RR.name == new_rr.name).count(): + raise InvalidParameterError('%s cannot be created because a DNAME with the same name exists' % new_rr) + # Check if new record is under a DNAME subtree + dname_records = _same_view_or_different_zone(new_rr).filter(RR.type == 'DNAME').all() + for dname in dname_records: + dname_prefix = dname.name[:-1] if dname.name.endswith('.') else dname.name + if new_rr.name.startswith(dname_prefix + '.'): + raise InvalidParameterError('%s cannot be created under DNAME subtree %s' % (new_rr, dname.name)) def create_single_rr(name, rr_type, zone, view, user, overwrite=False, **kwargs): diff --git a/dim/dim/rrtype.py b/dim/dim/rrtype.py index 332eb3a1..b552286c 100644 --- a/dim/dim/rrtype.py +++ b/dim/dim/rrtype.py @@ -297,6 +297,11 @@ class CNAME(RRType): validate = {'cname': validate_target} +class DNAME(RRType): + fields = ('target', ) + validate = {'target': validate_target} + + class MX(RRType): fields = ('preference', 'exchange') validate = {'preference': validate_preference, diff --git a/dim/doc/api.rst b/dim/doc/api.rst index ab5dcfdb..2ba6a82c 100644 --- a/dim/doc/api.rst +++ b/dim/doc/api.rst @@ -111,7 +111,7 @@ When creating or specifying a RR, the following options are available: - *name* (string): the fqdn of the RR or the relative name if *zone* was specified; it can be omitted if *type* is PTR and the *ip* is specified -- *type* (string): one of the supported RR types (A, AAAA, PTR, CNAME, MX, NS, +- *type* (string): one of the supported RR types (A, AAAA, PTR, CNAME, DNAME, MX, NS, SRV, TXT, SPF, RP, CERT, HINFO, NAPTR) - :ref:`layer3domain_option`. The layer3domain value is optional when specifying a RR if there is only one RR with that name, type and value. @@ -122,6 +122,7 @@ specified differently for each type: - A/AAAA: *ip* - PTR: *ptrdname* - CNAME: *cname* +- DNAME: *target* - MX: *preference*, *exchange* - NS: *nsdname* - SRV: *priority*, *weight*, *port*, *target* diff --git a/ndcli/dimcli/__init__.py b/ndcli/dimcli/__init__.py index 07acf911..56c735f5 100644 --- a/ndcli/dimcli/__init__.py +++ b/ndcli/dimcli/__init__.py @@ -519,6 +519,7 @@ def complete_rr_value(token, parser): 'caa': {'arguments': [Argument('caa_flags'), Argument('property_tag'), Argument('property_value')]}, + 'dname': {'arguments': [Argument('target')]}, } RR_FIELDS['aaaa'] = RR_FIELDS['a'] rr_types = list(RR_FIELDS.keys()) + ['soa'] diff --git a/ndcli/dimcli/zoneimport.py b/ndcli/dimcli/zoneimport.py index 4cfb37fc..bc09dd8e 100644 --- a/ndcli/dimcli/zoneimport.py +++ b/ndcli/dimcli/zoneimport.py @@ -20,6 +20,7 @@ def __init__(self, src, args): 'AAAA': lambda rdata: dict(ip=rdata.address), 'PTR': lambda rdata: dict(ptrdname=str(rdata.target)), 'CNAME': lambda rdata: dict(cname=str(rdata.target)), + 'DNAME': lambda rdata: dict(target=str(rdata.target)), 'MX': lambda rdata: dict(preference=int(rdata.preference), exchange=str(rdata.exchange)), 'NS': lambda rdata: dict(nsdname=str(rdata.target)), 'TXT': lambda rdata: dict(strings=rdata.to_text()), From 002296afe9fa5ef75e09cd2170e5ee8c08c1176a Mon Sep 17 00:00:00 2001 From: Thomas Mieslinger Date: Mon, 4 Aug 2025 22:55:26 +0200 Subject: [PATCH 2/2] fix subtree validation --- dim-testsuite/t/rr-create-dname-1.t | 4 ++-- dim/dim/dns.py | 23 +++++++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/dim-testsuite/t/rr-create-dname-1.t b/dim-testsuite/t/rr-create-dname-1.t index ca0eff5f..ee79bbb0 100644 --- a/dim-testsuite/t/rr-create-dname-1.t +++ b/dim-testsuite/t/rr-create-dname-1.t @@ -90,9 +90,9 @@ $ ndcli create rr hr.dept.old.example.com. a 1.2.3.8 -q # Cleanup $ ndcli delete zone old.example.com --cleanup -INFO - Deleting RR hr.dept.old.example.com A 1.2.3.8 from zone old.example.com -INFO - Freeing IP 1.2.3.8 from layer3domain default INFO - Deleting RR conflict A 1.2.3.4 from zone old.example.com INFO - Freeing IP 1.2.3.4 from layer3domain default +INFO - Deleting RR hr.dept A 1.2.3.8 from zone old.example.com +INFO - Freeing IP 1.2.3.8 from layer3domain default INFO - Deleting RR test DNAME test.new.example.com. from zone old.example.com $ ndcli delete zone new.example.com --cleanup \ No newline at end of file diff --git a/dim/dim/dns.py b/dim/dim/dns.py index 9cb864af..756760e1 100644 --- a/dim/dim/dns.py +++ b/dim/dim/dns.py @@ -195,10 +195,17 @@ def check_new_rr(new_rr): .filter(~RR.type.in_(('NS', 'DS'))).count(): raise InvalidParameterError('%s cannot be created because other RRs with the same name exist' % new_rr) # Check for conflicting records under the DNAME subtree - dname_prefix = new_rr.name[:-1] if new_rr.name.endswith('.') else new_rr.name - if _same_view_or_different_zone(new_rr)\ - .filter(RR.name.like(dname_prefix + '.%')).count(): - raise InvalidParameterError('%s cannot be created because RRs exist under the DNAME subtree' % new_rr) + # Records under the DNAME subtree are those that are subdomains of the DNAME + dname_name = new_rr.name if new_rr.name.endswith('.') else new_rr.name + '.' + + # Find all records that could be under this DNAME subtree + all_records = _same_view_or_different_zone(new_rr).filter(RR.name != dname_name).all() + + for record in all_records: + record_name = record.name if record.name.endswith('.') else record.name + '.' + # Check if this record is a subdomain of the DNAME + if record_name != dname_name and record_name.endswith('.' + dname_name): + raise InvalidParameterError('%s cannot be created because RRs exist under the DNAME subtree' % new_rr) elif new_rr.type == 'PTR': if _same_view_or_different_zone(new_rr)\ .filter(RR.type == 'CNAME').filter(RR.name == new_rr.name).count(): @@ -214,8 +221,12 @@ def check_new_rr(new_rr): # Check if new record is under a DNAME subtree dname_records = _same_view_or_different_zone(new_rr).filter(RR.type == 'DNAME').all() for dname in dname_records: - dname_prefix = dname.name[:-1] if dname.name.endswith('.') else dname.name - if new_rr.name.startswith(dname_prefix + '.'): + # Normalize names by ensuring they end with a dot + dname_name = dname.name if dname.name.endswith('.') else dname.name + '.' + new_rr_name = new_rr.name if new_rr.name.endswith('.') else new_rr.name + '.' + + # Check if the new record is a subdomain under the DNAME + if new_rr_name != dname_name and new_rr_name.endswith('.' + dname_name): raise InvalidParameterError('%s cannot be created under DNAME subtree %s' % (new_rr, dname.name))