Version in base suite: 20.0.0-2 Base version: designate_20.0.0-2 Target version: designate_20.0.0-2+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/d/designate/designate_20.0.0-2.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/d/designate/designate_20.0.0-2+deb13u1.dsc changelog | 20 patches/CVE-2026-71193_CVE-2026-71194_Fix_cross-tenant_cross-pool_zone_ownership_bypass.patch | 512 ++++++++++ patches/Fix_mDNS_record_query_pool_scoping_for_split-horizon_DNS.patch | 242 ++++ patches/Require_TSIG_keys_for_zones_in_non-default_pools.patch | 403 +++++++ patches/add-new-floatingip-handler.patch | 2 patches/series | 3 6 files changed, 1181 insertions(+), 1 deletion(-) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp25yutryr/designate_20.0.0-2.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp25yutryr/designate_20.0.0-2+deb13u1.dsc: no acceptable signature found diff -Nru designate-20.0.0/debian/changelog designate-20.0.0/debian/changelog --- designate-20.0.0/debian/changelog 2025-04-05 12:01:54.000000000 +0000 +++ designate-20.0.0/debian/changelog 2026-08-06 08:25:23.000000000 +0000 @@ -1,3 +1,23 @@ +designate (1:20.0.0-2+deb13u1) trixie-security; urgency=medium + + * CVE-2026-71193, CVE-2026-71194 / OSSA-2026-034: + - An authenticated tenant can bypass zone ownership checks by scheduling a + zone to a different pool, creating overlapping zones that hijack or deny + service to another tenant's DNS records. Any user with the default + create_zone policy can exploit this when the AttributeFilter scheduler is + enabled. Only deployments using the AttributeFilter scheduler with + multiple pools are affected. + - The mDNS handler performs pool-blind record lookups that fail when + colliding zones exist across pools, causing deterministic DNS query + failures. The NOTIFY handler path is reachable via unauthenticated UDP. + Applied upstream patches: + - Require TSIG keys for zones in non-default pools + - Fix mDNS record query pool scoping for split-horizon DNS + - Fix cross-tenant/cross-pool zone ownership bypass + (Closes: #1144145). + + -- Thomas Goirand Thu, 06 Aug 2026 10:25:23 +0200 + designate (1:20.0.0-2) unstable; urgency=medium * export OS_OSLO_MESSAGING_RABBIT__PROCESSNAME for all daemons. diff -Nru designate-20.0.0/debian/patches/CVE-2026-71193_CVE-2026-71194_Fix_cross-tenant_cross-pool_zone_ownership_bypass.patch designate-20.0.0/debian/patches/CVE-2026-71193_CVE-2026-71194_Fix_cross-tenant_cross-pool_zone_ownership_bypass.patch --- designate-20.0.0/debian/patches/CVE-2026-71193_CVE-2026-71194_Fix_cross-tenant_cross-pool_zone_ownership_bypass.patch 1970-01-01 00:00:00.000000000 +0000 +++ designate-20.0.0/debian/patches/CVE-2026-71193_CVE-2026-71194_Fix_cross-tenant_cross-pool_zone_ownership_bypass.patch 2026-08-06 08:25:23.000000000 +0000 @@ -0,0 +1,512 @@ +Description: Fix cross-tenant/cross-pool zone ownership bypass + Zone ownership checks (duplicate-name, subzone, superzone) and mDNS + record/NOTIFY lookups only ever considered the zone's own pool, so a + tenant could bypass all three ownership protections against another + tenant's zone by scheduling to a different pool, and could trigger an + ambiguous-lookup DoS in mDNS via a colliding zone name in another pool. + . + Add _check_zone_ownership_conflicts(), called from create_zone() before + pool scheduling, which searches for exact-name/subzone/superzone + conflicts across all pools and rejects them only when the conflicting + zone belongs to a different tenant. Same-tenant use of an identical or + overlapping name across pools (split-horizon, pool migrations) is + preserved unchanged. + . + Fix the two unscoped mDNS lookups this bypass made exploitable: + _handle_record_query() now walks from the query name up through its + ancestors, scoping each candidate by the TSIG-derived (or default) + pool_id, with no remaining unscoped fallback. _handle_notify() now + fetches every zone matching the name and disambiguates using the trust + check it already performs - whether the sending IP is a configured + master for that zone - since NOTIFY has no TSIG relationship to scope + by. +Author: Omer +Date: Thu, 23 Jul 2026 12:31:13 +0200 +Bug: https://launchpad.net/bugs/2160533 +Bug-Debian: https://bugs.debian.org/XXXXXXX +Change-Id: I0900980fd1f2ecfba11c745762fae2f043078259 +Signed-off-by: Omer +Origin: upstream, pre-OSSA mailing list +Last-Update: 2026-08-06 + +Index: designate/designate/central/service.py +=================================================================== +--- designate.orig/designate/central/service.py ++++ designate/designate/central/service.py +@@ -335,6 +335,49 @@ class Service(service.RPCService): + + return subzones + ++ def _check_zone_ownership_conflicts(self, context, zone): ++ """ ++ Ensures zone.name does not collide - as an exact duplicate, a ++ subzone, or a superzone - with a zone owned by a different ++ tenant, regardless of which pool that zone lives in. A single ++ tenant may still own the same or an overlapping zone name ++ across multiple pools (e.g. split-horizon deployments); only ++ cross-tenant collisions are rejected here. ++ """ ++ context = context.elevated(all_tenants=True) ++ labels = zone.name.split('.') ++ ++ # Exact-name duplicate owned by another tenant, in any pool. ++ for existing in self.storage.find_zones(context, {'name': zone.name}): ++ if existing.tenant_id != zone.tenant_id: ++ raise exceptions.DuplicateZone( ++ 'Zone already exists, owned by a different tenant') ++ ++ # This zone would be a subzone of a zone owned by another ++ # tenant, in any pool. Stop at the first (nearest) ancestor ++ # match, same as _is_subzone. ++ for i in range(1, len(labels)): ++ name = '.'.join(labels[i:]) ++ ancestors = self.storage.find_zones(context, {'name': name}) ++ if not ancestors: ++ continue ++ for ancestor in ancestors: ++ if ancestor.tenant_id != zone.tenant_id: ++ raise exceptions.IllegalChildZone( ++ 'Unable to create subzone in another tenants ' ++ 'zone') ++ break ++ ++ # This zone would be a superzone of a zone owned by another ++ # tenant, in any pool. ++ search_term = "%%.%(name)s" % {"name": zone.name} ++ for subzone in self.storage.find_zones( ++ context, {'name': search_term}): ++ if subzone.tenant_id != zone.tenant_id: ++ raise exceptions.IllegalParentZone( ++ 'Unable to create zone because another tenant owns ' ++ 'a subzone of the zone') ++ + def _is_valid_ttl(self, context, ttl): + if ttl is None: + return +@@ -766,6 +809,10 @@ class Service(service.RPCService): + # Ensure TTL is above the minimum + self._is_valid_ttl(context, zone.ttl) + ++ # Ensure this zone name does not collide with another tenant's ++ # zone, regardless of which pool either zone lives in ++ self._check_zone_ownership_conflicts(context, zone) ++ + # Get a pool id + zone.pool_id = self.scheduler.schedule_zone(context, zone) + +Index: designate/designate/mdns/handler.py +=================================================================== +--- designate.orig/designate/mdns/handler.py ++++ designate/designate/mdns/handler.py +@@ -113,25 +113,41 @@ class RequestHandler: + 'deleted': False + } + +- try: +- zone = self.storage.find_zone(context, criterion) +- except exceptions.ZoneNotFound: ++ # No pool_id here deliberately: NOTIFY has no TSIG requirement ++ # under any configuration, so at this point we don't know ++ # which pool the sender is even talking about. We must look ++ # across every pool and disambiguate afterwards. ++ matching_zones = self.storage.find_zones(context, criterion) ++ if not matching_zones: ++ # No SECONDARY zone with this name exists in any pool. + response.set_rcode(dns.rcode.NOTAUTH) + yield response + return + + notify_addr = request.environ['addr'][0] + +- # We check if the src_master which is the assumed master for the zone +- # that is sending this NOTIFY OP is actually the master. If it's not +- # We'll reply but don't do anything with the NOTIFY. +- master_addr = zone.get_master_by_ip(notify_addr) +- if not master_addr: ++ # There can be more than one SECONDARY zone with this name ++ # across different pools/tenants. Disambiguate using the trust ++ # mechanism NOTIFY already relies on - whether the sending ++ # address is a configured master for the zone - rather than ++ # assuming the name is unique across pools. ++ zone = None ++ master_addr = None ++ for candidate in matching_zones: ++ master_addr = candidate.get_master_by_ip(notify_addr) ++ if master_addr: ++ zone = candidate ++ break ++ ++ if not zone: ++ # None of the matching zones (in any pool) list this ++ # sender as a master - refuse, same as the classic ++ # single-zone "wrong master" case. + LOG.warning( + 'NOTIFY for %(name)s from non-master server %(addr)s, ' + 'refusing.', + { +- 'name': zone.name, ++ 'name': name, + 'addr': notify_addr + } + ) +@@ -194,6 +210,33 @@ class RequestHandler: + ) + return criterion + ++ def _find_zone_by_ancestor_walk(self, context, request, name): ++ """Find the zone that would be authoritative for `name`. ++ ++ Walks from the full query name up through each ancestor label ++ - e.g. for 'www.example.com.': tries 'www.example.com.' itself ++ (the record may be a zone apex), then 'example.com.', then ++ 'com.' - stopping at the first level that matches an existing ++ zone. Every candidate is scoped by the same pool/zone identity ++ derived from the request's TSIG key (or the default pool if ++ unsigned, via `_zone_criterion_from_request`), which is what ++ stops a same-named zone in a different pool from being ++ matched instead. Returns None if no ancestor at any level ++ matches within that scope. ++ """ ++ pool_criterion = self._zone_criterion_from_request(request) ++ labels = name.split('.') ++ ++ for i in range(len(labels) - 1): ++ ancestor_name = '.'.join(labels[i:]) ++ criterion = dict(pool_criterion, name=ancestor_name) ++ try: ++ return self.storage.find_zone(context, criterion) ++ except exceptions.ZoneNotFound: ++ continue ++ ++ return None ++ + def _handle_axfr(self, request): + context = request.environ['context'] + q_rrset = request.question[0] +@@ -316,49 +359,22 @@ class RequestHandler: + name = q_rrset.name.to_text() + rdtype = dns.rdatatype.to_text(q_rrset.rdtype) + +- # Try to find the zone first using TSIG-based pool scoping. +- # This handles split-horizon configurations where the same +- # zone name exists in multiple pools. The TSIG key on the +- # request determines which pool's zone to serve, matching +- # the approach used by _handle_axfr. +- zone = None +- try: +- zone_criterion = self._zone_criterion_from_request( +- request, {'name': name}) +- zone = self.storage.find_zone(context, zone_criterion) +- except exceptions.ZoneNotFound: +- # Query name may not be a zone name (e.g. a subdomain +- # like www.example.com when the zone is example.com). +- # Fall through to the recordset-first lookup below. +- pass +- +- if zone: +- # Zone found - look up the recordset within this +- # specific zone. The zone_id scoping ensures we find +- # the correct recordset even when multiple pools have +- # zones with the same name. +- criterion = { +- 'zone_id': zone.id, +- 'name': name, +- 'type': rdtype, +- } +- recordset = self.storage.find_recordset( +- context, criterion) +- else: +- # Could not match the query name to a zone directly. +- # Fall back to finding the recordset by name and type, +- # then verify the zone matches the TSIG key's pool. +- criterion = { +- 'name': name, +- 'type': rdtype, +- 'zones_deleted': False +- } +- recordset = self.storage.find_recordset( +- context, criterion) +- +- zone_criterion = self._zone_criterion_from_request( +- request, {'id': recordset.zone_id}) +- zone = self.storage.find_zone(context, zone_criterion) ++ # Find the zone containing this record (apex or not), ++ # scoped to the requester's own pool throughout - same ++ # scoping _handle_axfr uses. This never falls back to a ++ # pool-blind lookup that could collide with a matching ++ # name+type in another pool, so split-horizon setups ++ # (same zone name in multiple pools) resolve correctly. ++ zone = self._find_zone_by_ancestor_walk(context, request, name) ++ if zone is None: ++ raise exceptions.ZoneNotFound() ++ ++ criterion = { ++ 'zone_id': zone.id, ++ 'name': name, ++ 'type': rdtype, ++ } ++ recordset = self.storage.find_recordset(context, criterion) + + except exceptions.NotFound: + # If an FQDN exists, like www.rackspace.com, but the specific +Index: designate/designate/tests/functional/central/test_basic.py +=================================================================== +--- designate.orig/designate/tests/functional/central/test_basic.py ++++ designate/designate/tests/functional/central/test_basic.py +@@ -696,6 +696,7 @@ class CentralZoneTestCase(CentralBasic): + self.service._enforce_zone_quota = mock.Mock() + self.service._is_valid_zone_name = mock.Mock() + self.service._is_valid_ttl = mock.Mock() ++ self.service._check_zone_ownership_conflicts = mock.Mock() + self.service._is_subzone = mock.Mock( + return_value=False + ) +@@ -736,6 +737,7 @@ class CentralZoneTestCase(CentralBasic): + ) + self.service._is_valid_zone_name = mock.Mock() + self.service._is_valid_ttl = mock.Mock() ++ self.service._check_zone_ownership_conflicts = mock.Mock() + self.service._is_subzone = mock.Mock( + return_value=False + ) +Index: designate/designate/tests/functional/central/test_service.py +=================================================================== +--- designate.orig/designate/tests/functional/central/test_service.py ++++ designate/designate/tests/functional/central/test_service.py +@@ -719,6 +719,94 @@ class CentralServiceTest(designate.tests + + self.assertEqual(exceptions.IllegalParentZone, exc.exc_info[0]) + ++ def test_create_zone_duplicate_different_pools_different_tenant_fails( ++ self): ++ context = self.get_admin_context() ++ context.project_id = '1' ++ ++ fixture = self.get_zone_fixture() ++ fixture['context'] = context ++ ++ # Create first zone that's placed in default pool, owned by ++ # tenant '1' ++ self.create_zone(**fixture) ++ ++ # Create a secondary pool ++ second_pool = self.create_pool() ++ ++ context = self.get_admin_context() ++ context.project_id = '2' ++ ++ fixture['context'] = context ++ fixture['attributes'] = {} ++ fixture['attributes']['pool_id'] = second_pool.id ++ ++ # Attempt to create the same zone name in the second pool, ++ # owned by a different tenant ++ exc = self.assertRaises(rpc_dispatcher.ExpectedException, ++ self.create_zone, **fixture) ++ ++ self.assertEqual(exceptions.DuplicateZone, exc.exc_info[0]) ++ ++ def test_create_subzone_different_pools_different_tenant_fails(self): ++ context = self.get_admin_context() ++ context.project_id = '1' ++ ++ fixture = self.get_zone_fixture() ++ fixture['context'] = context ++ ++ # Create the parent zone in the default pool, owned by ++ # tenant '1' ++ self.create_zone(**fixture) ++ ++ # Create a secondary pool ++ second_pool = self.create_pool() ++ ++ context = self.get_admin_context() ++ context.project_id = '2' ++ ++ fixture['context'] = context ++ fixture['attributes'] = {} ++ fixture['attributes']['pool_id'] = second_pool.id ++ fixture['name'] = 'sub.%s' % fixture['name'] ++ ++ # Attempt to create the subzone in the second pool, owned by ++ # a different tenant ++ exc = self.assertRaises(rpc_dispatcher.ExpectedException, ++ self.create_zone, **fixture) ++ ++ self.assertEqual(exceptions.IllegalChildZone, exc.exc_info[0]) ++ ++ def test_create_superzone_different_pools_different_tenant_fails(self): ++ context = self.get_admin_context() ++ context.project_id = '1' ++ ++ zone_values = self.get_zone_fixture(fixture=1) ++ zone_name = zone_values['name'] ++ ++ subzone_values = copy.deepcopy(zone_values) ++ subzone_values['name'] = 'www.%s' % zone_name ++ subzone_values['context'] = context ++ ++ # Create the subzone in the default pool, owned by tenant '1' ++ self.create_zone(**subzone_values) ++ ++ # Create a secondary pool ++ second_pool = self.create_pool() ++ ++ context = self.get_admin_context() ++ context.project_id = '2' ++ ++ zone_values['context'] = context ++ zone_values['attributes'] = {'pool_id': second_pool.id} ++ ++ # Attempt to create the superzone in the second pool, owned by ++ # a different tenant ++ exc = self.assertRaises(rpc_dispatcher.ExpectedException, ++ self.create_zone, **zone_values) ++ ++ self.assertEqual(exceptions.IllegalParentZone, exc.exc_info[0]) ++ + def test_create_blacklisted_zone_success(self): + # Create blacklisted zone using default values + self.create_blacklist() +Index: designate/designate/tests/functional/mdns/test_handler.py +=================================================================== +--- designate.orig/designate/tests/functional/mdns/test_handler.py ++++ designate/designate/tests/functional/mdns/test_handler.py +@@ -174,8 +174,8 @@ class MdnsRequestHandlerTest(designate.t + 'context': self.context + } + +- with mock.patch.object(self.handler.storage, 'find_zone', +- return_value=zone): ++ with mock.patch.object(self.handler.storage, 'find_zones', ++ return_value=[zone]): + response = next(self.handler(request)).to_wire() + + self.assertEqual(expected_response, binascii.b2a_hex(response)) +@@ -211,8 +211,8 @@ class MdnsRequestHandlerTest(designate.t + 'context': self.context + } + +- with mock.patch.object(self.handler.storage, 'find_zone', +- return_value=zone): ++ with mock.patch.object(self.handler.storage, 'find_zones', ++ return_value=[zone]): + response = next(self.handler(request)).to_wire() + + assert not self.mock_tg.add_thread.called +@@ -247,8 +247,8 @@ class MdnsRequestHandlerTest(designate.t + 'context': self.context + } + +- with mock.patch.object(self.handler.storage, 'find_zone', +- return_value=zone): ++ with mock.patch.object(self.handler.storage, 'find_zones', ++ return_value=[zone]): + response = next(self.handler(request)).to_wire() + + assert not self.mock_tg.add_thread.called +Index: designate/designate/tests/unit/mdns/test_handler.py +=================================================================== +--- designate.orig/designate/tests/unit/mdns/test_handler.py ++++ designate/designate/tests/unit/mdns/test_handler.py +@@ -61,13 +61,13 @@ class MdnsHandleTest(oslotest.base.BaseT + @mock.patch.object(rpc, 'get_client', mock.Mock()) + @mock.patch.object(dns.resolver.Resolver, 'resolve') + def test_notify(self, mock_query): +- self.storage.find_zone.return_value = objects.Zone( ++ self.storage.find_zones.return_value = [objects.Zone( + id='e2bed4dc-9d01-11e4-89d3-123b93f75cba', + serial=2, + masters=objects.ZoneMasterList.from_list([ + {'host': '192.0.2.1', 'port': 53}, + ]) +- ) ++ )] + mock_query.return_value = [ + mock.Mock(serial=1) + ] +@@ -92,13 +92,13 @@ class MdnsHandleTest(oslotest.base.BaseT + mock.Mock()) + @mock.patch.object(dns.resolver.Resolver, 'resolve') + def test_notify_same_serial(self, mock_query): +- self.storage.find_zone.return_value = objects.Zone( ++ self.storage.find_zones.return_value = [objects.Zone( + id='e2bed4dc-9d01-11e4-89d3-123b93f75cba', + serial=1, + masters=objects.ZoneMasterList.from_list([ + {'host': '192.0.2.1', 'port': 53}, + ]) +- ) ++ )] + mock_query.return_value = [ + mock.Mock(serial=1) + ] +@@ -130,7 +130,7 @@ class MdnsHandleTest(oslotest.base.BaseT + self.assertEqual(dns.rcode.FORMERR, tuple(response)[0].rcode()) + + def test_notify_zone_not_found(self): +- self.storage.find_zone.side_effect = exceptions.ZoneNotFound ++ self.storage.find_zones.return_value = [] + + request = dns.message.make_query( + 'www.example.org.', dns.rdatatype.SOA +@@ -142,11 +142,11 @@ class MdnsHandleTest(oslotest.base.BaseT + self.assertEqual(dns.rcode.NOTAUTH, tuple(response)[0].rcode()) + + def test_notify_no_master_addr(self): +- self.storage.find_zone.return_value = objects.Zone( ++ self.storage.find_zones.return_value = [objects.Zone( + masters=objects.ZoneMasterList.from_list([ + {'host': '192.0.2.1', 'port': 53}, + ]) +- ) ++ )] + + request = dns.message.make_query( + 'www.example.org.', dns.rdatatype.SOA +@@ -158,7 +158,8 @@ class MdnsHandleTest(oslotest.base.BaseT + self.assertEqual(dns.rcode.REFUSED, tuple(response)[0].rcode()) + + self.assertIn( +- 'NOTIFY for None from non-master server 203.0.113.1, refusing.', ++ 'NOTIFY for www.example.org. from non-master server ' ++ '203.0.113.1, refusing.', + self.stdlog.logger.output + ) + +@@ -490,13 +491,15 @@ class HandleRecordQueryTest(oslotest.bas + ) + + def test_handle_record_query_subdomain_with_tsig(self): +- """Test recordset-first fallback for subdomain queries with TSIG. ++ """Test the ancestor walk-up for subdomain queries with TSIG. + + When the query is for a subdomain (www.example.org.) rather than +- a zone apex, the zone-first lookup raises ZoneNotFound because +- no zone is named 'www.example.org.'. The handler falls back to +- finding the recordset by name, then verifies the zone matches +- the TSIG key's pool. The overall query still succeeds. ++ a zone apex, the first candidate lookup (name='www.example.org.') ++ raises ZoneNotFound because no zone is named that. The handler ++ walks up to the parent name (example.org.), scoped by the same ++ TSIG-derived pool_id, and finds the containing zone there. The ++ recordset lookup is then scoped to that zone's zone_id, never ++ falling back to an unscoped, cross-pool lookup. + """ + pool_id = 'c4f6ea1c-a1af-4401-a849-000000000001' + zone_id = 'e2bed4dc-9d01-11e4-89d3-123b93f75cba' +@@ -528,11 +531,11 @@ class HandleRecordQueryTest(oslotest.bas + self.assertEqual(dns.rcode.NOERROR, response[0].rcode()) + + self.assertEqual(2, self.storage.find_zone.call_count) +- # Recordset found via fallback path (no zone_id scoping) ++ # Recordset found scoped to the zone found by walking up to the ++ # parent name (example.org.) + self.storage.find_recordset.assert_called_once_with( + self.context, +- {'name': 'www.example.org.', 'type': 'A', +- 'zones_deleted': False} ++ {'zone_id': zone_id, 'name': 'www.example.org.', 'type': 'A'} + ) + + def test_handle_record_query_zone_found_recordset_not_found(self): diff -Nru designate-20.0.0/debian/patches/Fix_mDNS_record_query_pool_scoping_for_split-horizon_DNS.patch designate-20.0.0/debian/patches/Fix_mDNS_record_query_pool_scoping_for_split-horizon_DNS.patch --- designate-20.0.0/debian/patches/Fix_mDNS_record_query_pool_scoping_for_split-horizon_DNS.patch 1970-01-01 00:00:00.000000000 +0000 +++ designate-20.0.0/debian/patches/Fix_mDNS_record_query_pool_scoping_for_split-horizon_DNS.patch 2026-08-06 08:25:23.000000000 +0000 @@ -0,0 +1,242 @@ +Description: Fix mDNS record query pool scoping for split-horizon DNS + _handle_record_query did not use TSIG-based pool scoping when looking + up recordsets. When the same zone name exists in multiple pools (e.g. + split-horizon with BIND views), find_recordset found multiple SOA + records and returned REFUSED. + . + Use _zone_criterion_from_request to resolve the zone by pool first, + then look up the recordset within that zone. Falls back to the + recordset-first path for subdomain queries. +Author: Omer +Date: Tue, 24 Feb 2026 12:45:53 +0100 +Assisted-By: Claude Code 4.6 Opus +Bug: https://launchpad.net/bugs/2142581 +Bug-Debian: https://bugs.debian.org/1144145 +Change-Id: I3807e32f0010e8f62fc59acaefccf4ea41e85387 +Signed-off-by: Omer +Origin: upstream, https://review.opendev.org/c/openstack/designate/+/998029 +Last-Update: 2028-08-06 + +diff --git a/designate/mdns/handler.py b/designate/mdns/handler.py +index 85ab764..f14e8e4 100644 +--- a/designate/mdns/handler.py ++++ b/designate/mdns/handler.py +@@ -314,14 +314,51 @@ + try: + q_rrset = request.question[0] + name = q_rrset.name.to_text() +- # TODO(vinod) once validation is separated from the api, +- # validate the parameters +- criterion = { +- 'name': name, +- 'type': dns.rdatatype.to_text(q_rrset.rdtype), +- 'zones_deleted': False +- } +- recordset = self.storage.find_recordset(context, criterion) ++ rdtype = dns.rdatatype.to_text(q_rrset.rdtype) ++ ++ # Try to find the zone first using TSIG-based pool scoping. ++ # This handles split-horizon configurations where the same ++ # zone name exists in multiple pools. The TSIG key on the ++ # request determines which pool's zone to serve, matching ++ # the approach used by _handle_axfr. ++ zone = None ++ try: ++ zone_criterion = self._zone_criterion_from_request( ++ request, {'name': name}) ++ zone = self.storage.find_zone(context, zone_criterion) ++ except exceptions.ZoneNotFound: ++ # Query name may not be a zone name (e.g. a subdomain ++ # like www.example.com when the zone is example.com). ++ # Fall through to the recordset-first lookup below. ++ pass ++ ++ if zone: ++ # Zone found - look up the recordset within this ++ # specific zone. The zone_id scoping ensures we find ++ # the correct recordset even when multiple pools have ++ # zones with the same name. ++ criterion = { ++ 'zone_id': zone.id, ++ 'name': name, ++ 'type': rdtype, ++ } ++ recordset = self.storage.find_recordset( ++ context, criterion) ++ else: ++ # Could not match the query name to a zone directly. ++ # Fall back to finding the recordset by name and type, ++ # then verify the zone matches the TSIG key's pool. ++ criterion = { ++ 'name': name, ++ 'type': rdtype, ++ 'zones_deleted': False ++ } ++ recordset = self.storage.find_recordset( ++ context, criterion) ++ ++ zone_criterion = self._zone_criterion_from_request( ++ request, {'id': recordset.zone_id}) ++ zone = self.storage.find_zone(context, zone_criterion) + + except exceptions.NotFound: + # If an FQDN exists, like www.rackspace.com, but the specific +@@ -351,23 +388,6 @@ + yield self._handle_query_error(request, dns.rcode.REFUSED) + return + +- try: +- criterion = self._zone_criterion_from_request( +- request, {'id': recordset.zone_id}) +- zone = self.storage.find_zone(context, criterion) +- +- except exceptions.ZoneNotFound: +- LOG.warning('ZoneNotFound while handling query request. ' +- 'Question was %(qr)s', {'qr': q_rrset}) +- yield self._handle_query_error(request, dns.rcode.REFUSED) +- return +- +- except exceptions.Forbidden: +- LOG.warning('Forbidden while handling query request. ' +- 'Question was %(qr)s', {'qr': q_rrset}) +- yield self._handle_query_error(request, dns.rcode.REFUSED) +- return +- + r_rrset = self._convert_to_rrset(zone, recordset) + response.answer = [r_rrset] if r_rrset else [] + response.set_rcode(dns.rcode.NOERROR) +diff --git a/designate/tests/unit/mdns/test_handler.py b/designate/tests/unit/mdns/test_handler.py +index 135df01..dcaf3a7 100644 +--- a/designate/tests/unit/mdns/test_handler.py ++++ b/designate/tests/unit/mdns/test_handler.py +@@ -442,3 +442,115 @@ + + self.assertEqual(1, len(response)) + self.assertEqual(dns.rcode.REFUSED, response[0].rcode()) ++ ++ def test_handle_record_query_zone_first_with_tsig(self): ++ """Test zone-first lookup with POOL-scoped TSIG key. ++ ++ In split-horizon configurations the same zone name exists in ++ multiple pools. The TSIG key determines which pool's zone to ++ serve. When the query name matches a zone name the handler ++ should resolve the zone by name + pool_id first, then look up ++ the recordset within that zone. ++ """ ++ pool_id = 'c4f6ea1c-a1af-4401-a849-000000000001' ++ zone_id = 'e2bed4dc-9d01-11e4-89d3-123b93f75cba' ++ zone = objects.Zone( ++ id=zone_id, name='example.org.', pool_id=pool_id, ttl=3600, ++ ) ++ recordset = objects.RecordSet( ++ name='example.org.', type='SOA', ++ records=objects.RecordList(objects=[ ++ objects.Record( ++ data='ns1.example.org. admin.example.org. ' ++ '2024010100 3600 600 86400 3600'), ++ ]) ++ ) ++ self.storage.find_zone.return_value = zone ++ self.storage.find_recordset.return_value = recordset ++ ++ tsigkey = mock.Mock(scope='POOL', resource_id=pool_id) ++ ++ request = dns.message.make_query( ++ 'example.org.', dns.rdatatype.SOA) ++ request.environ = dict(context=self.context, tsigkey=tsigkey) ++ response = tuple(self.handler._handle_record_query(request)) ++ ++ self.assertEqual(1, len(response)) ++ self.assertEqual(dns.rcode.NOERROR, response[0].rcode()) ++ ++ # Zone looked up by name + pool_id (zone-first path) ++ self.storage.find_zone.assert_called_once_with( ++ self.context, ++ {'name': 'example.org.', 'pool_id': pool_id} ++ ) ++ # Recordset scoped to zone_id ++ self.storage.find_recordset.assert_called_once_with( ++ self.context, ++ {'zone_id': zone_id, 'name': 'example.org.', 'type': 'SOA'} ++ ) ++ ++ def test_handle_record_query_subdomain_with_tsig(self): ++ """Test recordset-first fallback for subdomain queries with TSIG. ++ ++ When the query is for a subdomain (www.example.org.) rather than ++ a zone apex, the zone-first lookup raises ZoneNotFound because ++ no zone is named 'www.example.org.'. The handler falls back to ++ finding the recordset by name, then verifies the zone matches ++ the TSIG key's pool. The overall query still succeeds. ++ """ ++ pool_id = 'c4f6ea1c-a1af-4401-a849-000000000001' ++ zone_id = 'e2bed4dc-9d01-11e4-89d3-123b93f75cba' ++ zone = objects.Zone( ++ id=zone_id, name='example.org.', pool_id=pool_id, ttl=3600, ++ ) ++ recordset = objects.RecordSet( ++ name='www.example.org.', type='A', zone_id=zone_id, ++ records=objects.RecordList(objects=[ ++ objects.Record(data='192.0.2.1'), ++ ]) ++ ) ++ ++ # First find_zone (name='www.example.org.') fails; ++ # second find_zone (id=zone_id) succeeds. ++ self.storage.find_zone.side_effect = [ ++ exceptions.ZoneNotFound, zone ++ ] ++ self.storage.find_recordset.return_value = recordset ++ ++ tsigkey = mock.Mock(scope='POOL', resource_id=pool_id) ++ ++ request = dns.message.make_query( ++ 'www.example.org.', dns.rdatatype.A) ++ request.environ = dict(context=self.context, tsigkey=tsigkey) ++ response = tuple(self.handler._handle_record_query(request)) ++ ++ self.assertEqual(1, len(response)) ++ self.assertEqual(dns.rcode.NOERROR, response[0].rcode()) ++ ++ self.assertEqual(2, self.storage.find_zone.call_count) ++ # Recordset found via fallback path (no zone_id scoping) ++ self.storage.find_recordset.assert_called_once_with( ++ self.context, ++ {'name': 'www.example.org.', 'type': 'A', ++ 'zones_deleted': False} ++ ) ++ ++ def test_handle_record_query_zone_found_recordset_not_found(self): ++ """Test REFUSED when zone exists but recordset type does not.""" ++ pool_id = 'c4f6ea1c-a1af-4401-a849-000000000001' ++ zone = objects.Zone( ++ id='e2bed4dc-9d01-11e4-89d3-123b93f75cba', ++ name='example.org.', pool_id=pool_id, ttl=3600, ++ ) ++ self.storage.find_zone.return_value = zone ++ self.storage.find_recordset.side_effect = exceptions.NotFound ++ ++ tsigkey = mock.Mock(scope='POOL', resource_id=pool_id) ++ ++ request = dns.message.make_query( ++ 'example.org.', dns.rdatatype.MX) ++ request.environ = dict(context=self.context, tsigkey=tsigkey) ++ response = tuple(self.handler._handle_record_query(request)) ++ ++ self.assertEqual(1, len(response)) ++ self.assertEqual(dns.rcode.REFUSED, response[0].rcode()) +diff --git a/releasenotes/notes/fix-mdns-record-query-pool-scoping-9b035369.yaml b/releasenotes/notes/fix-mdns-record-query-pool-scoping-9b035369.yaml +new file mode 100644 +index 0000000..ba4c7d9 +--- /dev/null ++++ b/releasenotes/notes/fix-mdns-record-query-pool-scoping-9b035369.yaml +@@ -0,0 +1,9 @@ ++--- ++fixes: ++ - | ++ Fixed mDNS ``_handle_record_query`` to use TSIG-based pool scoping when ++ looking up SOA and other record queries. Previously, when the same zone ++ name existed in multiple pools (e.g. split-horizon DNS), the handler ++ would find multiple matching recordsets and return REFUSED. The handler ++ now resolves the zone first using the TSIG key's pool_id, then looks up ++ the recordset within that zone. diff -Nru designate-20.0.0/debian/patches/Require_TSIG_keys_for_zones_in_non-default_pools.patch designate-20.0.0/debian/patches/Require_TSIG_keys_for_zones_in_non-default_pools.patch --- designate-20.0.0/debian/patches/Require_TSIG_keys_for_zones_in_non-default_pools.patch 1970-01-01 00:00:00.000000000 +0000 +++ designate-20.0.0/debian/patches/Require_TSIG_keys_for_zones_in_non-default_pools.patch 2026-08-06 08:25:23.000000000 +0000 @@ -0,0 +1,403 @@ +Description: Require TSIG keys for zones in non-default pools + Add validation during zone creation and pool moves to ensure that + non-default pools have TSIG keys configured. Without TSIG, MDNS + defaults to searching only in the default pool, causing zones in + other pools to fail with 'ZoneNotFound' errors during AXFR, + leaving them stuck in ERROR status. + . + This provides fail-fast behavior with a clear error message, + rather than allowing zones to silently fail during synchronization. + . + Note: this does not change existing API behavior for the user as + the zone would get into ERROR eventually without a TSIG key. + . + Documentation has been added to explain the TSIG requirement for + multi-pool deployments. +Author: Omer +Date: Fri, 19 Dec 2025 18:42:32 -0300 +Bug: #2008693 +Bug-Debian: https://bugs.debian.org/1144145 +Assisted-By: Claude Code 4.5 Sonnet +Change-Id: I5a2a18903e2bdc6a7b2202203ef3d41a0c6a7849 +Signed-off-by: Omer +Origin: upstream, pre-OSSA mailing list +Last-Update: 2026-08-11 + +Index: designate/designate/central/service.py +=================================================================== +--- designate.orig/designate/central/service.py ++++ designate/designate/central/service.py +@@ -352,6 +352,23 @@ class Service(service.RPCService): + "A project ID must be specified when not using a project " + "scoped token.") + ++ def _validate_pool_tsig_key(self, context, pool_id): ++ if pool_id == CONF['service:central'].default_pool_id: ++ return ++ criterion = {'scope': 'POOL', 'resource_id': pool_id} ++ try: ++ self.storage.find_tsigkey(context, criterion) ++ except exceptions.TsigKeyNotFound: ++ pool = self.storage.get_pool(context, pool_id) ++ raise exceptions.BadRequest( ++ 'Zones in non-default pools require a TSIG key with ' ++ 'scope=POOL for zone transfers. Pool "%s" (ID: %s) does ' ++ 'not have a TSIG key configured. Please create a TSIG ' ++ 'key for this pool and configure the same key in the ' ++ 'backend nameservers before creating zones.' % ++ (pool.name, pool_id) ++ ) ++ + # SOA Recordset Methods + @staticmethod + def _build_soa_record(zone, ns_records): +@@ -752,6 +769,8 @@ class Service(service.RPCService): + # Get a pool id + zone.pool_id = self.scheduler.schedule_zone(context, zone) + ++ self._validate_pool_tsig_key(context, zone.pool_id) ++ + # Handle sub-zones appropriately + parent_zone = self._is_subzone( + context, zone.name, zone.pool_id) +@@ -1316,6 +1335,8 @@ class Service(service.RPCService): + except exceptions.PoolNotFound: + raise exceptions.BadRequest('Target pool does not exist') + ++ self._validate_pool_tsig_key(context, target_pool_id) ++ + target_pool_ns_records = self._get_pool_ns_records(context, + target_pool_id) + if len(target_pool_ns_records) == 0: +Index: designate/designate/tests/functional/central/test_service.py +=================================================================== +--- designate.orig/designate/tests/functional/central/test_service.py ++++ designate/designate/tests/functional/central/test_service.py +@@ -495,11 +495,48 @@ class CentralServiceTest(designate.tests + + # Create a secondary pool + second_pool = self.create_pool() ++ self.create_tsigkey(scope='POOL', resource_id=second_pool.id) + fixture["attributes"] = {} + fixture["attributes"]["pool_id"] = second_pool.id + + self.create_zone(**fixture) + ++ def test_create_zone_non_default_pool_without_tsig(self): ++ """Test that zone creation in non-default pool without TSIG fails""" ++ fixture = self.get_zone_fixture() ++ ++ # Create a secondary pool without a TSIG key ++ second_pool = self.create_pool() ++ ++ # Attempt to create a zone in the pool without TSIG ++ fixture["attributes"] = {} ++ fixture["attributes"]["pool_id"] = second_pool.id ++ ++ # Should raise BadRequest exception ++ exc = self.assertRaises( ++ rpc_dispatcher.ExpectedException, ++ self.central_service.create_zone, ++ self.admin_context, ++ objects.Zone.from_dict(fixture) ++ ) ++ self.assertEqual(exceptions.BadRequest, exc.exc_info[0]) ++ self.assertIn('TSIG key', str(exc.exc_info[1])) ++ ++ def test_create_zone_non_default_pool_with_tsig(self): ++ """Test that zone creation in non-default pool with TSIG succeeds""" ++ fixture = self.get_zone_fixture() ++ ++ # Create a secondary pool and TSIG key for it ++ second_pool = self.create_pool() ++ self.create_tsigkey(scope='POOL', resource_id=second_pool.id) ++ ++ # Create a zone in the pool with TSIG ++ fixture["attributes"] = {} ++ fixture["attributes"]["pool_id"] = second_pool.id ++ ++ zone = self.create_zone(**fixture) ++ self.assertEqual(second_pool.id, zone.pool_id) ++ + def test_create_zone_over_tld(self): + values = dict( + name='example.com.', +@@ -590,6 +627,7 @@ class CentralServiceTest(designate.tests + + # Create a secondary pool + second_pool = self.create_pool() ++ self.create_tsigkey(scope='POOL', resource_id=second_pool.id) + fixture["attributes"] = {} + fixture["attributes"]["pool_id"] = second_pool.id + fixture["name"] = "sub.%s" % fixture["name"] +@@ -3025,6 +3063,7 @@ class CentralServiceTest(designate.tests + def test_update_pool_add_ns_record(self): + # Create a server pool and 3 zones + pool = self.create_pool(fixture=0) ++ self.create_tsigkey(scope='POOL', resource_id=pool.id) + zone = self.create_zone( + attributes=[{'key': 'pool_id', 'value': pool.id}]) + self.create_zone( +@@ -3069,6 +3108,7 @@ class CentralServiceTest(designate.tests + + def test_update_pool_add_ns_record_without_priority(self): + pool = self.create_pool(fixture=0) ++ self.create_tsigkey(scope='POOL', resource_id=pool.id) + self.create_zone(pool_id=pool.id) + new_ns_record = objects.PoolNsRecord(hostname='ns-new.example.org.') + pool.ns_records.append(new_ns_record) +@@ -3079,6 +3119,7 @@ class CentralServiceTest(designate.tests + def test_update_pool_remove_ns_record(self): + # Create a server pool and zone + pool = self.create_pool(fixture=0) ++ self.create_tsigkey(scope='POOL', resource_id=pool.id) + zone = self.create_zone( + attributes=[{'key': 'pool_id', 'value': pool.id}]) + +@@ -4274,6 +4315,7 @@ class CentralServiceTest(designate.tests + + def test_pool_move_zone(self): + pool = self.create_pool(fixture=0) ++ self.create_tsigkey(scope='POOL', resource_id=pool.id) + zone = self.create_zone(context=self.admin_context, pool_id=pool.id) + self.storage.create_pool_ns_record( + self.admin_context, pool['id'], +@@ -4282,6 +4324,8 @@ class CentralServiceTest(designate.tests + + # create second pool + second_pool = self.create_pool(fixture=1) ++ self.create_tsigkey(name='test-key-second-pool', scope='POOL', ++ resource_id=second_pool.id) + self.storage.create_pool_ns_record( + self.admin_context, second_pool['id'], + objects.PoolNsRecord(priority=1, hostname='ns-new.example.org.') +@@ -4293,6 +4337,25 @@ class CentralServiceTest(designate.tests + self.assertEqual(zone.id, moved_zone.id) + self.assertEqual(moved_zone.pool_id, second_pool['id']) + ++ def test_pool_move_zone_without_tsig(self): ++ """Test that moving a zone to a non-default pool without TSIG fails""" ++ zone = self.create_zone() ++ ++ second_pool = self.create_pool(fixture=1) ++ self.storage.create_pool_ns_record( ++ self.admin_context, second_pool['id'], ++ objects.PoolNsRecord(priority=1, hostname='ns-new.example.org.') ++ ) ++ ++ exc = self.assertRaises( ++ rpc_dispatcher.ExpectedException, ++ self.central_service.pool_move_zone, ++ self.admin_context, ++ zone.id, second_pool['id'] ++ ) ++ self.assertEqual(exceptions.BadRequest, exc.exc_info[0]) ++ self.assertIn('TSIG key', str(exc.exc_info[1])) ++ + def test_pool_move_zone_no_valid_pool_selected(self): + pool_id = '794ccc2c-d751-44fe-b57f-8894c9f5c842' + zone = self.create_zone(fixture=0, pool_id=pool_id) +@@ -4307,10 +4370,13 @@ class CentralServiceTest(designate.tests + + def test_pool_move_zone_without_target_pool(self): + pool = self.create_pool(fixture=0) ++ self.create_tsigkey(scope='POOL', resource_id=pool.id) + zone = self.create_zone(context=self.admin_context, pool_id=pool.id) + + # create second pool + second_pool = self.create_pool(fixture=1) ++ self.create_tsigkey(name='test-key-second-pool', scope='POOL', ++ resource_id=second_pool.id) + new_ns_record = objects.PoolNsRecord(hostname='ns-new.example.org.') + second_pool.ns_records.append(new_ns_record) + +@@ -4325,10 +4391,13 @@ class CentralServiceTest(designate.tests + + def test_pool_move_zone_exception_no_ns_records(self): + pool = self.create_pool(fixture=0) ++ self.create_tsigkey(scope='POOL', resource_id=pool.id) + zone = self.create_zone(context=self.admin_context, pool_id=pool.id) + + # create second pool + second_pool = self.create_pool(fixture=1) ++ self.create_tsigkey(name='test-key-second-pool', scope='POOL', ++ resource_id=second_pool.id) + + zone.pool_id = second_pool['id'] + with mock.patch.object(self.central_service, '_get_pool_ns_records', +@@ -4340,6 +4409,7 @@ class CentralServiceTest(designate.tests + + def test_pool_move_zone_exception_invalid_pool_id(self): + pool = self.create_pool(fixture=0) ++ self.create_tsigkey(scope='POOL', resource_id=pool.id) + zone = self.create_zone(context=self.admin_context, pool_id=pool.id) + + # Use fake pool ID +@@ -4608,6 +4678,7 @@ class CentralServiceTest(designate.tests + + def test_create_catalog_member_zone(self): + pool = self.create_pool(fixture=2) ++ self.create_tsigkey(scope='POOL', resource_id=pool.id) + + self.storage._ensure_catalog_zone_config(self.admin_context, pool) + catalog_zone = self.storage.get_catalog_zone(self.admin_context, pool) +@@ -4625,6 +4696,7 @@ class CentralServiceTest(designate.tests + + def test_update_catalog_member_zone(self): + pool = self.create_pool(fixture=2) ++ self.create_tsigkey(scope='POOL', resource_id=pool.id) + + self.storage._ensure_catalog_zone_config(self.admin_context, pool) + catalog_zone = self.storage.get_catalog_zone(self.admin_context, pool) +@@ -4652,6 +4724,7 @@ class CentralServiceTest(designate.tests + + def test_delete_catalog_member_zone(self): + pool = self.create_pool(fixture=2) ++ self.create_tsigkey(scope='POOL', resource_id=pool.id) + + self.storage._ensure_catalog_zone_config(self.admin_context, pool) + catalog_zone = self.storage.get_catalog_zone(self.admin_context, pool) +Index: designate/designate/tests/functional/manage/test_pool.py +=================================================================== +--- designate.orig/designate/tests/functional/manage/test_pool.py ++++ designate/designate/tests/functional/manage/test_pool.py +@@ -283,6 +283,14 @@ class ManagePoolTestCase(designate.tests + self.command._setup() + self.command._create_pool(get_pools()[0]) + ++ # Create TSIG key for the pool to support zone creation validation ++ created_pool = self.central_service.find_pool( ++ self.admin_context, {'name': 'default'}) ++ self.create_tsigkey( ++ scope='POOL', ++ resource_id=created_pool.id ++ ) ++ + self.create_zone(fixture=0) + self.create_zone(fixture=1) + +Index: designate/designate/tests/functional/test_storage.py +=================================================================== +--- designate.orig/designate/tests/functional/test_storage.py ++++ designate/designate/tests/functional/test_storage.py +@@ -4023,6 +4023,7 @@ class SqlalchemyStorageTest(designate.te + + def test_get_catalog_zone_records(self): + pool = self.create_pool(fixture=2) ++ self.create_tsigkey(scope='POOL', resource_id=pool.id) + self.storage._ensure_catalog_zone_config(self.admin_context, pool) + member_zone = self.create_zone( + attributes=[{'key': 'pool_id', 'value': pool.id}]) +Index: designate/doc/source/admin/multiple-pools.rst +=================================================================== +--- designate.orig/doc/source/admin/multiple-pools.rst ++++ designate/doc/source/admin/multiple-pools.rst +@@ -174,6 +174,91 @@ In Designate, you can show the current c + You can either see a different pool by adding --pool_id , or you can + see all the configured pools by adding ``--all_pools`` or just ``--all``. + ++Configuring TSIG Keys for Non-Default Pools ++============================================ ++ ++.. important:: ++ ++ Non-default pools require TSIG (Transaction Signature) keys for zone ++ transfers to function correctly. Without TSIG keys, zones in non-default ++ pools will fail to synchronize with backend nameservers. ++ ++Why TSIG Keys Are Required ++--------------------------- ++ ++When backend nameservers request zone transfers via AXFR from Designate's ++MiniDNS service, MDNS needs to identify which pool the requested zone belongs ++to. MDNS uses TSIG authentication to make this determination: ++ ++* **With TSIG**: MDNS uses the TSIG key's resource_id to identify the pool ++ and retrieve the correct zone. ++* **Without TSIG**: MDNS defaults to searching only in the default pool, ++ causing zones in other pools to fail with "ZoneNotFound" errors. ++ ++Creating and Configuring TSIG Keys ++----------------------------------- ++ ++The process involves generating the TSIG key, configuring it in backend ++nameservers, and registering it in Designate's database. MiniDNS reads ++TSIG keys directly from the Designate database, so no separate MDNS ++configuration is needed. ++ ++#. Generate a TSIG key using ``tsig-keygen``: ++ ++ .. code-block:: bash ++ ++ sudo tsig-keygen -a hmac-sha256 > ++ ++ This creates a file with content similar to: ++ ++ .. code-block:: text ++ ++ key "" { ++ algorithm hmac-sha256; ++ secret "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; ++ }; ++ ++#. Configure the TSIG key in your backend nameservers. For BIND9, include ++ the key in ``named.conf`` and configure it for use with MDNS: ++ ++ .. code-block:: text ++ ++ include ""; ++ ++ server { ++ keys { ; }; ++ }; ++ ++#. Create the TSIG key in Designate's database using the API: ++ ++ .. code-block:: bash ++ ++ # Extract the secret from the key file ++ SECRET=$(grep secret | awk '{print $2}' | tr -d '";') ++ ++ # Create the TSIG key in Designate for your pool ++ openstack tsigkey create \ ++ --name \ ++ --algorithm hmac-sha256 \ ++ --secret "$SECRET" \ ++ --scope POOL \ ++ --resource-id ++ ++#. Verify the TSIG key is properly configured: ++ ++ .. code-block:: bash ++ ++ # List TSIG keys ++ openstack tsigkey list ++ ++ # Verify zones can now be created in the non-default pool ++ openstack zone create --email admin@example.com \ ++ --attributes pool_id: \ ++ example.com. ++ ++Without completing all these steps, zones created in non-default pools will ++remain in ERROR status, unable to synchronize with backend nameservers. ++ + Configuring the Pool Scheduler + ============================== + +Index: designate/releasenotes/notes/require-tsig-for-non-default-pools-2008693.yaml +=================================================================== +--- /dev/null ++++ designate/releasenotes/notes/require-tsig-for-non-default-pools-2008693.yaml +@@ -0,0 +1,12 @@ ++--- ++other: ++ - | ++ [`bug 2008693 `_] ++ Added validation to require TSIG keys for zones in non-default pools. ++ When backend nameservers request zone transfers from MDNS without TSIG ++ authentication, MDNS defaults to searching only in the default pool, ++ causing zones in other pools to fail with "ZoneNotFound" errors. The new ++ validation prevents this by rejecting zone creation and pool moves to ++ non-default pools when no TSIG key with scope=POOL exists for that pool. ++ Documentation has been added explaining how to properly configure TSIG ++ keys for multi-pool deployments. diff -Nru designate-20.0.0/debian/patches/add-new-floatingip-handler.patch designate-20.0.0/debian/patches/add-new-floatingip-handler.patch --- designate-20.0.0/debian/patches/add-new-floatingip-handler.patch 2025-04-05 12:01:54.000000000 +0000 +++ designate-20.0.0/debian/patches/add-new-floatingip-handler.patch 2026-08-06 08:25:23.000000000 +0000 @@ -84,7 +84,7 @@ =================================================================== --- designate.orig/setup.cfg +++ designate/setup.cfg -@@ -72,6 +72,7 @@ designate.notification.handler = +@@ -71,6 +71,7 @@ designate.notification.handler = fake = designate.notification_handler.fake:FakeHandler nova_fixed = designate.notification_handler.nova:NovaFixedHandler neutron_floatingip = designate.notification_handler.neutron:NeutronFloatingHandler diff -Nru designate-20.0.0/debian/patches/series designate-20.0.0/debian/patches/series --- designate-20.0.0/debian/patches/series 2025-04-05 12:01:54.000000000 +0000 +++ designate-20.0.0/debian/patches/series 2026-08-06 08:25:23.000000000 +0000 @@ -2,3 +2,6 @@ removed-httpdomain-sphinx-ext.patch add-new-floatingip-handler.patch fix-regex-to-create-floating-ptr.patch +Require_TSIG_keys_for_zones_in_non-default_pools.patch +Fix_mDNS_record_query_pool_scoping_for_split-horizon_DNS.patch +CVE-2026-71193_CVE-2026-71194_Fix_cross-tenant_cross-pool_zone_ownership_bypass.patch