1 # Copyright (C) Internet Systems Consortium, Inc. ("ISC") 2 # 3 # SPDX-License-Identifier: MPL-2.0 4 # 5 # This Source Code Form is subject to the terms of the Mozilla Public 6 # License, v. 2.0. If a copy of the MPL was not distributed with this 7 # file, you can obtain one at https://mozilla.org/MPL/2.0/. 8 # 9 # See the COPYRIGHT file distributed with this work for additional 10 # information regarding copyright ownership. 11 12 from collections.abc import Iterable 13 14 from dns.name import Name 15 16 import dns.name 17 import dns.rdatatype 18 import dns.zone 19 20 21 def prepend_label(label: str, name: Name) -> Name: 22 return Name((label,) + name.labels) 23 24 25 def len_wire_uncompressed(name: Name) -> int: 26 return len(name) + sum(map(len, name.labels)) 27 28 29 def get_wildcard_names(names: Iterable[Name]) -> frozenset[Name]: 30 return frozenset(name for name in names if name.is_wild()) 31 32 33 class ZoneAnalyzer: 34 """ 35 Categorize names in zone and provide list of ENTs: 36 37 - delegations - names with NS RR 38 - dnames - names with DNAME RR 39 - wildcards - names with leftmost label '*' 40 - reachable - non-empty authoritative nodes in zone 41 - have at least one auth RR set and are not occluded 42 - ents - reachable empty non-terminals 43 - occluded - names under a parent node which has DNAME or a non-apex NS 44 - reachable_delegations 45 - have NS RR on it, are not zone's apex, and are not occluded 46 - reachable_dnames - have DNAME RR on it and are not occluded 47 - reachable_wildcards - have leftmost label '*' and are not occluded 48 - reachable_wildcard_parents - reachable_wildcards with leftmost '*' stripped 49 50 Warnings: 51 - Quadratic complexity ahead! Use only on small test zones. 52 - Zone must be constant. 53 """ 54 55 @classmethod 56 def read_path(cls, zpath, origin): 57 with open(zpath, encoding="ascii") as zf: 58 zonedb = dns.zone.from_file(zf, origin, relativize=False) 59 return cls(zonedb) 60 61 def __init__(self, zone: dns.zone.Zone): 62 self.zone = zone 63 assert self.zone.origin # mypy hack 64 # based on individual nodes but not relationship between nodes 65 self.delegations = self.get_names_with_type(dns.rdatatype.NS) - { 66 self.zone.origin 67 } 68 self.dnames = self.get_names_with_type(dns.rdatatype.DNAME) 69 self.wildcards = get_wildcard_names(self.zone) 70 71 # takes relationship between nodes into account 72 self._categorize_names() 73 self.ents = self.generate_ents() 74 self.reachable_dnames = self.dnames.intersection(self.reachable) 75 self.reachable_wildcards = self.wildcards.intersection(self.reachable) 76 self.reachable_wildcard_parents = { 77 Name(wname[1:]) for wname in self.reachable_wildcards 78 } 79 80 # (except for wildcard expansions) all names in zone which result in NOERROR answers 81 self.all_existing_names = ( 82 self.reachable.union(self.ents) 83 .union(self.reachable_delegations) 84 .union(self.reachable_dnames) 85 ) 86 87 def get_names_with_type(self, rdtype) -> frozenset[Name]: 88 return frozenset( 89 name for name in self.zone if self.zone.get_rdataset(name, rdtype) 90 ) 91 92 def _categorize_names( 93 self, 94 ) -> None: 95 """ 96 Split names defined in a zone into three sets: 97 Generally reachable, reachable delegations, and occluded. 98 99 Delegations are set aside because they are a weird hybrid with different 100 rules for different RR types (NS, DS, NSEC, everything else). 101 """ 102 assert self.zone.origin # mypy workaround 103 reachable = set(self.zone) 104 # assume everything is reachable until proven otherwise 105 reachable_delegations = set(self.delegations) 106 occluded = set() 107 108 def _mark_occluded(name: Name) -> None: 109 occluded.add(name) 110 if name in reachable: 111 reachable.remove(name) 112 if name in reachable_delegations: 113 reachable_delegations.remove(name) 114 115 # sanity check, should be impossible with dnspython 2.7.0 zone reader 116 for name in reachable: 117 relation, _, _ = name.fullcompare(self.zone.origin) 118 if relation in ( 119 dns.name.NameRelation.NONE, # out of zone? 120 dns.name.NameRelation.SUPERDOMAIN, # parent of apex?! 121 ): 122 raise NotImplementedError 123 124 for maybe_occluded in reachable.copy(): 125 for cut in self.delegations: 126 rel, _, _ = maybe_occluded.fullcompare(cut) 127 if rel == dns.name.NameRelation.EQUAL: 128 # data _on_ a parent-side of a zone cut are in limbo: 129 # - are not really authoritative (except for DS) 130 # - but NS is not really 'occluded' 131 # We remove them from 'reachable' but do not add them to 'occluded' set, 132 # i.e. leave them in 'reachable_delegations'. 133 if maybe_occluded in reachable: 134 reachable.remove(maybe_occluded) 135 136 if rel == dns.name.NameRelation.SUBDOMAIN: 137 _mark_occluded(maybe_occluded) 138 # do not break cycle - handle also nested NS and DNAME 139 140 # DNAME itself is authoritative but nothing under it is reachable 141 for dname in self.dnames: 142 rel, _, _ = maybe_occluded.fullcompare(dname) 143 if rel == dns.name.NameRelation.SUBDOMAIN: 144 _mark_occluded(maybe_occluded) 145 # do not break cycle - handle also nested NS and DNAME 146 147 self.reachable = frozenset(reachable) 148 self.reachable_delegations = frozenset(reachable_delegations) 149 self.occluded = frozenset(occluded) 150 151 def generate_ents(self) -> frozenset[Name]: 152 """ 153 Generate reachable names of empty nodes "between" all reachable 154 names with a RR and the origin. 155 """ 156 assert self.zone.origin 157 all_reachable_names = self.reachable.union(self.reachable_delegations) 158 ents = set() 159 for name in all_reachable_names: 160 _, super_name = name.split(len(name) - 1) 161 while len(super_name) > len(self.zone.origin): 162 if super_name not in all_reachable_names: 163 ents.add(super_name) 164 _, super_name = super_name.split(len(super_name) - 1) 165 166 return frozenset(ents) 167 168 def closest_encloser(self, qname: Name): 169 """ 170 Get (closest encloser, next closer name) for given qname. 171 """ 172 ce = None # Closest encloser, RFC 4592 173 nce = None # Next closer name, RFC 5155 174 for zname in self.all_existing_names: 175 relation, _, common_labels = qname.fullcompare(zname) 176 if relation == dns.name.NameRelation.SUBDOMAIN: 177 if not ce or common_labels > len(ce): 178 # longest match so far 179 ce = zname 180 _, nce = qname.split(len(ce) + 1) 181 assert ce is not None 182 assert nce is not None 183 return ce, nce 184 185 def source_of_synthesis(self, qname: Name) -> Name: 186 """ 187 Return source of synthesis according to RFC 4592 section 3.3.1. 188 Name is not guaranteed to exist or be reachable. 189 """ 190 ce, _ = self.closest_encloser(qname) 191 return Name("*") + ce 192