From 984713374104ac5c315ec415aa1a7f60d5d29f29 Mon Sep 17 00:00:00 2001 From: iyashnov Date: Tue, 3 Mar 2026 17:17:31 +0300 Subject: [PATCH 1/6] add: added DNS migration manager --- .../bind_to_pdns_migrations_manager.py | 150 ++++++++++++++++++ app/multidirectory.py | 24 +++ 2 files changed, 174 insertions(+) create mode 100644 app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py diff --git a/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py b/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py new file mode 100644 index 000000000..33a897e1b --- /dev/null +++ b/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py @@ -0,0 +1,150 @@ +"""Manager for migrating from BIND to PowerDNS. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +import os + +import dns.zone +from loguru import logger + +from ldap_protocol.dns.dto import ( + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRecordDTO, + DNSRRSetDTO, + DNSSettingsDTO, +) +from ldap_protocol.dns.enums import DNSRecordType +from ldap_protocol.dns.managers.power_dns_manager import PowerDNSManager + + +class BindToPDNSMigrationManager: + bind_zone_file_dir: str = "/opt/" + bind_config_files_dir: str = "/etc/bind/" + + def __init__( + self, + pdns_manager: PowerDNSManager, + dns_settings: DNSSettingsDTO, + ) -> None: + self.pdns_manager = pdns_manager + self.dns_settings = dns_settings + + def parse_bind_config_file( + self, + ) -> tuple[list[DNSMasterZoneDTO], list[DNSForwardZoneDTO]]: + """Parse BIND configuration files to extract zone information.""" + master_zones: list[DNSMasterZoneDTO] = [] + forward_zones: list[DNSForwardZoneDTO] = [] + + with open( + os.path.join(self.bind_config_files_dir, "named.conf.local"), + ) as f: + for line in f: + line = line.strip() + if line.startswith("zone"): + parts = line.split() + if len(parts) >= 2: + zone_name = parts[1].strip('"') + continue + + if "type master" in line: + master_zones.append( + DNSMasterZoneDTO( + id=zone_name, + name=zone_name, + ), + ) + elif "type forward" in line: + forward_zones.append( + DNSForwardZoneDTO( + id=zone_name, + name=zone_name, + ), + ) + + return master_zones, forward_zones + + def parse_zones_records( + self, + master_zones: list[DNSMasterZoneDTO], + ) -> list[DNSMasterZoneDTO]: + """Parse zone files to extract DNS records.""" + for zone in master_zones: + zone_rrsets: list[DNSRRSetDTO] = [] + zone_file_path = os.path.join( + self.bind_zone_file_dir, + f"{zone.name}.zone", + ) + zone_obj = dns.zone.from_file(zone_file_path, origin=zone.name) + for name, ttl, rdata in zone_obj.iterate_rdatas(): + zone_rrsets.append( + DNSRRSetDTO( + name=name.to_text(), + type=DNSRecordType(rdata.rdtype.name), + records=[ + DNSRecordDTO( + content=rdata.to_text(), + disabled=False, + ), + ], + ttl=ttl, + ), + ) + zone.rrsets = zone_rrsets + + return master_zones + + async def get_bind_zones( + self, + ) -> tuple[list[DNSMasterZoneDTO], list[DNSForwardZoneDTO]]: + """Get zones from BIND.""" + master_zones, forward_zones = self.parse_bind_config_file() + master_zones = self.parse_zones_records(master_zones) + + return master_zones, forward_zones + + async def migrate_from_bind(self) -> None: + """Migrate from BIND to PowerDNS.""" + master_zones, forward_zones = await self.get_bind_zones() + + for master_zone in master_zones: + await self.pdns_manager.create_master_zone( + master_zone, + is_empty=True, + ) + for rrset in master_zone.rrsets: + await self.pdns_manager.create_record( + master_zone.name, + rrset, + ) + + for forward_zone in forward_zones: + await self.pdns_manager.create_forward_zone(forward_zone) + + open(os.path.join(self.bind_zone_file_dir, "migrated"), "a").close() + open(os.path.join(self.bind_config_files_dir, "migrated"), "a").close() + + def is_migration_needed(self) -> bool: + """Check if migration is needed.""" + return not ( + os.path.exists(os.path.join(self.bind_zone_file_dir, "migrated")) + and os.path.exists( + os.path.join(self.bind_config_files_dir, "migrated"), + ) + ) and bool(os.listdir(self.bind_zone_file_dir)) + + async def migrate(self) -> None: + """Migrate from BIND to PowerDNS.""" + if not self.is_migration_needed(): + logger.info("BIND to PowerDNS migration is not needed, exiting...") + return + + logger.info("Starting BIND to PowerDNS migration...") + await self.pdns_manager.setup(self.dns_settings) + logger.info(f"{self.dns_settings}") + + await self.migrate_from_bind() + return diff --git a/app/multidirectory.py b/app/multidirectory.py index 22a19259d..5e7f4d403 100644 --- a/app/multidirectory.py +++ b/app/multidirectory.py @@ -50,6 +50,9 @@ MFAProvider, ) from ldap_protocol.dependency import resolve_deps +from ldap_protocol.dns.managers.bind_to_pdns_migrations_manager import ( + BindToPDNSMigrationManager, +) from ldap_protocol.identity.exceptions import UnauthorizedError from ldap_protocol.policies.audit.events.handler import AuditEventHandler from ldap_protocol.policies.audit.events.sender import AuditEventSenderManager @@ -287,6 +290,19 @@ async def event_sender_factory(settings: Settings) -> None: await asyncio.gather(manager.run()) +async def migrate_dns_factory(settings: Settings) -> None: + """Run DNS migration.""" + main_container = make_async_container( + MainProvider(), + EventSenderProvider(), + context={Settings: settings}, + ) + + async with main_container(scope=Scope.REQUEST) as container: + manager = await container.get(BindToPDNSMigrationManager) + await asyncio.gather(manager.migrate()) + + ldap = partial(run_entrypoint, factory=ldap_factory) cldap = partial(run_entrypoint, factory=cldap_factory) global_ldap_server = partial( @@ -297,6 +313,7 @@ async def event_sender_factory(settings: Settings) -> None: create_shadow_app = partial(create_prod_app, factory=_create_shadow_app) event_handler = partial(run_entrypoint, factory=event_handler_factory) event_sender = partial(run_entrypoint, factory=event_sender_factory) +dns_migration = partial(run_entrypoint, factory=migrate_dns_factory) if __name__ == "__main__": @@ -334,6 +351,11 @@ async def event_sender_factory(settings: Settings) -> None: action="store_true", help="Make migrations", ) + group.add_argument( + "--migrate_dns", + action="store_true", + help="Migrate DNS from BIND to PowerDNS", + ) args = parser.parse_args() @@ -376,3 +398,5 @@ async def event_sender_factory(settings: Settings) -> None: dump_acme_cert() elif args.migrate: command.upgrade(Config("alembic.ini"), "head") + elif args.migrate_dns: + dns_migration(settings=settings) From 93154947a0fd3e32773a026d703c05671cfe6019 Mon Sep 17 00:00:00 2001 From: iyashnov Date: Tue, 3 Mar 2026 17:22:58 +0300 Subject: [PATCH 2/6] add: migration manager added to ioc --- app/ioc.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/ioc.py b/app/ioc.py index 5673a37ba..a535490ea 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -65,6 +65,9 @@ RemoteDNSManager, StubDNSManager, ) +from ldap_protocol.dns.managers.bind_to_pdns_migrations_manager import ( + BindToPDNSMigrationManager, +) from ldap_protocol.identity import IdentityProvider from ldap_protocol.identity.provider_gateway import IdentityProviderGateway from ldap_protocol.kerberos import AbstractKadmin, get_kerberos_class @@ -334,6 +337,25 @@ async def get_dns_mngr( else: yield StubDNSManager(settings=dns_settings) + @provide(scope=Scope.REQUEST) + async def get_dns_mgrt_mngr( + self, + dns_settings: DNSSettingsDTO, + power_dns_auth_client: PowerDNSAuthHTTPClient, + power_dns_recursor_client: PowerDNSRecursorHTTPClient, + power_dns_dist_client: PowerDNSDistClient, + ) -> AsyncIterator[BindToPDNSMigrationManager]: + """Get migration manager class.""" + yield BindToPDNSMigrationManager( + PowerDNSManager( + settings=dns_settings, + power_dns_auth_client=power_dns_auth_client, + power_dns_recursor_client=power_dns_recursor_client, + dnsdist_client=power_dns_dist_client, + ), + dns_settings=dns_settings, + ) + @provide(scope=Scope.APP) async def get_redis_for_sessions( self, From 24acf52cafdf83346b6b170449d164595d1aa8a6 Mon Sep 17 00:00:00 2001 From: iyashnov Date: Tue, 3 Mar 2026 17:24:02 +0300 Subject: [PATCH 3/6] add: added DNS migration to prod docker compose --- .package/docker-compose.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.package/docker-compose.yml b/.package/docker-compose.yml index 44415d5e9..163449ddc 100644 --- a/.package/docker-compose.yml +++ b/.package/docker-compose.yml @@ -87,6 +87,28 @@ services: postgres: condition: service_healthy + dns_migration: + image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} + container_name: multidirectory_dns_migration + networks: + md_net: + restart: "no" + volumes: + - ./app:/app + - dns_server_file:/opt/ + - dns_server_config:/etc/bind/ + env_file: local.env + command: python multidirectory.py --migrate_dns + depends_on: + migrations: + condition: service_completed_successfully + pdns_auth: + condition: service_started + pdns_recursor: + condition: service_started + pdnsdist: + condition: service_started + ldap_server: image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} networks: From 9deb9b085d0bc65f17f71f57c29cdff632f08cd9 Mon Sep 17 00:00:00 2001 From: iyashnov Date: Tue, 3 Mar 2026 17:57:02 +0300 Subject: [PATCH 4/6] refactor: update DNS migration manager and related configurations --- .package/docker-compose.yml | 3 +-- app/ioc.py | 4 ++-- .../dns/managers/abstract_dns_manager.py | 1 + .../bind_to_pdns_migrations_manager.py | 16 ++++++++++++++-- .../dns/managers/power_dns_manager.py | 19 ++++++++++++------- .../dns/managers/remote_dns_manager.py | 1 + .../dns/managers/stub_dns_manager.py | 1 + app/multidirectory.py | 3 +-- 8 files changed, 33 insertions(+), 15 deletions(-) diff --git a/.package/docker-compose.yml b/.package/docker-compose.yml index 163449ddc..0e12c6d8c 100644 --- a/.package/docker-compose.yml +++ b/.package/docker-compose.yml @@ -94,10 +94,9 @@ services: md_net: restart: "no" volumes: - - ./app:/app - dns_server_file:/opt/ - dns_server_config:/etc/bind/ - env_file: local.env + env_file: .env command: python multidirectory.py --migrate_dns depends_on: migrations: diff --git a/app/ioc.py b/app/ioc.py index a535490ea..b8953d7d4 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -338,14 +338,14 @@ async def get_dns_mngr( yield StubDNSManager(settings=dns_settings) @provide(scope=Scope.REQUEST) - async def get_dns_mgrt_mngr( + async def get_dns_migration_manager( self, dns_settings: DNSSettingsDTO, power_dns_auth_client: PowerDNSAuthHTTPClient, power_dns_recursor_client: PowerDNSRecursorHTTPClient, power_dns_dist_client: PowerDNSDistClient, ) -> AsyncIterator[BindToPDNSMigrationManager]: - """Get migration manager class.""" + """Get DNS migration manager class.""" yield BindToPDNSMigrationManager( PowerDNSManager( settings=dns_settings, diff --git a/app/ldap_protocol/dns/managers/abstract_dns_manager.py b/app/ldap_protocol/dns/managers/abstract_dns_manager.py index bfe7a79d6..de07c9de4 100644 --- a/app/ldap_protocol/dns/managers/abstract_dns_manager.py +++ b/app/ldap_protocol/dns/managers/abstract_dns_manager.py @@ -77,6 +77,7 @@ async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: ... async def create_master_zone( self, zone: DNSMasterZoneDTO, + is_empty: bool = False, ) -> None: ... @abstractmethod diff --git a/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py b/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py index 33a897e1b..63be1e8ad 100644 --- a/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py +++ b/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py @@ -78,8 +78,20 @@ def parse_zones_records( self.bind_zone_file_dir, f"{zone.name}.zone", ) - zone_obj = dns.zone.from_file(zone_file_path, origin=zone.name) + zone_obj = dns.zone.from_file( + zone_file_path, + origin=zone.name, + relativize=False, + ) for name, ttl, rdata in zone_obj.iterate_rdatas(): + try: + DNSRecordType(rdata.rdtype.name) + except ValueError: + logger.warning( + f"Unsupported DNS record type {rdata.rdtype.name} in zone '{zone.name}'", # noqa: E501 + ) + continue + zone_rrsets.append( DNSRRSetDTO( name=name.to_text(), @@ -144,7 +156,7 @@ async def migrate(self) -> None: logger.info("Starting BIND to PowerDNS migration...") await self.pdns_manager.setup(self.dns_settings) - logger.info(f"{self.dns_settings}") await self.migrate_from_bind() + logger.info("Migration successful") return diff --git a/app/ldap_protocol/dns/managers/power_dns_manager.py b/app/ldap_protocol/dns/managers/power_dns_manager.py index 551efbdb9..2c19f3435 100644 --- a/app/ldap_protocol/dns/managers/power_dns_manager.py +++ b/app/ldap_protocol/dns/managers/power_dns_manager.py @@ -157,17 +157,22 @@ async def delete_record(self, zone_id: str, record: DNSRRSetDTO) -> None: raise DNSRecordDeleteError(f"Failed to delete DNS record: {e}") @logger_wraps() - async def create_master_zone(self, zone: DNSMasterZoneDTO) -> None: + async def create_master_zone( + self, + zone: DNSMasterZoneDTO, + is_empty: bool = False, + ) -> None: """Create a master DNS zone.""" zone.name = self._normalize_dns_name(zone.name) - zone.nameservers.append(f"ns1.{zone.name}") + if not is_empty: + zone.nameservers.append(f"ns1.{zone.name}") - records = await create_initial_zone_records( - zone.name, - self._dns_settings.default_nameserver, - ) - zone.rrsets.extend(records) + records = await create_initial_zone_records( + zone.name, + self._dns_settings.default_nameserver, + ) + zone.rrsets.extend(records) try: await self._power_dns_auth_client.create_master_zone(zone) diff --git a/app/ldap_protocol/dns/managers/remote_dns_manager.py b/app/ldap_protocol/dns/managers/remote_dns_manager.py index 73f4aae00..1b09d0864 100644 --- a/app/ldap_protocol/dns/managers/remote_dns_manager.py +++ b/app/ldap_protocol/dns/managers/remote_dns_manager.py @@ -150,6 +150,7 @@ async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: async def create_master_zone( self, zone: DNSMasterZoneDTO, # noqa: ARG002 + is_empty: bool = False, # noqa: ARG002 ) -> None: raise DNSNotImplementedError diff --git a/app/ldap_protocol/dns/managers/stub_dns_manager.py b/app/ldap_protocol/dns/managers/stub_dns_manager.py index f07ae1e4c..707d96888 100644 --- a/app/ldap_protocol/dns/managers/stub_dns_manager.py +++ b/app/ldap_protocol/dns/managers/stub_dns_manager.py @@ -65,6 +65,7 @@ async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: async def create_master_zone( self, zone: DNSMasterZoneDTO, + is_empty: bool = False, ) -> None: ... @logger_wraps(is_stub=True) diff --git a/app/multidirectory.py b/app/multidirectory.py index 5e7f4d403..85ed6d3dc 100644 --- a/app/multidirectory.py +++ b/app/multidirectory.py @@ -294,13 +294,12 @@ async def migrate_dns_factory(settings: Settings) -> None: """Run DNS migration.""" main_container = make_async_container( MainProvider(), - EventSenderProvider(), context={Settings: settings}, ) async with main_container(scope=Scope.REQUEST) as container: manager = await container.get(BindToPDNSMigrationManager) - await asyncio.gather(manager.migrate()) + await manager.migrate() ldap = partial(run_entrypoint, factory=ldap_factory) From 16970e02f45471dee3690942477e732d6cc6c5e4 Mon Sep 17 00:00:00 2001 From: iyashnov Date: Tue, 3 Mar 2026 19:02:50 +0300 Subject: [PATCH 5/6] add: added is_migration flag to setup DNS method to skip zone creating --- .package/docker-compose.yml | 1 + .../dns/managers/abstract_dns_manager.py | 1 + .../bind_to_pdns_migrations_manager.py | 2 +- .../dns/managers/power_dns_manager.py | 56 ++++++++++--------- .../dns/managers/remote_dns_manager.py | 1 + .../dns/managers/stub_dns_manager.py | 1 + 6 files changed, 36 insertions(+), 26 deletions(-) diff --git a/.package/docker-compose.yml b/.package/docker-compose.yml index 0e12c6d8c..64f8b4a7d 100644 --- a/.package/docker-compose.yml +++ b/.package/docker-compose.yml @@ -96,6 +96,7 @@ services: volumes: - dns_server_file:/opt/ - dns_server_config:/etc/bind/ + - dnsdist_confd:/dnsdist env_file: .env command: python multidirectory.py --migrate_dns depends_on: diff --git a/app/ldap_protocol/dns/managers/abstract_dns_manager.py b/app/ldap_protocol/dns/managers/abstract_dns_manager.py index de07c9de4..90cf0a14e 100644 --- a/app/ldap_protocol/dns/managers/abstract_dns_manager.py +++ b/app/ldap_protocol/dns/managers/abstract_dns_manager.py @@ -38,6 +38,7 @@ def __init__( async def setup( self, dns_settings: DNSSettingsDTO, + is_migration: bool = False, ) -> None: ... @abstractmethod diff --git a/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py b/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py index 63be1e8ad..1d02531df 100644 --- a/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py +++ b/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py @@ -155,7 +155,7 @@ async def migrate(self) -> None: return logger.info("Starting BIND to PowerDNS migration...") - await self.pdns_manager.setup(self.dns_settings) + await self.pdns_manager.setup(self.dns_settings, is_migration=True) await self.migrate_from_bind() logger.info("Migration successful") diff --git a/app/ldap_protocol/dns/managers/power_dns_manager.py b/app/ldap_protocol/dns/managers/power_dns_manager.py index 2c19f3435..500c69d40 100644 --- a/app/ldap_protocol/dns/managers/power_dns_manager.py +++ b/app/ldap_protocol/dns/managers/power_dns_manager.py @@ -70,28 +70,33 @@ def _normalize_dns_name(name: str) -> str: return name if name.endswith(".") else f"{name}." @logger_wraps() - async def setup(self, dns_settings: DNSSettingsDTO) -> None: + async def setup( + self, + dns_settings: DNSSettingsDTO, + is_migration: bool = False, + ) -> None: """Set up DNS server and DNS manager.""" records = [] if dns_settings.power_dns_settings is None: raise DNSSetupError("PowerDNS settings is not set.") - for record in DNS_FIRST_SETUP_RECORDS: - records.append( - DNSRRSetDTO( - name=f"{record['name']}{self._dns_settings.domain}.", - type=DNSRecordType(record["type"]), - records=[ - DNSRecordDTO( - content=f"{record['value']}{self._dns_settings.domain}.", - disabled=False, - modified_at=None, - ), - ], - changetype=PowerDNSRecordChangeType.EXTEND, - ttl=3600, - ), - ) + if not is_migration: + for record in DNS_FIRST_SETUP_RECORDS: + records.append( + DNSRRSetDTO( + name=f"{record['name']}{self._dns_settings.domain}.", + type=DNSRecordType(record["type"]), + records=[ + DNSRecordDTO( + content=f"{record['value']}{self._dns_settings.domain}.", + disabled=False, + modified_at=None, + ), + ], + changetype=PowerDNSRecordChangeType.EXTEND, + ttl=3600, + ), + ) try: self._dnsdist_client.setup_dnsdist( @@ -101,14 +106,15 @@ async def setup(self, dns_settings: DNSSettingsDTO) -> None: dns_settings.power_dns_settings.auth_server_ip, "master", ) - await self.create_master_zone( - DNSMasterZoneDTO( - id=self._dns_settings.domain, - name=self._dns_settings.domain, - dnssec=False, - rrsets=records, - ), - ) + if not is_migration: + await self.create_master_zone( + DNSMasterZoneDTO( + id=self._dns_settings.domain, + name=self._dns_settings.domain, + dnssec=False, + rrsets=records, + ), + ) except DNSZoneCreateError as e: raise DNSSetupError(f"Failed to set up DNS: {e}") diff --git a/app/ldap_protocol/dns/managers/remote_dns_manager.py b/app/ldap_protocol/dns/managers/remote_dns_manager.py index 1b09d0864..c41f844bc 100644 --- a/app/ldap_protocol/dns/managers/remote_dns_manager.py +++ b/app/ldap_protocol/dns/managers/remote_dns_manager.py @@ -50,6 +50,7 @@ async def _send(self, action: Message) -> None: async def setup( self, dns_settings: DNSSettingsDTO, # noqa: ARG002 + is_migration: bool = False, # noqa: ARG002 ) -> None: """Set up DNS server and DNS manager.""" raise DNSNotImplementedError diff --git a/app/ldap_protocol/dns/managers/stub_dns_manager.py b/app/ldap_protocol/dns/managers/stub_dns_manager.py index 707d96888..2dceeb626 100644 --- a/app/ldap_protocol/dns/managers/stub_dns_manager.py +++ b/app/ldap_protocol/dns/managers/stub_dns_manager.py @@ -23,6 +23,7 @@ class StubDNSManager(AbstractDNSManager): async def setup( self, dns_settings: DNSSettingsDTO, + is_migration: bool = False, ) -> None: ... @logger_wraps(is_stub=True) From dcf3303407ec536baddb044cb6d86c7a58eea350 Mon Sep 17 00:00:00 2001 From: iyashnov Date: Wed, 4 Mar 2026 12:36:08 +0300 Subject: [PATCH 6/6] refactor: renamed DNS migration manager to usecase --- app/ioc.py | 10 +- .../dns/bind_to_pdns_migration_use_case.py | 172 ++++++++++++++++++ app/multidirectory.py | 8 +- 3 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 app/ldap_protocol/dns/bind_to_pdns_migration_use_case.py diff --git a/app/ioc.py b/app/ioc.py index b8953d7d4..fa3b85ee3 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -65,8 +65,8 @@ RemoteDNSManager, StubDNSManager, ) -from ldap_protocol.dns.managers.bind_to_pdns_migrations_manager import ( - BindToPDNSMigrationManager, +from ldap_protocol.dns.bind_to_pdns_migration_use_case import ( + BindToPDNSMigrationUseCase, ) from ldap_protocol.identity import IdentityProvider from ldap_protocol.identity.provider_gateway import IdentityProviderGateway @@ -338,15 +338,15 @@ async def get_dns_mngr( yield StubDNSManager(settings=dns_settings) @provide(scope=Scope.REQUEST) - async def get_dns_migration_manager( + async def get_dns_migration_usecase( self, dns_settings: DNSSettingsDTO, power_dns_auth_client: PowerDNSAuthHTTPClient, power_dns_recursor_client: PowerDNSRecursorHTTPClient, power_dns_dist_client: PowerDNSDistClient, - ) -> AsyncIterator[BindToPDNSMigrationManager]: + ) -> AsyncIterator[BindToPDNSMigrationUseCase]: """Get DNS migration manager class.""" - yield BindToPDNSMigrationManager( + yield BindToPDNSMigrationUseCase( PowerDNSManager( settings=dns_settings, power_dns_auth_client=power_dns_auth_client, diff --git a/app/ldap_protocol/dns/bind_to_pdns_migration_use_case.py b/app/ldap_protocol/dns/bind_to_pdns_migration_use_case.py new file mode 100644 index 000000000..07824ac2a --- /dev/null +++ b/app/ldap_protocol/dns/bind_to_pdns_migration_use_case.py @@ -0,0 +1,172 @@ +"""Manager for migrating from BIND to PowerDNS. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +import os + +import dns.zone +from loguru import logger + +from ldap_protocol.dns.dto import ( + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRecordDTO, + DNSRRSetDTO, + DNSSettingsDTO, +) +from ldap_protocol.dns.enums import DNSRecordType +from ldap_protocol.dns.managers.power_dns_manager import PowerDNSManager + + +class BindToPDNSMigrationUseCase: + bind_zone_file_dir: str = "/opt/" + bind_config_files_dir: str = "/etc/bind/" + + def __init__( + self, + pdns_manager: PowerDNSManager, + dns_settings: DNSSettingsDTO, + ) -> None: + self.pdns_manager = pdns_manager + self.dns_settings = dns_settings + + def parse_bind_config_file( + self, + ) -> tuple[list[DNSMasterZoneDTO], list[DNSForwardZoneDTO]]: + """Parse BIND configuration files to extract zone information.""" + master_zones: list[DNSMasterZoneDTO] = [] + forward_zones: list[DNSForwardZoneDTO] = [] + + with open( + os.path.join(self.bind_config_files_dir, "named.conf.local"), + ) as f: + for line in f: + line = line.strip() + if line.startswith("zone"): + parts = line.split() + if len(parts) >= 2: + zone_name = parts[1].strip('"') + continue + + if "type master" in line: + master_zones.append( + DNSMasterZoneDTO( + id=zone_name, + name=zone_name, + ), + ) + elif "type forward" in line: + forward_zones.append( + DNSForwardZoneDTO( + id=zone_name, + name=zone_name, + ), + ) + + return master_zones, forward_zones + + def parse_zones_records( + self, + master_zones: list[DNSMasterZoneDTO], + ) -> list[DNSMasterZoneDTO]: + """Parse zone files to extract DNS records.""" + zones_with_records: list[DNSMasterZoneDTO] = [] + + for zone in master_zones: + zone_rrsets: list[DNSRRSetDTO] = [] + zone_file_path = os.path.join( + self.bind_zone_file_dir, + f"{zone.name}.zone", + ) + try: + zone_obj = dns.zone.from_file( + zone_file_path, + origin=zone.name, + relativize=False, + ) + except FileNotFoundError: + logger.error( + f"Zone file for zone {zone.name} not found, skipping...", + ) + continue + + for name, ttl, rdata in zone_obj.iterate_rdatas(): + try: + DNSRecordType(rdata.rdtype.name) + except ValueError: + logger.warning( + f"Unsupported DNS record type {rdata.rdtype.name} in zone '{zone.name}'", # noqa: E501 + ) + continue + + zone_rrsets.append( + DNSRRSetDTO( + name=name.to_text(), + type=DNSRecordType(rdata.rdtype.name), + records=[ + DNSRecordDTO( + content=rdata.to_text(), + disabled=False, + ), + ], + ttl=ttl, + ), + ) + zone.rrsets = zone_rrsets + zones_with_records.append(zone) + + return zones_with_records + + async def get_bind_zones( + self, + ) -> tuple[list[DNSMasterZoneDTO], list[DNSForwardZoneDTO]]: + """Get zones from BIND.""" + master_zones, forward_zones = self.parse_bind_config_file() + master_zones = self.parse_zones_records(master_zones) + + return master_zones, forward_zones + + async def migrate_from_bind(self) -> None: + """Migrate from BIND to PowerDNS.""" + master_zones, forward_zones = await self.get_bind_zones() + + for master_zone in master_zones: + await self.pdns_manager.create_master_zone( + master_zone, + is_empty=True, + ) + for rrset in master_zone.rrsets: + await self.pdns_manager.create_record( + master_zone.name, + rrset, + ) + + for forward_zone in forward_zones: + await self.pdns_manager.create_forward_zone(forward_zone) + + open(os.path.join(self.bind_zone_file_dir, "migrated"), "a").close() + open(os.path.join(self.bind_config_files_dir, "migrated"), "a").close() + + def is_migration_needed(self) -> bool: + """Check if migration is needed.""" + return not ( + os.path.exists(os.path.join(self.bind_zone_file_dir, "migrated")) + and os.path.exists( + os.path.join(self.bind_config_files_dir, "migrated"), + ) + ) and bool(os.listdir(self.bind_zone_file_dir)) + + async def migrate(self) -> None: + """Migrate from BIND to PowerDNS.""" + if not self.is_migration_needed(): + logger.info("BIND to PowerDNS migration is not needed, exiting...") + return + + logger.info("Starting BIND to PowerDNS migration...") + await self.pdns_manager.setup(self.dns_settings, is_migration=True) + + await self.migrate_from_bind() + logger.info("Migration successful") + return diff --git a/app/multidirectory.py b/app/multidirectory.py index 85ed6d3dc..7e7c58862 100644 --- a/app/multidirectory.py +++ b/app/multidirectory.py @@ -50,8 +50,8 @@ MFAProvider, ) from ldap_protocol.dependency import resolve_deps -from ldap_protocol.dns.managers.bind_to_pdns_migrations_manager import ( - BindToPDNSMigrationManager, +from ldap_protocol.dns.bind_to_pdns_migration_use_case import ( + BindToPDNSMigrationUseCase, ) from ldap_protocol.identity.exceptions import UnauthorizedError from ldap_protocol.policies.audit.events.handler import AuditEventHandler @@ -298,8 +298,8 @@ async def migrate_dns_factory(settings: Settings) -> None: ) async with main_container(scope=Scope.REQUEST) as container: - manager = await container.get(BindToPDNSMigrationManager) - await manager.migrate() + usecase = await container.get(BindToPDNSMigrationUseCase) + await usecase.migrate() ldap = partial(run_entrypoint, factory=ldap_factory)