diff --git a/.package/docker-compose.yml b/.package/docker-compose.yml index 44415d5e9..64f8b4a7d 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: + - dns_server_file:/opt/ + - dns_server_config:/etc/bind/ + - dnsdist_confd:/dnsdist + env_file: .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: diff --git a/app/ioc.py b/app/ioc.py index 5673a37ba..fa3b85ee3 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -65,6 +65,9 @@ RemoteDNSManager, StubDNSManager, ) +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 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_migration_usecase( + self, + dns_settings: DNSSettingsDTO, + power_dns_auth_client: PowerDNSAuthHTTPClient, + power_dns_recursor_client: PowerDNSRecursorHTTPClient, + power_dns_dist_client: PowerDNSDistClient, + ) -> AsyncIterator[BindToPDNSMigrationUseCase]: + """Get DNS migration manager class.""" + yield BindToPDNSMigrationUseCase( + 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, 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/ldap_protocol/dns/managers/abstract_dns_manager.py b/app/ldap_protocol/dns/managers/abstract_dns_manager.py index bfe7a79d6..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 @@ -77,6 +78,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 new file mode 100644 index 000000000..1d02531df --- /dev/null +++ b/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py @@ -0,0 +1,162 @@ +"""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, + 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(), + 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, is_migration=True) + + 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..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}") @@ -157,17 +163,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..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 @@ -150,6 +151,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..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) @@ -65,6 +66,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 22a19259d..7e7c58862 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.bind_to_pdns_migration_use_case import ( + BindToPDNSMigrationUseCase, +) 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,18 @@ 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(), + context={Settings: settings}, + ) + + async with main_container(scope=Scope.REQUEST) as container: + usecase = await container.get(BindToPDNSMigrationUseCase) + await usecase.migrate() + + ldap = partial(run_entrypoint, factory=ldap_factory) cldap = partial(run_entrypoint, factory=cldap_factory) global_ldap_server = partial( @@ -297,6 +312,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 +350,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 +397,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)