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 typing import Iterable, FrozenSet 13 14 import dns.name 15 import dns.zone 16 import dns.rdatatype 17 18 from dns.name import Name 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._abort_on_old_dnspython() 63 self.zone = zone 64 assert self.zone.origin # mypy hack 65 # based on individual nodes but not relationship between nodes 66 self.delegations = self.get_names_with_type(dns.rdatatype.NS) - { 67 self.zone.origin 68 } 69 self.dnames = self.get_names_with_type(dns.rdatatype.DNAME) 70 self.wildcards = get_wildcard_names(self.zone) 71 72 # takes relationship between nodes into account 73 self._categorize_names() 74 self.ents = self.generate_ents() 75 self.reachable_dnames = self.dnames.intersection(self.reachable) 76 self.reachable_wildcards = self.wildcards.intersection(self.reachable) 77 self.reachable_wildcard_parents = { 78 Name(wname[1:]) for wname in self.reachable_wildcards 79 } 80 81 # (except for wildcard expansions) all names in zone which result in NOERROR answers 82 self.all_existing_names = ( 83 self.reachable.union(self.ents) 84 .union(self.reachable_delegations) 85 .union(self.reachable_dnames) 86 ) 87 88 def _abort_on_old_dnspython(self): 89 if not hasattr(dns.name, "NameRelation"): 90 raise RuntimeError( 91 "ZoneAnalyzer requires dnspython>=2.3.0 for dns.name.NameRelation support. " 92 "Use pytest.importorskip('dns', minversion='2.3.0') to the test module to " 93 "skip this test." 94 ) 95 96 def get_names_with_type(self, rdtype) -> FrozenSet[Name]: 97 return frozenset( 98 name for name in self.zone if self.zone.get_rdataset(name, rdtype) 99 ) 100 101 def _categorize_names( 102 self, 103 ) -> None: 104 """ 105 Split names defined in a zone into three sets: 106 Generally reachable, reachable delegations, and occluded. 107 108 Delegations are set aside because they are a weird hybrid with different 109 rules for different RR types (NS, DS, NSEC, everything else). 110 """ 111 assert self.zone.origin # mypy workaround 112 reachable = set(self.zone) 113 # assume everything is reachable until proven otherwise 114 reachable_delegations = set(self.delegations) 115 occluded = set() 116 117 def _mark_occluded(name: Name) -> None: 118 occluded.add(name) 119 if name in reachable: 120 reachable.remove(name) 121 if name in reachable_delegations: 122 reachable_delegations.remove(name) 123 124 # sanity check, should be impossible with dnspython 2.7.0 zone reader 125 for name in reachable: 126 relation, _, _ = name.fullcompare(self.zone.origin) 127 if relation in ( 128 dns.name.NameRelation.NONE, # out of zone? 129 dns.name.NameRelation.SUPERDOMAIN, # parent of apex?! 130 ): 131 raise NotImplementedError 132 133 for maybe_occluded in reachable.copy(): 134 for cut in self.delegations: 135 rel, _, _ = maybe_occluded.fullcompare(cut) 136 if rel == dns.name.NameRelation.EQUAL: 137 # data _on_ a parent-side of a zone cut are in limbo: 138 # - are not really authoritative (except for DS) 139 # - but NS is not really 'occluded' 140 # We remove them from 'reachable' but do not add them to 'occluded' set, 141 # i.e. leave them in 'reachable_delegations'. 142 if maybe_occluded in reachable: 143 reachable.remove(maybe_occluded) 144 145 if rel == dns.name.NameRelation.SUBDOMAIN: 146 _mark_occluded(maybe_occluded) 147 # do not break cycle - handle also nested NS and DNAME 148 149 # DNAME itself is authoritative but nothing under it is reachable 150 for dname in self.dnames: 151 rel, _, _ = maybe_occluded.fullcompare(dname) 152 if rel == dns.name.NameRelation.SUBDOMAIN: 153 _mark_occluded(maybe_occluded) 154 # do not break cycle - handle also nested NS and DNAME 155 156 self.reachable = frozenset(reachable) 157 self.reachable_delegations = frozenset(reachable_delegations) 158 self.occluded = frozenset(occluded) 159 160 def generate_ents(self) -> FrozenSet[Name]: 161 """ 162 Generate reachable names of empty nodes "between" all reachable 163 names with a RR and the origin. 164 """ 165 assert self.zone.origin 166 all_reachable_names = self.reachable.union(self.reachable_delegations) 167 ents = set() 168 for name in all_reachable_names: 169 _, super_name = name.split(len(name) - 1) 170 while len(super_name) > len(self.zone.origin): 171 if super_name not in all_reachable_names: 172 ents.add(super_name) 173 _, super_name = super_name.split(len(super_name) - 1) 174 175 return frozenset(ents) 176 177 def closest_encloser(self, qname: Name): 178 """ 179 Get (closest encloser, next closer name) for given qname. 180 """ 181 ce = None # Closest encloser, RFC 4592 182 nce = None # Next closer name, RFC 5155 183 for zname in self.all_existing_names: 184 relation, _, common_labels = qname.fullcompare(zname) 185 if relation == dns.name.NameRelation.SUBDOMAIN: 186 if not ce or common_labels > len(ce): 187 # longest match so far 188 ce = zname 189 _, nce = qname.split(len(ce) + 1) 190 assert ce is not None 191 assert nce is not None 192 return ce, nce 193 194 def source_of_synthesis(self, qname: Name) -> Name: 195 """ 196 Return source of synthesis according to RFC 4592 section 3.3.1. 197 Name is not guaranteed to exist or be reachable. 198 """ 199 ce, _ = self.closest_encloser(qname) 200 return Name("*") + ce 201