diff --git a/requirements.txt b/requirements.txt index 8f10cb4e..6933c679 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ jc pytest python-ldap -pytest-mh >= 1.0.29 +pytest-mh diff --git a/sssd_test_framework/roles/ad.py b/sssd_test_framework/roles/ad.py index fb87b155..41c06791 100644 --- a/sssd_test_framework/roles/ad.py +++ b/sssd_test_framework/roles/ad.py @@ -18,7 +18,6 @@ attrs_include_value, attrs_parse, attrs_to_hash, - ip_to_ptr, ip_version, seconds_to_timespan, ) @@ -2416,10 +2415,11 @@ def list_zones(self) -> list[str]: :return: List of zones. :rtype: list[str] """ - result = self.host.conn.run("Get-DnsServerZone | Format-List -Property ZoneName").stdout_lines - result = [x for x in result if x not in ["\r", "", None]] - result = [y.replace("\r", "").strip() for y in result] - result = [z.split(":")[1].strip() for z in result] + result = self.host.conn.run( + "Get-DnsServerZone | Format-List -Property ZoneName", unified_newlines=True + ).stdout_lines + result = [x for x in result if x not in ["", None]] + result = [y.split(":")[1].strip() for y in result] return result @@ -2492,41 +2492,57 @@ def add_record(self, name: str, data: str | int) -> ADDNSZone: def delete_record(self, name: str) -> None: """ - Delete DNS record, both forward and reverse records are deleted. + Delete all records for provided dns entry. + + The complexity is because the method takes one parameter. We need to discover the correct + value, cmdlet, protocol, and zone to properly delete the entry. + + If dig returns a result, iterate through the zones, if the zone is in the name, it's a forward record. + The return may be from the cache, search the zone for the record, if found, delete it. If "in-addr" is + in the zone name, it's a reverse record that follows the same steps as a forward record. :param name: Name or IP of the record. :type name: str """ if self.domain not in name: name = f"{name}.{self.domain}" + short_name = name.split(".")[0] - records = self.host.conn.run(f"dig +short +norecurse {name} '@{self.server}'").stdout_lines - records = [s.rstrip("\r") for s in records] - - if not isinstance(records, list) or records is None: - return None - - if len(records) > 1: - for record in records: - if ip_version(record) == 4: - self.role.host.conn.run( - f"Remove-DnsServerResourceRecord -RRType A -Force -ZoneName {self.zone_name} -Name {name}" - ) - if ip_version(record) == 6: - self.host.conn.run( - f"Remove-DnsServerResourceRecord -RRType AAAA -Force -ZoneName {self.zone_name} -Name {name}" - ) - - for ptr_records in records: - ptr_record = self.host.conn.run(f"dig +short -x +norecurse {ptr_records} '@{self.server}'").stdout_lines - ptr_record = [r.rstrip("\r") for r in ptr_record] - if ptr_record: - self.host.conn.run( - f"Remove-DnsServerResourceRecord -RRType PTR " - f"-Force -ZoneName {ip_to_ptr(ptr_record[0])} " - f"-Name {'.'.join(ptr_record).split()[-1]}", - ) - return None + records = self.host.conn.run( + f"dig +short +norecurse {name} '@{self.server}'", unified_newlines=True + ).stdout_lines + zones = self.list_zones() + + if records is not None: + for zone in zones: + if zone in name: + a_records = self.host.conn.run( + f'Get-DnsServerResourceRecord -ZoneName "{zone}" | Where-Object ' + f'{{ $_.HostName -eq "{short_name}" }}', + unified_newlines=True, + ).stdout_lines[3:] + if a_records is not None: + for j in a_records: + if ip_version(j.split()[-1].strip()) == 4: + self.host.conn.run( + f"Remove-DnsServerResourceRecord -ZoneName {zone} " + f"-Name {short_name} -RRType A -Force" + ) + elif ip_version(j.split()[-1].strip()) == 6: + self.host.conn.run( + f"Remove-DnsServerResourceRecord -ZoneName {zone} " + f"-Name {short_name} -RRType AAAA -Force" + ) + if "in-addr" in zone: + ptr_record = self.host.conn.run( + f'Get-DnsServerResourceRecord -ZoneName "{zone}" | Where-Object ' + f'{{ $_.RecordData.PtrDomainName -eq "{name}." }}' + ).stdout_lines + if len(ptr_record) == 1: + ptr_name = ptr_record[0].split()[0].strip() + self.host.conn.run( + f"Remove-DnsServerResourceRecord -ZoneName {zone} " f"-Name {ptr_name}. -RRType PTR -Force" + ) def print(self) -> str: """ diff --git a/sssd_test_framework/roles/ipa.py b/sssd_test_framework/roles/ipa.py index 5064d69f..484341d6 100644 --- a/sssd_test_framework/roles/ipa.py +++ b/sssd_test_framework/roles/ipa.py @@ -3089,7 +3089,10 @@ def add_record(self, name: str, data: str | int) -> IPADNSZone: def delete_record(self, name: str) -> None: """ - Delete DNS record, both forward and reverse records are deleted. + Delete all A/AAAA/PTR/NSPTR records for provided record. + + The complexity is because the method takes one parameter and not specifying the record type will + remove the entire entry. Instead the record type and data is specified to preserve sshfp records. Implements :meth:`GenericDNSZone.delete_record`. @@ -3098,28 +3101,58 @@ def delete_record(self, name: str) -> None: """ if self.domain not in name: name = f"{name}.{self.domain}" - - records = self.host.conn.run(f"dig +short +norecurse {name} '@{self.server}'").stdout_lines - records = [s.rstrip("\r") for s in records] - - if not isinstance(records, list) or records is None: - return None - - if len(records) > 1: - for record in records: - if ip_version(record) == 4: - self.host.conn.run(f"ipa dnsrecord-del {self.zone_name} --a-rec={record}") - if ip_version(record) == 6: - self.host.conn.run(f"ipa dnsrecord-del {self.zone_name} --aaaa-rec={record}") - - for ptr_records in records: - ptr_record = self.host.conn.run(f"dig +short -x +norecurse {ptr_records} '@{self.server}'").stdout_lines - ptr_record = [r.rstrip("\r") for r in ptr_record] - if ptr_record: - self.host.conn.run(f"ipa dnsrecord-del {self.zone_name} --ptr-rec={record}") - return None - - time.sleep(5) # Wait for the record to be deleted + short_name = name.split(".")[0].strip() + zones = self.list_zones() + + for zone in zones: + zone_records = self.host.conn.run(f"ipa dnsrecord-find {zone}").stdout_lines + in_section: bool = False + + for i, line in enumerate(zone_records): + _line = line.strip() + + if not _line: + if in_section: + break + continue + + if _line.startswith("Record name:"): + current = _line.split(":", 1)[1].strip() + in_section = current == short_name or current == f"{name}." + continue + + if in_section: + if _line.startswith("AAAA record:"): + ip = _line.split(":", 1)[1].strip() + _ip = [x.strip() for x in ip.split(",")] if "," in ip else [ip.strip()] + for y in _ip: + self.host.conn.run( + f"ipa dnsrecord-del {zone} {short_name} " f"--aaaa-rec={y}", + ) + + elif _line.startswith("A record:"): + ip = _line.split(":", 1)[1].strip() + _ip = [x.strip() for x in ip.split(",")] if "," in ip else [ip.strip()] + for y in _ip: + self.host.conn.run( + f"ipa dnsrecord-del {zone} {short_name} " f"--a-rec={y}", + ) + elif _line.startswith("PTR record:"): + if i > 0: + prev = zone_records[i - 1].strip() + if prev.startswith("Record name:"): + ptr = prev.split(":", 1)[1].strip() + self.host.conn.run( + f"ipa dnsrecord-del {self.zone_name} {zone} --ptr-rec={ptr} {name}." + ) + elif _line.startswith("NSPTR record:"): + if i > 0: + prev = zone_records[i - 1].strip() + if prev.startswith("Record name:"): + ptr = prev.split(":", 1)[1].strip() + self.host.conn.run( + f"ipa dnsrecord-del {self.zone_name} {zone} --naptr-rec={ptr} {name}." + ) def print(self) -> str: """ diff --git a/sssd_test_framework/roles/samba.py b/sssd_test_framework/roles/samba.py index 1647e957..4995760f 100644 --- a/sssd_test_framework/roles/samba.py +++ b/sssd_test_framework/roles/samba.py @@ -12,7 +12,7 @@ from pytest_mh.conn import ProcessResult from ..hosts.samba import SambaHost -from ..misc import attrs_parse, ip_to_ptr, ip_version, to_list_of_strings +from ..misc import attrs_parse, ip_version, to_list_of_strings from ..utils.ldap import LDAPRecordAttributes from .base import BaseLinuxLDAPRole, BaseObject, DeleteAttribute from .generic import ( @@ -1635,40 +1635,66 @@ def delete_record(self, name: str) -> None: """ Delete DNS record, both forward and reverse records are deleted. - Implements :meth:`GenericDNSZone.delete_record`. + The complexity is because the method takes one parameter. We need to discover the correct + values and type to properly delete the entry. + + The samba-tool output returns multiple lines per record. Two examples, + + Name=client, Records=1, Children=0 + A: 172.16.100.40 (flags=f0, serial=110, ttl=3600) + + Name=50, Records=1, Children=0 + PTR: client.samba.test (flags=f0, serial=2, ttl=900) + + Implements: meth:`GenericDNSZone.delete_record`. :param name: Name of the record. :type name: str """ if self.domain not in name: name = f"{name}.{self.domain}" + short_name = name.split(".")[0] records = self.host.conn.run(f"dig +short +norecurse {name} '@{self.server}'").stdout_lines - records = [s.rstrip("\r") for s in records] - - if not isinstance(records, list) or records is None: - return None - - if len(records) > 1: - for record in records: - if ip_version(record) == 4: - self.role.host.conn.run( - f"samba-tool dns delete {self.server} {self.zone_name} {name} A {record} {self.credentials}" - ) - if ip_version(record) == 6: - self.host.conn.run( - f"samba-tool dns delete {self.server} {self.zone_name} {name} AAAA {record} {self.credentials}" - ) - - for ptr_records in records: - ptr_record = self.host.conn.run(f"dig +short -x +norecurse {ptr_records} '@{self.server}'").stdout_lines - ptr_record = [r.rstrip("\r") for r in ptr_record] - if ptr_record: - self.host.conn.run( - f"samba-tool dns delete {self.server} {ip_to_ptr(ptr_record[0])} {name} " - f"PTR {ptr_record} {self.credentials}" - ) - return None + zones = self.list_zones() + + if records is not None: + for zone in zones: + if zone in name: + a_record = self.host.conn.run( + f"samba-tool dns query {self.server} {zone} @ A {self.credentials}", + ).stdout_lines + for i in a_record: + if f"Name={short_name}," in i: + ip = a_record[a_record.index(i) + 1].strip().split()[1].strip() + if ip_version(ip) == 4: + self.host.conn.run( + f"samba-tool dns delete {self.server} {zone} " + f"{short_name} A {ip} {self.credentials}", + ) + elif ip_version(ip) == 6: + self.host.conn.run( + f"samba-tool dns delete {self.server} {zone} " + f"{short_name} AAAA {ip} {self.credentials}", + ) + if "in-addr" in zone: + ptr_record = self.host.conn.run( + f"samba-tool dns query {self.server} {zone} @ PTR {self.credentials}", + ).stdout_lines + for j in ptr_record: + if name in j: + _name = ( + ptr_record[ptr_record.index(j) - 1] + .strip() + .split()[0] + .strip() + .split("=")[-1] + .rstrip(",") + ) + self.host.conn.run( + f"samba-tool dns delete {self.server} {zone} " + f"{_name} PTR {name} {self.credentials}", + ) def print(self) -> str: """ diff --git a/sssd_test_framework/topology_controllers.py b/sssd_test_framework/topology_controllers.py index 31105944..b02fdb45 100644 --- a/sssd_test_framework/topology_controllers.py +++ b/sssd_test_framework/topology_controllers.py @@ -233,6 +233,8 @@ class ADTopologyController(ProvisionedBackupTopologyController): def topology_setup(self, client: ClientHost, provider: ADHost | SambaHost) -> None: short_hostname = client.conn.run("hostname").stdout.split(".")[0].strip() hostname = f"{short_hostname}.{provider.domain}" + client.fs.backup("/etc/hostname") + client.conn.run(f"echo {hostname} > /etc/hostname") # Change client hostname to match the domain self.logger.info(f"Changing hostname to {hostname}") @@ -272,6 +274,8 @@ class IPATrustADTopologyController(ProvisionedBackupTopologyController): def topology_setup(self, client: ClientHost, ipa: IPAHost, trusted: ADHost | SambaHost) -> None: short_hostname = client.conn.run("hostname").stdout.split(".")[0].strip() hostname = f"{short_hostname}.{ipa.domain}" + client.fs.backup("/etc/hostname") + client.conn.run(f"echo {hostname} > /etc/hostname") # Change client hostname to match the domain self.logger.info(f"Changing hostname to {hostname}") diff --git a/sssd_test_framework/utils/network.py b/sssd_test_framework/utils/network.py index 1d40d041..dcc9058e 100644 --- a/sssd_test_framework/utils/network.py +++ b/sssd_test_framework/utils/network.py @@ -2,6 +2,7 @@ from __future__ import annotations +import time from typing import Any import jc @@ -77,7 +78,7 @@ def tshark(self, args: list[Any] | None = None) -> ProcessResult: return self.host.conn.exec(["tshark", *args]) - def dig(self, address: str, server: str | None = None) -> list[dict] | None: + def dig(self, address: str, server: str | None = None, attempts: int = 30, delay: int = 2) -> list[dict] | None: """ Execute and parse dig command. @@ -99,50 +100,59 @@ def dig(self, address: str, server: str | None = None) -> list[dict] | None: :type address: str :param server: DNS server, optional, defaults to "" :type server: str = "" + :param attempts: Number of attempts, defaults to 5 + :type attempts: int + :param delay: Delay between attempts, defaults to 3 + :type delay: int :return: List of dig results. :rtype: list[dict] """ server = f"@{server}" if server else "" args = f"+norecurse {'-x ' if ip_is_valid(address) else ''}{address} {server}" - try: - output = self.host.conn.run(f"dig {args}").stdout - parsed_output = jc.parse("dig", output) - except Exception: - return None - - if not isinstance(parsed_output, list) or not parsed_output: - return None - - result = parsed_output[0].get("answer", []) - if not isinstance(result, list) or not result: - return None - - required_keys = {"name", "type", "ttl", "data"} - records = [] - - for record in result: - if not isinstance(record, dict): - continue - if not required_keys.issubset(record.keys()): - continue - if not isinstance(record["ttl"], int) or record["ttl"] < 0: - continue - - # Strip trailing dots for easier matching - _name = record["name"].rstrip(".") if isinstance(record["name"], str) else record["name"] - _data = record["data"].rstrip(".") if isinstance(record["data"], str) else record["data"] - - records.append( - { - "name": _name, - "data": _data, - "type": record["type"], - "ttl": record["ttl"], - } - ) - - return records if records else None + attempt = 0 + while True: + try: + output = self.host.conn.run(f"dig {args}").stdout + parsed_output = jc.parse("dig", output) + except Exception: + parsed_output = None + + if isinstance(parsed_output, list) and parsed_output: + result = parsed_output[0].get("answer", []) + if isinstance(result, list) and result: + required_keys = {"name", "type", "ttl", "data"} + records = [] + + for record in result: + if not isinstance(record, dict): + continue + if not required_keys.issubset(record.keys()): + continue + if not isinstance(record["ttl"], int) or record["ttl"] < 0: + continue + + # Strip trailing dots for easier matching + _name = record["name"].rstrip(".") if isinstance(record["name"], str) else record["name"] + _data = record["data"].rstrip(".") if isinstance(record["data"], str) else record["data"] + + records.append( + { + "name": _name, + "data": _data, + "type": record["type"], + "ttl": record["ttl"], + } + ) + + if records: + return records + + attempt += 1 + if attempt >= attempts: + return None + + time.sleep(delay) def teardown(self): """ @@ -175,9 +185,9 @@ class IPUtils(MultihostUtility[MultihostHost]): :caption: Example usage # Create dummy interfaces - client.net.ip(name="dummy0").add_device(ip="172.16.2.40", netmask="255.255.255.0") - client.net.ip(name="dummy0").add_device(ip="172.16.2.40", netmask="24") client.net.ip(name="dummy0").add_device(ip="172.16.2.40") + client.net.ip(name="dummy0").add_device(ip="2001:db8::1") + client.net.ip(name="dummy0").add_device(ip=["172.16.2.40", "2001:db8::1"]) # Get default device default_device = client.net.ip().default_device @@ -294,20 +304,29 @@ def netmask(self) -> str | None: netmask = self._get(["ipv4_mask"]).get("ipv4_mask") return netmask if netmask is not None else None - def add_device(self, ip: str, netmask: str = "255.255.255.0") -> IPUtils: + def add_device(self, ip: str | list[str]) -> IPUtils: """ Add and create a link to a dummy device.This is used by dyndns tests. - :param ip: IP address. - :type ip: str - :param netmask: IP network mask, defaults to 255.255.255.0 - :type netmask: str, optional + :param ip: IP address or list of IP addresses. + :type ip: str | list[str] :return: IPUtils object. :rtype: IPUtils """ if self.name != self.default_device or self.name is not None: self.host.conn.exec(["ip", "link", "add", self.name, "type", "dummy"]) - self.host.conn.exec(["ip", "addr", "add", f"{ip}/{netmask}", "dev", self.name]) + + addresses = ip if isinstance(ip, list) else [ip] + for address in addresses: + if "/" in address: + _address = address + elif ":" in address: + _address = f"{address}/64" + else: + _address = f"{address}/24" + + self.host.conn.exec(["ip", "addr", "add", _address, "dev", self.name]) + self.__rollback.append(f"ip link del {self.name}") return self else: