From 69f9978dc8bfe33b0f02cfb62a7d9d6a6051657c Mon Sep 17 00:00:00 2001 From: PRANAV THUBE Date: Thu, 12 Feb 2026 10:54:17 +0530 Subject: [PATCH] ipatests: Add DNS integration tests. Tests cover LDAP connection handling, PTR synchronization, update policies, record validation, and zone management fixes. Fixes: https://pagure.io/freeipa/issue/9911 Reviewed-By: Florence Blanc-Renaud Reviewed-By: David Hanina Reviewed-By: Sudhir Menon --- ipatests/test_integration/test_dns.py | 1078 ++++++++++++++++++++++++- 1 file changed, 1076 insertions(+), 2 deletions(-) diff --git a/ipatests/test_integration/test_dns.py b/ipatests/test_integration/test_dns.py index 6840a10d1a301a8f19cfdf95896e08be7cfd7ea2..4b9ab1fe8d7b8884760ed637cb2fcc5d5a060df0 100644 --- a/ipatests/test_integration/test_dns.py +++ b/ipatests/test_integration/test_dns.py @@ -6,12 +6,11 @@ from __future__ import absolute_import import time - import dns.exception import dns.resolver from ipapython.dn import DN from ipapython.dnsutil import DNSResolver -from ipatests.pytest_ipa.integration import tasks +from ipatests.pytest_ipa.integration import tasks, skip_if_fips from ipatests.test_integration.base import IntegrationTest # ============================================================================= @@ -71,6 +70,35 @@ PTR_TTL = 59 NEW_TXT = "newip=5.6.7.8" NEWER_TXT = "newip=8.7.6.5" +# TSIG key configuration for dynamic DNS updates (hmac-md5) +KEY_NAME = "selfupdate" +KEY_SECRET = "05Fu1ACKv1/1Ag==" +KEY_CONFIG = f'''key {KEY_NAME} {{ + algorithm hmac-md5; + secret "{KEY_SECRET}"; +}}; +''' + +# nsupdate template for deleting A record +NSUPDATE_DELETE_A_TEMPLATE = """debug +update delete {hostname} IN A {ip} +send +""" + +# nsupdate template for adding AAAA record +NSUPDATE_ADD_AAAA_TEMPLATE = """debug +update add {hostname} {ttl} IN AAAA {ip} +send +""" + +# nsupdate template for adding A record with TSIG key +NSUPDATE_ADD_A_WITH_KEY_TEMPLATE = """server {server} +zone {zone} +key {key_name} {key_secret} +update add {hostname} {ttl} IN A {ip} +send +""" + class TestDNS(IntegrationTest): """Tests for DNS feature. @@ -1793,3 +1821,1049 @@ class TestDNSMisc(IntegrationTest): self.master.run_command([ '/sbin/ip', '-6', 'addr', 'del', temp_ipv6, 'dev', eth ], raiseonerr=False) + + @skip_if_fips(reason='hmac-md5 not supported in FIPS mode') + def test_updatepolicy_zonesub(self): + """Test BIND with bind-dyndb-ldap works with zonesub updatepolicy. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=921167 + + This test verifies that dynamic DNS updates work correctly when + using the 'zonesub' match type in the update policy. Note that + this test requires hmac-md5 which is not supported in FIPS mode. + """ + tasks.kinit_admin(self.master) + domain = self.master.domain.name + realm = self.master.domain.realm + + # Get IP address components for test record + ip_parts = self.master.ip.split('.') + test_ip = f'{ip_parts[0]}.{ip_parts[1]}.{ip_parts[2]}.100' + + # Backup named.conf + self.master.run_command([ + 'cp', '/etc/named.conf', '/etc/named.conf.bak' + ]) + + try: + # Add key to named.conf for dynamic updates + self.master.run_command([ + 'sh', '-c', + f'echo \'{KEY_CONFIG}\' >> /etc/named.conf' + ]) + + # Update zone policy to include zonesub match type + update_policy = ( + f'grant {realm} krb5-self * A; ' + f'grant {realm} krb5-self * AAAA; ' + f'grant {realm} krb5-self * SSHFP; ' + 'grant selfupdate zonesub A;' + ) + tasks.mod_dns_zone( + self.master, domain, f'--update-policy={update_policy}' + ) + + # Restart named to pick up changes + tasks.restart_named(self.master) + + # Create nsupdate commands file + nsupdate_content = NSUPDATE_ADD_A_WITH_KEY_TEMPLATE.format( + server=self.master.hostname, + zone=domain, + key_name=KEY_NAME, + key_secret=KEY_SECRET, + hostname=f'foobz921167.{domain}.', + ttl=60, + ip=test_ip + ) + self.master.put_file_contents( + '/tmp/dnsupdate.txt', nsupdate_content + ) + + # Execute nsupdate with zonesub policy and verify NOERROR + result = self.master.run_command([ + 'nsupdate', '-v', '-D', '/tmp/dnsupdate.txt' + ]) + assert 'NOERROR' in result.stdout_text, \ + 'Dynamic update did not return NOERROR' + + finally: + # Restore original named.conf + self.master.run_command([ + 'cp', '/etc/named.conf.bak', '/etc/named.conf' + ], raiseonerr=False) + + # Restore original update policy + original_policy = ( + f'grant {realm} krb5-self * A; ' + f'grant {realm} krb5-self * AAAA; ' + f'grant {realm} krb5-self * SSHFP;' + ) + tasks.mod_dns_zone( + self.master, domain, f'--update-policy={original_policy}', + raiseonerr=False + ) + + # Cleanup test record if it was created + tasks.del_dns_record( + self.master, domain, 'foobz921167', + del_all=True, raiseonerr=False + ) + + # Restart named + tasks.restart_named(self.master) + + def test_bind_shutdown_ldap_failure(self): + """Test BIND shuts down correctly when psearch enabled and LDAP fails. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=802375 + + This test verifies that named service can be stopped cleanly + even when LDAP connection is configured with an invalid URI. + The test modifies named.conf to point to an invalid LDAP URI, + then verifies that 'rndc stop' works correctly. + """ + tasks.kinit_admin(self.master) + + # Get realm name with dashes instead of dots for socket path + realm_inst = self.master.domain.realm.replace('.', '-') + + try: + # Check named is running + assert tasks.host_service_active(self.master, 'named') + + # Backup named.conf + self.master.run_command(['cp', '/etc/named.conf', '/root/']) + + # Comment out the valid LDAP URI and add invalid one + # The valid URI looks like: + # uri "ldapi://%2fvar%2frun%2fslapd-REALM.socket"; + sed_pattern = ( + f's|uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";' + f'|#uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";|g' + ) + self.master.run_command([ + 'sed', '-i', sed_pattern, '/etc/named.conf' + ]) + + # Add invalid LDAP URI before the commented line + insert_pattern = ( + f'/ldapi:\\/\\/%2fvar%2frun%2fslapd-{realm_inst}.socket/i ' + 'uri "ldapi://127.0.0.1";' + ) + self.master.run_command([ + 'sed', '-i', insert_pattern, '/etc/named.conf' + ]) + + # Restart named with invalid LDAP config + tasks.restart_named(self.master) + + # Try to stop named with rndc - should work without hanging + self.master.run_command(['rndc', 'stop'], raiseonerr=False) + + # Wait a moment for service to stop + time.sleep(5) + + # Verify named is not running (exit code 3 = inactive) + result = self.master.run_command( + ['systemctl', 'is-active', 'named'], raiseonerr=False + ) + assert result.returncode != 0, "named should be stopped" + + finally: + # Restore original named.conf + # Remove the invalid URI line + self.master.run_command([ + 'sed', '-i', '/ldapi:\\/\\/127.0.0.1/d', '/etc/named.conf' + ], raiseonerr=False) + + # Uncomment the original valid URI + uncomment_pattern = ( + f's|#uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";' + f'|uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";|g' + ) + self.master.run_command([ + 'sed', '-i', uncomment_pattern, '/etc/named.conf' + ], raiseonerr=False) + + # Restart named to restore normal operation + tasks.restart_named(self.master) + + def test_bind_ldap_reconnect(self): + """Test reconnect to LDAP when the first connection fails. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=767489 + + This test verifies that named service can handle LDAP connection + failures gracefully. It modifies named.conf to use an invalid LDAP + URI while blocking LDAP ports with iptables, then restarts named + to verify it handles the reconnection properly when connectivity + is restored. + """ + tasks.kinit_admin(self.master) + + # Get realm name with dashes instead of dots for socket path + realm_inst = self.master.domain.realm.replace('.', '-') + + # Firewall rich rules to block LDAP ports + ldap_reject = 'rule family=ipv4 port port=389 protocol=tcp reject' + ldaps_reject = 'rule family=ipv4 port port=636 protocol=tcp reject' + + try: + # Check named is running + assert tasks.host_service_active(self.master, 'named') + + # Backup named.conf + self.master.run_command(['cp', '/etc/named.conf', '/root/']) + + # Comment out the valid LDAP URI and add invalid one + sed_pattern = ( + f's|uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";' + f'|#uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";|g' + ) + self.master.run_command([ + 'sed', '-i', sed_pattern, '/etc/named.conf' + ]) + + # Add invalid LDAP URI before the commented line + insert_pattern = ( + f'/ldapi:\\/\\/%2fvar%2frun%2fslapd-{realm_inst}.socket/i ' + 'uri "ldapi://127.0.0.1";' + ) + self.master.run_command([ + 'sed', '-i', insert_pattern, '/etc/named.conf' + ]) + + # Block LDAP ports with firewall-cmd to ensure connection fails + self.master.run_command([ + 'firewall-cmd', f'--add-rich-rule={ldap_reject}' + ]) + self.master.run_command([ + 'firewall-cmd', f'--add-rich-rule={ldaps_reject}' + ]) + + # Restart named - it should handle the LDAP connection failure + tasks.restart_named(self.master) + + # Verify named is still running (may be in degraded state) + result = self.master.run_command( + ['systemctl', 'is-active', 'named'], raiseonerr=False + ) + # Named may be active or activating depending on timing + assert result.returncode == 0 or 'activating' in result.stdout_text + + finally: + # Remove firewall rules to restore LDAP connectivity + self.master.run_command([ + 'firewall-cmd', f'--remove-rich-rule={ldap_reject}' + ], raiseonerr=False) + self.master.run_command([ + 'firewall-cmd', f'--remove-rich-rule={ldaps_reject}' + ], raiseonerr=False) + + # Restore original named.conf + # Remove the invalid URI line + self.master.run_command([ + 'sed', '-i', '/ldapi:\\/\\/127.0.0.1/d', '/etc/named.conf' + ], raiseonerr=False) + + # Uncomment the original valid URI + uncomment_pattern = ( + f's|#uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";' + f'|uri "ldapi://%2fvar%2frun%2fslapd-{realm_inst}.socket";|g' + ) + self.master.run_command([ + 'sed', '-i', uncomment_pattern, '/etc/named.conf' + ], raiseonerr=False) + + # Restart named to restore normal operation + tasks.restart_named(self.master) + + def test_dnsrecord_mod_nonexistent_error(self): + """Test correct error message when modifying nonexistent DNS record. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=856281 + """ + tasks.kinit_admin(self.master) + domain = self.master.domain.name + + # Try to modify a non-existent record + result = tasks.mod_dns_record( + self.master, domain, 'this.does.not.exist', + '--txt-rec=foo', raiseonerr=False + ) + + assert result.returncode != 0 + expected_msg = "this.does.not.exist: DNS resource record not found" + assert expected_msg in result.stderr_text + + def test_zone_without_update_policy(self): + """Test zone without idnsUpdatePolicy works during zone refresh. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=908780 + + As per bind-dyndb-ldap/NEWS, psearch, serial_autoincrement and + zone_refresh were deprecated and removed. This test verifies + that zones without idnsUpdatePolicy still work correctly after + service restart. + """ + tasks.kinit_admin(self.master) + zone = "bz908780zone" + + try: + # Add test zone + tasks.add_dns_zone( + self.master, zone, + admin_email=self.EMAIL + ) + + # Modify zone to remove idnsUpdatePolicy attribute + tasks.mod_dns_zone(self.master, zone, '--update-policy=') + + # Restart named service + tasks.restart_named(self.master) + + # Check logs for error message (use journalctl for compatibility) + result = self.master.run_command([ + 'journalctl', '-u', 'named', '-n', '100', '--no-pager' + ]) + + # Verify no error about zone failing to transfer + err_msg = "unchanged. zone may fail to transfer to slaves" + assert err_msg not in result.stdout_text + + finally: + tasks.del_dns_zone(self.master, zone, raiseonerr=False) + + def test_txt_record_with_comma(self): + """Test dnsrecord works if TXT record data contains comma. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=910460 + """ + tasks.kinit_admin(self.master) + domain = self.master.domain.name + txt_value = ( + 'Holmes laughed. "It is quite a pretty little problem," ' + 'said he.' + ) + txt_mod_value = ( + 'Holmes laughed. "It is quite a pretty little problem," ' + 'said me.' + ) + + try: + # Add TXT record with comma - should not cause traceback + result = tasks.add_dns_record( + self.master, domain, '@', 'txt', [txt_value] + ) + assert result.returncode == 0 + + # Modify TXT record with comma - should not cause traceback + result = tasks.mod_dns_record( + self.master, domain, '@', + f'--txt-rec={txt_value}', f'--txt-data={txt_mod_value}' + ) + assert result.returncode == 0 + + finally: + # Delete TXT record + tasks.del_dns_record( + self.master, domain, '@', + record_type='txt', record_value=[txt_mod_value], + raiseonerr=False + ) + + def test_dname_single_value(self): + """Test DNAME record attribute allows single value only. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=915797 + + Verify that adding a second DNAME record to the same name fails + with appropriate error message. + """ + tasks.kinit_admin(self.master) + domain = self.master.domain.name + dname1 = f"foo.{domain}" + dname2 = f"bar.{domain}" + + try: + # Add first DNAME record + tasks.add_dns_record( + self.master, domain, 'dnamebz915797', 'dname', [dname1] + ) + + # Try to add second DNAME record - should fail + result = tasks.add_dns_record( + self.master, domain, 'dnamebz915797', 'dname', [dname2], + raiseonerr=False + ) + assert result.returncode != 0 + expected_msg = "only one DNAME record is allowed per name" + assert expected_msg in result.stderr_text + + finally: + tasks.del_dns_record( + self.master, domain, 'dnamebz915797', + del_all=True, raiseonerr=False + ) + + def test_cname_single_value(self): + """Test CNAME record allows single value only. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=915807 + + Verify that adding a second CNAME record to the same name fails + with appropriate error message. + """ + tasks.kinit_admin(self.master) + domain = self.master.domain.name + cname1 = f"foo.{domain}" + cname2 = f"bar.{domain}" + + try: + # Add first CNAME record + tasks.add_dns_record( + self.master, domain, 'cnamebz915807', 'cname', [cname1] + ) + + # Try to add second CNAME record - should fail + result = tasks.add_dns_record( + self.master, domain, 'cnamebz915807', 'cname', [cname2], + raiseonerr=False + ) + assert result.returncode != 0 + expected_msg = "only one CNAME record is allowed per name" + assert expected_msg in result.stderr_text + + finally: + tasks.del_dns_record( + self.master, domain, 'cnamebz915807', + del_all=True, raiseonerr=False + ) + + def test_idnszone_schema_no_cn(self): + """Test cn attribute is not present in idnsZone objectClasses. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=947911 + + Verify that 'cn' attribute is removed from idnsZone objectClasses + in LDAP schema. Note: 'cn' was returned back to idnsRecord to fix + bug 1167964, so we only check idnsZone. + """ + tasks.kinit_admin(self.master) + + # Check that cn attribute is not in idnsZone objectClass + # Use ldif-wrap=no to keep each objectClass on a single line + result = self.master.run_command([ + 'ldapsearch', '-x', '-D', + 'cn=Directory Manager', + '-w', self.master.config.dirman_password, + '-b', 'cn=schema', 'objectClasses', + '-o', 'ldif-wrap=no' + ]) + + # Verify idnsZone objectClass exists and doesn't contain cn attribute + lines = result.stdout_text.split('\n') + is_valid = any( + "NAME 'idnsZone'" in line and ' cn ' not in line + for line in lines + ) + assert is_valid, "cn attribute should not be in idnsZone objectClass" + + def test_ptr_sync_preserves_txt_record(self): + """Test PTR sync deletes only PTR record, preserves TXT record. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=958140 + + When PTR record synchronization is enabled and an A record is + deleted via dynamic update, only the corresponding PTR record + should be deleted. Other records (like TXT) on the same name + in the reverse zone should be preserved. + """ + tasks.kinit_admin(self.master) + test_zone = "examplebz958140.com" + reverse_zone = "3.2.1.in-addr.arpa." + realm = self.master.domain.realm + hostname = f"testbz958140.{test_zone}" + + try: + # Add test zones + tasks.add_dns_zone( + self.master, test_zone, + skip_overlap_check=True, + admin_email=f"hostmaster.{test_zone}" + ) + tasks.add_dns_zone( + self.master, reverse_zone, + skip_overlap_check=True, + admin_email=f"hostmaster.{reverse_zone}" + ) + + # Reload named so it can serve the new zones + self.master.run_command(['rndc', 'reload']) + + # Add A record with reverse PTR + tasks.add_dns_record( + self.master, test_zone, 'testbz958140', 'a', ['1.2.3.4'], + '--a-create-reverse' + ) + + # Add TXT record to reverse zone on same name as PTR + tasks.add_dns_record( + self.master, reverse_zone, '4', 'txt', ['text'] + ) + + # Enable PTR record synchronization + tasks.mod_dns_zone( + self.master, test_zone, '--dynamic-update=True' + ) + tasks.mod_dns_zone( + self.master, test_zone, '--allow-sync-ptr=True' + ) + tasks.mod_dns_zone( + self.master, reverse_zone, '--dynamic-update=True' + ) + + # Add host and get keytab for dynamic updates + self.master.run_command([ + 'ipa', 'host-add', hostname, '--force' + ]) + self.master.run_command([ + 'ipa-getkeytab', '-s', self.master.hostname, + '-p', f'host/{hostname}@{realm}', + '-k', '/tmp/bz958140.keytab' + ]) + + self.master.run_command(['rndc', 'reload']) + self.master.run_command(['ipactl', 'restart']) + + # Use nsupdate to delete A record (should trigger PTR sync) + nsupdate_content = NSUPDATE_DELETE_A_TEMPLATE.format( + hostname=hostname, ip='1.2.3.4' + ) + self.master.put_file_contents('/tmp/nsupdate958140.txt', + nsupdate_content) + self.master.run_command([ + 'kinit', '-k', '-t', '/tmp/bz958140.keytab', + f'host/{hostname}' + ]) + self.master.run_command([ + 'nsupdate', '-g', '-v', '/tmp/nsupdate958140.txt' + ], raiseonerr=False) + + tasks.kinit_admin(self.master) + + # Verify A record is deleted + result = tasks.find_dns_record( + self.master, test_zone, 'testbz958140', raiseonerr=False + ) + assert 'A record' not in result.stdout_text + + # Verify PTR record is deleted + result = self.master.run_command([ + 'ipa', 'dnsrecord-find', reverse_zone, '4' + ], raiseonerr=False) + assert 'PTR record' not in result.stdout_text + + # Verify TXT record is preserved + assert 'TXT record' in result.stdout_text + + finally: + tasks.kinit_admin(self.master) + self.master.run_command([ + 'ipa', 'host-del', hostname + ], raiseonerr=False) + tasks.del_dns_zone(self.master, test_zone, raiseonerr=False) + tasks.del_dns_zone(self.master, reverse_zone, raiseonerr=False) + self.master.run_command(['rm', '-f', '/tmp/bz958140.keytab', + '/tmp/nsupdate958140.txt'], + raiseonerr=False) + + def test_invalid_policy_disables_operations(self): + """Test invalid policy disables updates, transfers, and queries. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=958141 + + When zone policy (update, transfer, or query) contains invalid + values, the corresponding operation should be disabled/refused. + """ + tasks.kinit_admin(self.master) + test_zone = 'example.test' + realm = self.master.domain.realm + hostname = f'test.{test_zone}' + + try: + # Add test zone and get original update policy + tasks.add_dns_zone( + self.master, test_zone, + skip_overlap_check=True, + admin_email=f'hostmaster.{test_zone}' + ) + + # Get original update policy + result = self.master.run_command([ + 'ipa', 'dnszone-show', test_zone, '--all' + ]) + original_policy = None + for line in result.stdout_text.split('\n'): + if 'BIND update policy:' in line: + original_policy = line.split(':', 1)[1].strip() + break + + tasks.add_dns_record( + self.master, test_zone, 'test', 'a', ['1.2.3.4'] + ) + + # Test 1: Verify dynamic update works with valid policy + tasks.mod_dns_zone( + self.master, test_zone, '--dynamic-update=True' + ) + + self.master.run_command([ + 'ipa', 'host-add', hostname + ]) + self.master.run_command([ + 'ipa-getkeytab', '-s', self.master.hostname, + '-p', f'host/{hostname}@{realm}', + '-k', '/tmp/bz958141.keytab' + ]) + + self.master.run_command(['rndc', 'reload']) + + # Create nsupdate file to delete A record + nsupdate_content = NSUPDATE_DELETE_A_TEMPLATE.format( + hostname=hostname, ip='1.2.3.4' + ) + self.master.put_file_contents('/tmp/nsupdate958141.txt', + nsupdate_content) + + self.master.run_command([ + 'kinit', '-k', '-t', '/tmp/bz958141.keytab', + f'host/{hostname}' + ]) + + # Dynamic update should succeed with valid policy + result = self.master.run_command([ + 'nsupdate', '-g', '-v', '/tmp/nsupdate958141.txt' + ], raiseonerr=False) + assert result.returncode == 0, \ + 'Dynamic update should succeed with valid policy' + + # Set invalid update policy + tasks.kinit_admin(self.master) + tasks.mod_dns_zone( + self.master, test_zone, '--update-policy=invalid value' + ) + + # Re-add A record for next test + tasks.add_dns_record( + self.master, test_zone, 'test', 'a', ['1.2.3.4'] + ) + + # Dynamic update should fail with invalid policy + self.master.run_command([ + 'kinit', '-k', '-t', '/tmp/bz958141.keytab', + f'host/{hostname}' + ]) + result = self.master.run_command([ + 'nsupdate', '-g', '/tmp/nsupdate958141.txt' + ], raiseonerr=False) + assert result.returncode == 2, \ + 'Dynamic update should fail with invalid policy' + + # Restore original update policy + tasks.kinit_admin(self.master) + tasks.mod_dns_zone( + self.master, test_zone, + f'--update-policy={original_policy}' + ) + + # Test 2: Invalid transfer policy disables zone transfers + tasks.mod_dns_zone( + self.master, test_zone, '--allow-transfer=any' + ) + + # Verify zone transfer works with valid policy + result = self.master.run_command([ + 'dig', '@127.0.0.1', '-t', 'AXFR', test_zone + ], raiseonerr=False) + assert 'Transfer failed' not in result.stdout_text + + # Set invalid transfer policy via LDAP + ldap = self.master.ldap_connect() + zone_dn = DN( + ('idnsname', f'{test_zone}.'), + ('cn', 'dns'), + self.master.domain.basedn + ) + entry = ldap.get_entry(zone_dn) + entry['idnsAllowTransfer'] = ['192.0.2..0/24'] # Invalid + ldap.update_entry(entry) + + # Verify zone transfer fails with invalid policy + result = self.master.run_command([ + 'dig', '@127.0.0.1', '-t', 'AXFR', test_zone + ], raiseonerr=False) + assert 'Transfer failed' in result.stdout_text + + # Restore valid transfer policy + tasks.mod_dns_zone( + self.master, test_zone, '--allow-transfer=none' + ) + + # Test 3: Invalid query policy disables queries + # First verify query works with valid policy + result = self.master.run_command([ + 'dig', f'test.{test_zone}' + ], raiseonerr=False) + assert 'NOERROR' in result.stdout_text + + # Set invalid query policy via LDAP + entry = ldap.get_entry(zone_dn) + entry['idnsAllowQuery'] = ['192.0.2..0/24'] # Invalid + ldap.update_entry(entry) + + # Verify query is refused + result = self.master.run_command([ + 'dig', f'test.{test_zone}' + ], raiseonerr=False) + assert 'REFUSED' in result.stdout_text + + # Restore valid query policy + tasks.mod_dns_zone( + self.master, test_zone, '--allow-query=any' + ) + + finally: + tasks.kinit_admin(self.master) + self.master.run_command([ + 'ipa', 'host-del', hostname + ], raiseonerr=False) + tasks.del_dns_zone(self.master, test_zone, raiseonerr=False) + self.master.run_command([ + 'rm', '-f', '/tmp/bz958141.keytab', '/tmp/nsupdate958141.txt' + ], raiseonerr=False) + + def test_ptr_sync_ipv6(self): + """Test PTR record synchronization works with IPv6 addresses. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=962814 + + Verify that when AAAA record is added via dynamic update with + PTR sync enabled, the corresponding PTR record is created in + the IPv6 reverse zone. + """ + tasks.kinit_admin(self.master) + test_zone = "examplebz962814.com" + # Reverse zone for 1:2:3:4:5:6::/96 + reverse_zone = ( + "6.0.0.0.5.0.0.0.4.0.0.0.3.0.0.0.2.0.0.0.1.0.0.0.ip6.arpa." + ) + realm = self.master.domain.realm + hostname = f"test.{test_zone}" + + try: + # Add test zones + tasks.add_dns_zone( + self.master, test_zone, + skip_overlap_check=True, + admin_email=f"hostmaster.{test_zone}" + ) + tasks.add_dns_zone( + self.master, reverse_zone, + skip_overlap_check=True, + admin_email=f"hostmaster.{reverse_zone}" + ) + + # Enable PTR record synchronization + tasks.mod_dns_zone( + self.master, test_zone, '--dynamic-update=True' + ) + tasks.mod_dns_zone( + self.master, test_zone, '--allow-sync-ptr=True' + ) + tasks.mod_dns_zone( + self.master, reverse_zone, '--dynamic-update=True' + ) + + # Add host and get keytab + self.master.run_command([ + 'ipa', 'host-add', hostname, '--force' + ]) + self.master.run_command([ + 'ipa-getkeytab', '-s', self.master.hostname, + '-p', f'host/{hostname}@{realm}', + '-k', '/tmp/bz962814.keytab' + ]) + + self.master.run_command(['rndc', 'reload']) + + # Use nsupdate to add AAAA record + nsupdate_content = NSUPDATE_ADD_AAAA_TEMPLATE.format( + hostname=hostname, ttl=3600, ip='1:2:3:4:5:6:7:8' + ) + self.master.put_file_contents('/tmp/nsupdate962814.txt', + nsupdate_content) + self.master.run_command([ + 'kinit', '-k', '-t', '/tmp/bz962814.keytab', + f'host/{hostname}' + ]) + + time.sleep(60) + self.master.run_command([ + 'nsupdate', '-g', '/tmp/nsupdate962814.txt' + ]) + + tasks.kinit_admin(self.master) + + # Verify AAAA record was added + result = tasks.find_dns_record( + self.master, test_zone, 'test', raiseonerr=False + ) + assert 'AAAA record' in result.stdout_text + + # Verify PTR record was created in reverse zone + # PTR for 1:2:3:4:5:6:7:8 should be at 8.0.0.0.7.0.0.0 + result = self.master.run_command([ + 'ipa', 'dnsrecord-find', reverse_zone, '8.0.0.0.7.0.0.0' + ], raiseonerr=False) + assert 'PTR record' in result.stdout_text + + finally: + tasks.kinit_admin(self.master) + self.master.run_command([ + 'ipa', 'host-del', hostname + ], raiseonerr=False) + tasks.del_dns_zone(self.master, test_zone, raiseonerr=False) + tasks.del_dns_zone(self.master, reverse_zone, raiseonerr=False) + self.master.run_command(['rm', '-f', '/tmp/bz962814.keytab', + '/tmp/nsupdate962814.txt'], + raiseonerr=False) + + def test_ipv6_private_reverse_zone(self): + """Test serving reverse zones for IPv6 private ranges. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=962815 + + Verify that reverse zones for IPv6 private/documentation ranges + (like 2001:db8::/32) can be served without manual changes to + named.conf. These are in the automatic empty zones list. + """ + tasks.kinit_admin(self.master) + # 2001:db8::/32 documentation prefix reverse zone + reverse_zone = "8.b.d.0.1.0.0.2.ip6.arpa" + test_record = "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0" + test_ptr = "test.example.com" + test_ipv6 = "2001:0db8::1" + + try: + # Add reverse zone for IPv6 documentation prefix + tasks.add_dns_zone( + self.master, reverse_zone, + admin_email=f"hostmaster.{reverse_zone}" + ) + + # Add PTR record + tasks.add_dns_record( + self.master, f"{reverse_zone}.", test_record, + 'ptr', [test_ptr] + ) + + self.master.run_command(['ipactl', 'restart']) + + # Verify reverse lookup works + result = self.master.run_command([ + 'dig', '-x', test_ipv6 + ]) + assert test_record in result.stdout_text + assert test_ptr in result.stdout_text + + # Restart named and verify zone loads + tasks.restart_named(self.master) + time.sleep(5) + + # Check logs that zone was loaded without errors + result = self.master.run_command([ + 'journalctl', '-u', 'named', '-n', '100', '--no-pager' + ]) + # Verify zone is mentioned in logs (loaded) + assert reverse_zone in result.stdout_text, \ + f'Zone {reverse_zone} not found in named logs' + + finally: + tasks.del_dns_zone(self.master, reverse_zone, raiseonerr=False) + + def test_tld_with_numbers(self): + """Test DNS record allows top-level domains with numbers. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=907913 + + Verify that DNS zones and records can be created with TLDs + that contain numbers, and NS records can reference them. + """ + tasks.kinit_admin(self.master) + domain = self.master.domain.name + test_zone = "TBADTEST0" # TLD with number + test_record = "TBADTEST" + + try: + # Add zone with numeric TLD + tasks.add_dns_zone( + self.master, test_zone, + admin_email=f"hostmaster.{test_zone}" + ) + + # Add A record in the zone + tasks.add_dns_record( + self.master, test_zone, test_record, 'a', ['1.2.3.4'] + ) + + self.master.run_command(['ipactl', 'restart']) + + # Verify NS hostname with numeric TLD is valid + result = tasks.add_dns_record( + self.master, domain, test_record, 'ns', + [f'{test_record}.{test_zone}.'] + ) + assert result.returncode == 0 + + # Cleanup NS record + tasks.del_dns_record( + self.master, domain, test_record, + record_type='ns', + record_value=[f'{test_record}.{test_zone}.'] + ) + + finally: + tasks.del_dns_zone(self.master, test_zone, raiseonerr=False) + + def test_rfc2317_classless_arpa(self): + """Test dnszone-add supports RFC 2317 classless in-addr.arpa. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=1058688 + + Verify that DNS zones with RFC 2317 classless reverse delegation + format (like 0/27.10.10.in-addr.arpa) can be created. + """ + tasks.kinit_admin(self.master) + # RFC 2317 classless delegation format + test_zone = "0/27.10.10.in-addr.arpa." + + try: + # Add zone with RFC 2317 format + tasks.add_dns_zone( + self.master, test_zone, + skip_overlap_check=True, + admin_email="hostmaster.0.0.10.10.in-addr.arpa." + ) + + # Verify zone was created + result = tasks.find_dns_zone(self.master, test_zone) + assert result.returncode == 0 + assert test_zone in result.stdout_text + + finally: + tasks.del_dns_zone(self.master, test_zone, raiseonerr=False) + + def test_dnsrecord_mod_no_warning(self): + """Test dnsrecord-mod doesn't display API version warning. + + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=1054869 + + Verify that modifying DNS records doesn't show warning message + about API version. Also tests that setting txt-rec to empty + deletes the record. + """ + tasks.kinit_admin(self.master) + domain = self.master.domain.name + record_name = "bz1054869" + + try: + # Add TXT record + tasks.add_dns_record( + self.master, domain, record_name, 'txt', ['1054869'] + ) + + # Verify record was added + result = tasks.show_dns_record( + self.master, domain, record_name, '--all', '--raw' + ) + assert 'txtrecord: 1054869' in result.stdout_text.lower() + + # Modify record with empty txt-rec (deletes the record) + result = tasks.mod_dns_record( + self.master, domain, record_name, + '--txt-rec=' + ) + # Verify no API version warning + assert 'WARNING: API Version' not in result.stdout_text + + # Verify record was deleted + result = tasks.find_dns_record( + self.master, domain, record_name, raiseonerr=False + ) + assert result.returncode != 0 + + finally: + tasks.del_dns_record( + self.master, domain, record_name, + del_all=True, raiseonerr=False + ) + + def test_dnszone_disable_enable_and_system_records(self): + """Test dnszone-disable, dnszone-enable and dns-update-system-records. + + This test verifies that: + 1. A DNS zone can be disabled and enabled + 2. dns-update-system-records works correctly (dry-run and actual) + """ + tasks.kinit_admin(self.master) + test_zone = 'disabletest.test' + + try: + # Add test zone + tasks.add_dns_zone( + self.master, test_zone, + admin_email=f'hostmaster.{test_zone}' + ) + + result = self.master.run_command([ + 'ipa', 'dnszone-show', test_zone + ]) + assert 'Active zone: True' in result.stdout_text + + # Disable the zone + result = self.master.run_command([ + 'ipa', 'dnszone-disable', test_zone + ]) + assert f'Disabled DNS zone "{test_zone}."' in result.stdout_text + + result = self.master.run_command([ + 'ipa', 'dnszone-show', test_zone + ]) + assert 'Active zone: False' in result.stdout_text + + # Enable the zone + result = self.master.run_command([ + 'ipa', 'dnszone-enable', test_zone + ]) + assert f'Enabled DNS zone "{test_zone}."' in result.stdout_text + + result = self.master.run_command([ + 'ipa', 'dnszone-show', test_zone + ]) + assert 'Active zone: True' in result.stdout_text + + # Test dns-update-system-records --dry-run + result = self.master.run_command([ + 'ipa', 'dns-update-system-records', '--dry-run' + ]) + assert result.returncode == 0 + + result = self.master.run_command([ + 'ipa', 'dns-update-system-records' + ]) + assert result.returncode == 0 + + finally: + tasks.del_dns_zone(self.master, test_zone, raiseonerr=False) -- 2.53.0