mirror of
https://github.com/isc-projects/bind9.git
synced 2026-06-19 12:48:53 -04:00
Wrong version number was uncovered by Ubuntu 22.04 Jammy which actually has dnspython 2.1.0.
204 lines
7.6 KiB
Python
204 lines
7.6 KiB
Python
# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
|
|
#
|
|
# SPDX-License-Identifier: MPL-2.0
|
|
#
|
|
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
# file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
#
|
|
# See the COPYRIGHT file distributed with this work for additional
|
|
# information regarding copyright ownership.
|
|
|
|
from typing import Container, Iterable, FrozenSet
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("dns", minversion="2.3.0") # NameRelation
|
|
from dns.name import Name, NameRelation
|
|
import dns.zone
|
|
import dns.rdatatype
|
|
|
|
|
|
def prepend_label(label: str, name: Name) -> Name:
|
|
return Name((label,) + name.labels)
|
|
|
|
|
|
def len_wire_uncompressed(name: Name) -> int:
|
|
return len(name) + sum(map(len, name.labels))
|
|
|
|
|
|
def get_wildcard_names(names: Iterable[Name]) -> FrozenSet[Name]:
|
|
return frozenset(name for name in names if name.is_wild())
|
|
|
|
|
|
class ZoneAnalyzer:
|
|
"""
|
|
Categorize names in zone and provide list of ENTs:
|
|
|
|
- delegations - names with NS RR
|
|
- dnames - names with DNAME RR
|
|
- wildcards - names with leftmost label '*'
|
|
- reachable - non-empty authoritative nodes in zone
|
|
- have at least one auth RR set and are not occluded
|
|
- ents - reachable empty non-terminals
|
|
- occluded - names under a parent node which has DNAME or a non-apex NS
|
|
- reachable_delegations
|
|
- have NS RR on it, are not zone's apex, and are not occluded
|
|
- reachable_dnames - have DNAME RR on it and are not occluded
|
|
- reachable_wildcards - have leftmost label '*' and are not occluded
|
|
- reachable_wildcard_parents - reachable_wildcards with leftmost '*' stripped
|
|
|
|
Warnings:
|
|
- Quadratic complexity ahead! Use only on small test zones.
|
|
- Zone must be constant.
|
|
"""
|
|
|
|
@classmethod
|
|
def read_path(cls, zpath, origin):
|
|
with open(zpath, encoding="ascii") as zf:
|
|
zonedb = dns.zone.from_file(zf, origin, relativize=False)
|
|
return cls(zonedb)
|
|
|
|
def __init__(self, zone: dns.zone.Zone):
|
|
self.zone = zone
|
|
assert self.zone.origin # mypy hack
|
|
# based on individual nodes but not relationship between nodes
|
|
self.delegations = self.get_names_with_type(dns.rdatatype.NS) - {
|
|
self.zone.origin
|
|
}
|
|
self.dnames = self.get_names_with_type(dns.rdatatype.DNAME)
|
|
self.wildcards = get_wildcard_names(self.zone)
|
|
|
|
# takes relationship between nodes into account
|
|
self._categorize_names()
|
|
self.ents = self.generate_ents()
|
|
self.reachable_dnames = self.dnames.intersection(self.reachable)
|
|
self.reachable_wildcards = self.wildcards.intersection(self.reachable)
|
|
self.reachable_wildcard_parents = {
|
|
Name(wname[1:]) for wname in self.reachable_wildcards
|
|
}
|
|
|
|
# (except for wildcard expansions) all names in zone which result in NOERROR answers
|
|
self.all_existing_names = (
|
|
self.reachable.union(self.ents)
|
|
.union(self.reachable_delegations)
|
|
.union(self.reachable_dnames)
|
|
)
|
|
|
|
def get_names_with_type(self, rdtype) -> FrozenSet[Name]:
|
|
return frozenset(
|
|
name for name in self.zone if self.zone.get_rdataset(name, rdtype)
|
|
)
|
|
|
|
def _categorize_names(
|
|
self,
|
|
) -> None:
|
|
"""
|
|
Split names defined in a zone into three sets:
|
|
Generally reachable, reachable delegations, and occluded.
|
|
|
|
Delegations are set aside because they are a weird hybrid with different
|
|
rules for different RR types (NS, DS, NSEC, everything else).
|
|
"""
|
|
assert self.zone.origin # mypy workaround
|
|
reachable = set(self.zone)
|
|
# assume everything is reachable until proven otherwise
|
|
reachable_delegations = set(self.delegations)
|
|
occluded = set()
|
|
|
|
def _mark_occluded(name: Name) -> None:
|
|
occluded.add(name)
|
|
if name in reachable:
|
|
reachable.remove(name)
|
|
if name in reachable_delegations:
|
|
reachable_delegations.remove(name)
|
|
|
|
# sanity check, should be impossible with dnspython 2.7.0 zone reader
|
|
for name in reachable:
|
|
relation, _, _ = name.fullcompare(self.zone.origin)
|
|
if relation in (
|
|
NameRelation.NONE, # out of zone?
|
|
NameRelation.SUPERDOMAIN, # parent of apex?!
|
|
):
|
|
raise NotImplementedError
|
|
|
|
for maybe_occluded in reachable.copy():
|
|
for cut in self.delegations:
|
|
rel, _, _ = maybe_occluded.fullcompare(cut)
|
|
if rel == NameRelation.EQUAL:
|
|
# data _on_ a parent-side of a zone cut are in limbo:
|
|
# - are not really authoritative (except for DS)
|
|
# - but NS is not really 'occluded'
|
|
# We remove them from 'reachable' but do not add them to 'occluded' set,
|
|
# i.e. leave them in 'reachable_delegations'.
|
|
if maybe_occluded in reachable:
|
|
reachable.remove(maybe_occluded)
|
|
|
|
if rel == NameRelation.SUBDOMAIN:
|
|
_mark_occluded(maybe_occluded)
|
|
# do not break cycle - handle also nested NS and DNAME
|
|
|
|
# DNAME itself is authoritative but nothing under it is reachable
|
|
for dname in self.dnames:
|
|
rel, _, _ = maybe_occluded.fullcompare(dname)
|
|
if rel == NameRelation.SUBDOMAIN:
|
|
_mark_occluded(maybe_occluded)
|
|
# do not break cycle - handle also nested NS and DNAME
|
|
|
|
self.reachable = frozenset(reachable)
|
|
self.reachable_delegations = frozenset(reachable_delegations)
|
|
self.occluded = frozenset(occluded)
|
|
|
|
def generate_ents(self) -> FrozenSet[Name]:
|
|
"""
|
|
Generate reachable names of empty nodes "between" all reachable
|
|
names with a RR and the origin.
|
|
"""
|
|
assert self.zone.origin
|
|
all_reachable_names = self.reachable.union(self.reachable_delegations)
|
|
ents = set()
|
|
for name in all_reachable_names:
|
|
_, super_name = name.split(len(name) - 1)
|
|
while len(super_name) > len(self.zone.origin):
|
|
if super_name not in all_reachable_names:
|
|
ents.add(super_name)
|
|
_, super_name = super_name.split(len(super_name) - 1)
|
|
|
|
return frozenset(ents)
|
|
|
|
def closest_encloser(self, qname: Name):
|
|
"""
|
|
Get (closest encloser, next closer name) for given qname.
|
|
"""
|
|
ce = None # Closest encloser, RFC 4592
|
|
nce = None # Next closer name, RFC 5155
|
|
for zname in self.all_existing_names:
|
|
relation, _, common_labels = qname.fullcompare(zname)
|
|
if relation == NameRelation.SUBDOMAIN:
|
|
if not ce or common_labels > len(ce):
|
|
# longest match so far
|
|
ce = zname
|
|
_, nce = qname.split(len(ce) + 1)
|
|
assert ce is not None
|
|
assert nce is not None
|
|
return ce, nce
|
|
|
|
def source_of_synthesis(self, qname: Name) -> Name:
|
|
"""
|
|
Return source of synthesis according to RFC 4592 section 3.3.1.
|
|
Name is not guaranteed to exist or be reachable.
|
|
"""
|
|
ce, _ = self.closest_encloser(qname)
|
|
return Name("*") + ce
|
|
|
|
|
|
def is_related_to_any(
|
|
test_name: Name,
|
|
acceptable_relations: Container[NameRelation],
|
|
candidates: Iterable[Name],
|
|
) -> bool:
|
|
for maybe_parent in candidates:
|
|
relation, _, _ = test_name.fullcompare(maybe_parent)
|
|
if relation in acceptable_relations:
|
|
return True
|
|
return False
|