cleanup-cpnr.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. #!/usr/bin/env python
  2. from elemental_utils import ElementalDns, ElementalNetbox
  3. from elemental_utils.cpnr.query import RequestError
  4. # from elemental_utils.cpnr.query import RequestError
  5. from elemental_utils import cpnr
  6. from utils import (
  7. launch_parallel_task,
  8. restart_dns_servers,
  9. get_reverse_zone,
  10. parse_txt_record,
  11. )
  12. from pynetbox.core.response import Record
  13. # from pynetbox.models.virtualization import VirtualMachines
  14. from colorama import Fore, Style
  15. from dataclasses import dataclass, field
  16. from threading import Lock
  17. import os
  18. import re
  19. from typing import List
  20. import CLEUCreds # type: ignore
  21. from cleu.config import Config as C # type: ignore
  22. # import ipaddress
  23. import logging.config
  24. import logging
  25. import argparse
  26. import sys
  27. # import json
  28. # import hvac
  29. logging.config.fileConfig(os.path.realpath(os.path.dirname(os.path.realpath(__file__)) + "/dns_logger.conf"))
  30. logger = logging.getLogger(__name__)
  31. # Bool to indicate whether or not DNS was modified.
  32. EDNS_MODIFIED = False
  33. @dataclass
  34. class DnsRecords:
  35. """Class for tracking DNS records to delete."""
  36. deletes: List[cpnr.models.model.Record] = field(default_factory=list)
  37. lock: Lock = Lock()
  38. def get_ptr_rrs(ips: list, edns: ElementalDns) -> List[cpnr.models.model.Record]:
  39. """Get a list of PTR records for a given set of IP addresses.
  40. Args:
  41. :ips list: The IP addresses to process
  42. :edns ElementalDns: ElementalDns object
  43. Returns:
  44. :list: List of RRSet records
  45. """
  46. result = []
  47. for addr in ips:
  48. rzone = get_reverse_zone(addr)
  49. ptr_name = addr.split(".")[::-1][0]
  50. ptr_rrs = edns.rrset.get(ptr_name, zoneOrigin=rzone)
  51. if ptr_rrs:
  52. result.append(ptr_rrs)
  53. return result
  54. def check_record(
  55. host: cpnr.models.model.Record,
  56. primary_domain: str,
  57. rrs: list,
  58. edns: ElementalDns,
  59. enb: ElementalNetbox,
  60. wip_records: DnsRecords,
  61. ) -> None:
  62. """Check if a host record is still valid.
  63. Args:
  64. :host Record: Host DNS record
  65. :primary_domain str: Primary domain name for the hosts
  66. :rrs list: List of all RRSets
  67. :edns ElementalDns: ElementalDns object
  68. :dac DAC: DNS As Code Object
  69. :enb ElementalNetbox: ElementalNetbox object
  70. :wip_records DnsRecords: DnsRecords object to hold the records to delete
  71. """
  72. # We do not want to operate on the domain itself or the DNS server A records.
  73. if f"{host.name}.{host.zoneOrigin}" == primary_domain or host.name in (
  74. "@",
  75. primary_domain,
  76. C.PRIMARY_DNS,
  77. C.SECONDARY_DNS,
  78. ):
  79. return
  80. # Get the RRSet for the host.
  81. host_rr = None
  82. for rr in rrs:
  83. if rr.name.lower() == host.name.lower():
  84. host_rr = rr
  85. break
  86. if not host_rr:
  87. logger.warning(f"🪲 Did not find an RRSet for {host.name}. This is definitely a bug!")
  88. return
  89. found_txt = None
  90. for rr in host_rr.rrList["CCMRRItem"]:
  91. # The re.search is to support DDNS entries.
  92. if rr["rrType"] == "TXT" and (
  93. rr["rdata"].startswith('"v=_netbox') or rr["rdata"].startswith('"v=_static') or re.search(r'^["0-9:]+$', rr["rdata"])
  94. ):
  95. found_txt = rr["rdata"]
  96. break
  97. wip_records.lock.acquire()
  98. if not found_txt:
  99. # No TXT record with NetBox data means this host record should be removed.
  100. wip_records.deletes.append(host_rr)
  101. # Also remove any PTR records.
  102. wip_records.deletes.extend(get_ptr_rrs(host.addrs["stringItem"], edns))
  103. elif found_txt.startswith('"v=_netbox'):
  104. txt_obj = parse_txt_record(found_txt)
  105. ip_obj = enb.ipam.ip_addresses.get(int(txt_obj["ip_id"]))
  106. if not ip_obj:
  107. # The IP object is gone, so remove this record.
  108. wip_records.deletes.append(host_rr)
  109. # Also remove the PTR record
  110. wip_records.deletes.extend(get_ptr_rrs(host.addrs["stringItem"], edns))
  111. elif txt_obj["type"] == "device" or txt_obj["type"] == "vm":
  112. # The IP object exists, so check the assigned object to make sure it hasn't been
  113. # renamed.
  114. nb_obj = None
  115. if txt_obj["type"] == "device":
  116. nb_obj = enb.dcim.devices.get(int(txt_obj["id"]))
  117. else:
  118. nb_obj = enb.virtualization.virtual_machines.get(int(txt_obj["id"]))
  119. if not nb_obj or (nb_obj.name.lower() != host_rr.name.lower() and host_rr.name.lower() != ip_obj.dns_name.lower()):
  120. wip_records.deletes.append(host_rr)
  121. wip_records.deletes.extend(get_ptr_rrs(host.addrs["stringItem"], edns))
  122. wip_records.lock.release()
  123. def check_cname(
  124. rrs: cpnr.models.model.Record,
  125. primary_domain: str,
  126. edns: ElementalDns,
  127. wip_records: DnsRecords,
  128. ) -> None:
  129. """Check if a CNAME record is still valid.
  130. Args:
  131. :host Record: Host DNS record
  132. :primary_domain str: Primary domain name for the hosts
  133. :rrs list: List of all RRSets
  134. :edns ElementalDns: ElementalDns object
  135. :dac DAC: DNS As Code object
  136. :enb ElementalNetbox: ElementalNetbox object
  137. :wip_records DnsRecords: DnsRecords object to hold the records to delete
  138. """
  139. found_host = False
  140. for rr in rrs.rrList["CCMRRItem"]:
  141. if rr["rrType"] == "CNAME":
  142. found_host = rr["rdata"]
  143. break
  144. if not found_host:
  145. # This is not a CNAME, so skip it.
  146. return
  147. # Lookup the CNAME target to make sure it's still in DNS.
  148. domain_parts = found_host.split(".")
  149. host = domain_parts[0]
  150. if len(domain_parts) == 1:
  151. zone = primary_domain
  152. else:
  153. zone = ".".join(domain_parts[1:])
  154. host_obj = edns.host.get(host, zoneOrigin=zone)
  155. if not host_obj:
  156. # The host that this CNAME points to is gone, so delete the CNAME.
  157. wip_records.lock.acquire()
  158. wip_records.deletes.append(rrs)
  159. wip_records.lock.release()
  160. def delete_record(cpnr_record: cpnr.models.model.Record, primary_domain: str, edns: ElementalDns) -> None:
  161. """Delete a record from CPNR.
  162. Args:
  163. :cpnr_record Record: CPNR record to delete
  164. :primary_domain str: Primary DNS domain
  165. :edns ElementalDns: ElementalDns object to use
  166. """
  167. global EDNS_MODIFIED
  168. name = cpnr_record.name
  169. domain = cpnr_record.zoneOrigin
  170. try:
  171. cpnr_record.delete()
  172. except RequestError as e:
  173. if e.req.status_code != 404:
  174. # We may end up deleting the same record twice.
  175. # If it's already gone, don't complain.
  176. raise
  177. else:
  178. logger.info(f"🧼 Successfully deleted record {name}.{domain}")
  179. EDNS_MODIFIED = True
  180. def print_records(wip_records: DnsRecords, tenant: Record) -> None:
  181. """Print the records to be processed.
  182. Args:
  183. :wip_records DnsRecords: DnsRecords object containing the records to process
  184. :tenant Record: A NetBox Tenant for which this DNS record applies
  185. """
  186. print(f"DNS records to be deleted for tenant {tenant.name} ({len(wip_records.deletes)} records):")
  187. for rec in wip_records.deletes:
  188. print(f"\t{Fore.RED}DELETE{Style.RESET_ALL} {rec.name}.{rec.zoneOrigin}")
  189. def parse_args() -> object:
  190. """Parse any command line arguments.
  191. Returns:
  192. :object: Object representing the arguments passed
  193. """
  194. parser = argparse.ArgumentParser(prog=sys.argv[0], description="Cleanup stale DNS records in CPNR")
  195. parser.add_argument(
  196. "--site",
  197. metavar="<SITE>",
  198. help="Site to cleanup",
  199. required=False,
  200. )
  201. parser.add_argument(
  202. "--tenant",
  203. metavar="<TENANT>",
  204. help="Tenant to cleanup",
  205. required=False,
  206. )
  207. parser.add_argument(
  208. "--dry-run",
  209. action="store_true",
  210. help="Do a dry-run (no changes made)",
  211. required=False,
  212. )
  213. parser.add_argument(
  214. "--dummy", metavar="<DUMMY SERVER>", help="Override main DNS server with a dummy server (only used with --tenant", required=False
  215. )
  216. args = parser.parse_args()
  217. if args.site and args.tenant:
  218. print("Only one of --site or --tenant can be given")
  219. exit(1)
  220. if not args.site and not args.tenant:
  221. print("One of --site or --tenant must be provided")
  222. exit(1)
  223. if args.dummy and not args.tenant:
  224. print("--dummy requires --tenant")
  225. exit(1)
  226. return args
  227. def main():
  228. os.environ["NETBOX_ADDRESS"] = C.NETBOX_SERVER
  229. os.environ["NETBOX_API_TOKEN"] = CLEUCreds.NETBOX_API_TOKEN
  230. os.environ["CPNR_USERNAME"] = CLEUCreds.CPNR_USERNAME
  231. os.environ["CPNR_PASSWORD"] = CLEUCreds.CPNR_PASSWORD
  232. args = parse_args()
  233. if args.site:
  234. lower_site = args.site.lower()
  235. if args.tenant:
  236. lower_tenant = args.tenant.lower()
  237. enb = ElementalNetbox()
  238. # 1. Get a list of all tenants. If we work tenant-by-tenant, we will likely remain connected
  239. # to the same DNS server.
  240. tenants = enb.tenancy.tenants.all()
  241. for tenant in tenants:
  242. if args.site and str(tenant.group.parent).lower() != lower_site:
  243. continue
  244. if args.tenant and tenant.name.lower() != lower_tenant:
  245. continue
  246. primary_domain = C.DNS_DOMAIN + "."
  247. edns = ElementalDns(url=f"https://{C.DNS_SERVER}:8443/")
  248. ecdnses = C.CDNS_SERVERS
  249. # 2. Get all host records then all RRSets from CPNR
  250. hosts = edns.host.all(zoneOrigin=primary_domain)
  251. if len(hosts) == 0:
  252. continue
  253. rrs = edns.rrset.all(zoneOrigin=primary_domain)
  254. wip_records = DnsRecords()
  255. # 3. Use thread pools to obtain a list of records to delete.
  256. launch_parallel_task(check_record, "check DNS record(s)", hosts, "name", 20, False, primary_domain, rrs, edns, enb, wip_records)
  257. # 4. Iterate through the RRs looking for stale CNAMEs
  258. launch_parallel_task(check_cname, "check for stale CNAMEs", rrs, "name", 20, False, primary_domain, edns, wip_records)
  259. # 5. If doing a dry-run, only print out the changes.
  260. if args.dry_run:
  261. print_records(wip_records, tenant)
  262. continue
  263. # 6. Process records to be deleted first. Use thread pools again to parallelize this.
  264. launch_parallel_task(delete_record, "delete DNS record", wip_records.deletes, "name", 20, False, primary_domain, edns)
  265. # 7. Restart affected DNS servers.
  266. if not args.dry_run:
  267. if EDNS_MODIFIED:
  268. # Technically nothing is modified in dry-run, but just to be safe.
  269. restart_dns_servers(edns, ecdnses)
  270. if __name__ == "__main__":
  271. main()