modified: netbox_dns_bridge/models.py deleted: netbox_dns_bridge/migrations/0003_catalogzone_and_per_zone_identifier.py new file: netbox_dns_bridge/migrations/0003_full_netbox_models.py Adjust model access and function calls to match new models: modified: netbox_dns_bridge/signals/netbox_dns_zones.py modified: netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py modified: netbox_dns_bridge/management/commands/dns_transfer_endpoint/request_handler.py Update UI elements and permission interactions: modified: netbox_dns_bridge/filtersets.py modified: netbox_dns_bridge/forms.py modified: netbox_dns_bridge/jobs/notify.py modified: netbox_dns_bridge/navigation.py modified: netbox_dns_bridge/tables.py deleted: netbox_dns_bridge/templates/netbox_dns_bridge/catalog_zone_members.html new file: netbox_dns_bridge/templates/netbox_dns_bridge/catalogzone.html new file: netbox_dns_bridge/templates/netbox_dns_bridge/catalogzonemember.html deleted: netbox_dns_bridge/templates/netbox_dns_bridge/notify_overview.html new file: netbox_dns_bridge/templates/netbox_dns_bridge/seentransferclient.html modified: netbox_dns_bridge/urls.py modified: netbox_dns_bridge/views.py AI/LLM updated tests (Not verified): modified: netbox_dns_bridge/tests/conftest.py modified: netbox_dns_bridge/tests/test_catalog_zone_manager.py modified: netbox_dns_bridge/tests/test_models.py modified: netbox_dns_bridge/tests/test_notify.py modified: netbox_dns_bridge/tests/test_request_handler_integration.py modified: netbox_dns_bridge/tests/test_signals.py modified: netbox_dns_bridge/tests/test_smoke.py modified: netbox_dns_bridge/tests/test_views.py
191 lines
6.9 KiB
Python
191 lines
6.9 KiB
Python
"""Real-socket integration tests for the DNS transfer endpoint.
|
|
|
|
Starts actual TCP+UDP servers on 127.0.0.1:0 and sends real DNS queries via
|
|
raw sockets against NetBox DNS zones in the test database. Raw sockets are used
|
|
so response TSIG can be verified explicitly with the request MAC.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import dns.flags
|
|
import dns.message
|
|
import dns.name
|
|
import dns.query
|
|
import dns.rcode
|
|
import dns.rdatatype
|
|
import pytest
|
|
|
|
from netbox_dns_bridge.tests._helpers import (
|
|
make_signed_query,
|
|
make_unsigned_query,
|
|
parse_response,
|
|
parse_response_strict,
|
|
query_mac,
|
|
tcp_exchange,
|
|
udp_exchange,
|
|
build_keyring,
|
|
xfr_rrsets,
|
|
)
|
|
|
|
|
|
pytestmark = pytest.mark.django_db(transaction=True)
|
|
|
|
|
|
def _signed_soa(zone_name, tsig_key):
|
|
return make_signed_query(zone_name, dns.rdatatype.SOA, tsig_key)
|
|
|
|
|
|
def _signed_axfr(zone_name, tsig_key):
|
|
return make_signed_query(zone_name, dns.rdatatype.AXFR, tsig_key)
|
|
|
|
|
|
class TestSOAIntegration:
|
|
def test_soa_over_tcp(self, dns_server, make_zone, make_view, tsig_key):
|
|
view = make_view()
|
|
make_zone(name="int-soa.test", view=view, soa_serial=555)
|
|
srv = dns_server.start(views={"testkey.": view})
|
|
keyring = build_keyring(tsig_key)
|
|
|
|
wire = _signed_soa("int-soa.test", tsig_key)
|
|
resp_wire = tcp_exchange(wire, srv["host"], srv["port"])
|
|
resp = parse_response_strict(resp_wire, keyring, query_mac(wire, keyring))
|
|
assert resp.rcode() == dns.rcode.NOERROR
|
|
assert resp.flags & dns.flags.AA
|
|
assert resp.answer[0].rdtype == dns.rdatatype.SOA
|
|
assert resp.answer[0][0].serial == 555
|
|
assert resp.had_tsig
|
|
|
|
def test_soa_over_udp(self, dns_server, make_zone, make_view, tsig_key):
|
|
view = make_view()
|
|
make_zone(name="int-udp.test", view=view, soa_serial=7)
|
|
srv = dns_server.start(views={"testkey.": view})
|
|
keyring = build_keyring(tsig_key)
|
|
|
|
wire = _signed_soa("int-udp.test", tsig_key)
|
|
resp_wire = udp_exchange(wire, srv["host"], srv["port"])
|
|
resp = parse_response_strict(resp_wire, keyring, query_mac(wire, keyring))
|
|
assert resp.rcode() == dns.rcode.NOERROR
|
|
assert resp.answer[0][0].serial == 7
|
|
|
|
def test_unsigned_query_refused(self, dns_server, make_zone, make_view, tsig_key):
|
|
view = make_view()
|
|
make_zone(name="int-nosig.test", view=view)
|
|
srv = dns_server.start(views={"testkey.": view})
|
|
|
|
resp_wire = tcp_exchange(
|
|
make_unsigned_query("int-nosig.test", dns.rdatatype.SOA),
|
|
srv["host"], srv["port"],
|
|
)
|
|
resp = parse_response(resp_wire)
|
|
assert resp.rcode() == dns.rcode.REFUSED
|
|
|
|
|
|
class TestAXFRIntegration:
|
|
def test_axfr_over_tcp(self, dns_server, make_zone, make_view, make_record, tsig_key):
|
|
view = make_view()
|
|
zone = make_zone(name="int-axfr.test", view=view, soa_serial=123)
|
|
make_record(zone, name="www", type_="A", value="192.0.2.10")
|
|
make_record(zone, name="api", type_="A", value="192.0.2.11")
|
|
srv = dns_server.start(views={"testkey.": view})
|
|
|
|
records = xfr_rrsets(
|
|
dns.query.xfr(
|
|
srv["host"], "int-axfr.test", port=srv["port"],
|
|
keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name,
|
|
lifetime=5, relativize=False,
|
|
)
|
|
)
|
|
names = {r.name.to_text() for r in records}
|
|
assert "int-axfr.test." in names
|
|
assert "www.int-axfr.test." in names
|
|
assert "api.int-axfr.test." in names
|
|
# AXFR must begin and end with SOA.
|
|
assert records[0].rdtype == dns.rdatatype.SOA
|
|
assert records[-1].rdtype == dns.rdatatype.SOA
|
|
assert records[0][0].serial == 123
|
|
|
|
def test_axfr_over_udp_refused(self, dns_server, make_zone, make_view, tsig_key):
|
|
view = make_view()
|
|
make_zone(name="int-axfr-udp.test", view=view)
|
|
srv = dns_server.start(views={"testkey.": view})
|
|
|
|
resp_wire = udp_exchange(
|
|
_signed_axfr("int-axfr-udp.test", tsig_key), srv["host"], srv["port"]
|
|
)
|
|
resp = parse_response(resp_wire)
|
|
assert resp.rcode() == dns.rcode.REFUSED
|
|
|
|
def test_axfr_unknown_zone_refused(self, dns_server, make_view, tsig_key):
|
|
view = make_view()
|
|
srv = dns_server.start(views={"testkey.": view})
|
|
|
|
resp_wire = tcp_exchange(
|
|
_signed_axfr("does-not-exist.test", tsig_key), srv["host"], srv["port"]
|
|
)
|
|
resp = parse_response(resp_wire)
|
|
assert resp.rcode() == dns.rcode.REFUSED
|
|
|
|
|
|
class TestCatzIntegration:
|
|
def test_catz_axfr_contains_member_ptr(self, dns_server, make_zone, make_view, tsig_key):
|
|
view = make_view()
|
|
make_zone(name="member-a.test", view=view)
|
|
srv = dns_server.start(views={"testkey.": view})
|
|
|
|
records = xfr_rrsets(
|
|
dns.query.xfr(
|
|
srv["host"], "catz", port=srv["port"],
|
|
keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name,
|
|
lifetime=5, relativize=False,
|
|
)
|
|
)
|
|
types = {r.rdtype for r in records}
|
|
assert dns.rdatatype.SOA in types
|
|
assert dns.rdatatype.PTR in types
|
|
ptrs = [r for r in records if r.rdtype == dns.rdatatype.PTR]
|
|
targets = {str(r[0].target) for r in ptrs}
|
|
assert any("member-a.test" in t for t in targets)
|
|
|
|
|
|
class TestSeenClientTracking:
|
|
def _wait_for_seen_client(self, view, timeout=3.0):
|
|
"""Poll for SeenTransferClient record (race: response is sent before
|
|
_track_seen_client completes in the handler thread)."""
|
|
import time
|
|
from netbox_dns_bridge.models import SeenTransferClient
|
|
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if SeenTransferClient.objects.filter(
|
|
source_ip="127.0.0.1", view=view
|
|
).exists():
|
|
return True
|
|
time.sleep(0.05)
|
|
return False
|
|
|
|
def test_seen_client_recorded_on_success(
|
|
self, dns_server, make_zone, make_view, tsig_key, patch_settings
|
|
):
|
|
patch_settings({"notify_clients": True})
|
|
view = make_view()
|
|
make_zone(name="seen.test", view=view)
|
|
srv = dns_server.start(views={"testkey.": view})
|
|
|
|
tcp_exchange(_signed_soa("seen.test", tsig_key), srv["host"], srv["port"])
|
|
|
|
assert self._wait_for_seen_client(view)
|
|
|
|
def test_no_tracking_when_notify_clients_false(
|
|
self, dns_server, make_zone, make_view, tsig_key, patch_settings
|
|
):
|
|
patch_settings({"notify_clients": False})
|
|
view = make_view()
|
|
make_zone(name="not-seen.test", view=view)
|
|
srv = dns_server.start(views={"testkey.": view})
|
|
|
|
tcp_exchange(_signed_soa("not-seen.test", tsig_key), srv["host"], srv["port"])
|
|
|
|
import time
|
|
time.sleep(0.3)
|
|
from netbox_dns_bridge.models import SeenTransferClient
|
|
assert not SeenTransferClient.objects.filter(view=view).exists()
|