diff --git a/.dns/dns_api.py b/.dns/dns_api.py deleted file mode 100644 index f57f23bed..000000000 --- a/.dns/dns_api.py +++ /dev/null @@ -1,1395 +0,0 @@ -"""API for managing Bind9 DNS server. - -Copyright (c) 2025 MultiFactor -License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE -""" - -import contextlib -import logging -import os -import re -import subprocess -import tempfile -from collections import defaultdict -from dataclasses import dataclass -from enum import StrEnum -from typing import Annotated, NoReturn - -import dns -import dns.zone -import jinja2 -from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status -from pydantic import BaseModel - -logging.basicConfig(level=logging.INFO) - -TEMPLATES: jinja2.Environment = jinja2.Environment( - loader=jinja2.FileSystemLoader("templates/"), - autoescape=True, - keep_trailing_newline=True, -) - -ZONE_FILES_DIR = "/opt" -NAMED_CONF = "/etc/bind/named.conf" -NAMED_LOCAL = "/etc/bind/named.conf.local" -NAMED_OPTIONS = "/etc/bind/named.conf.options" - -FIRST_SETUP_RECORDS = [ - {"name": "_ldap._tcp.", "value": "0 0 389 ", "type": "SRV"}, - {"name": "_ldaps._tcp.", "value": "0 0 636 ", "type": "SRV"}, - {"name": "_kerberos._tcp.", "value": "0 0 88 ", "type": "SRV"}, - {"name": "_kerberos._udp.", "value": "0 0 88 ", "type": "SRV"}, - {"name": "_kdc._tcp.", "value": "0 0 88 ", "type": "SRV"}, - {"name": "_kdc._udp.", "value": "0 0 88 ", "type": "SRV"}, - {"name": "_kpasswd._tcp.", "value": "0 0 464 ", "type": "SRV"}, - {"name": "_kpasswd._udp.", "value": "0 0 464 ", "type": "SRV"}, - # Record for PDC Emulator - { - "name": "_ldap._tcp.pdc._msdcs.", - "value": "0 100 389 ", - "type": "SRV", - }, - # Records for DC Locator (for trusts) - { - "name": "_kerberos._tcp.dc._msdcs.", - "value": "0 100 88 ", - "type": "SRV", - }, - { - "name": "_kerberos._tcp.Default-First-Site-Name._sites.dc._msdcs.", - "value": "0 100 88 ", - "type": "SRV", - }, - { - "name": "_ldap._tcp.dc._msdcs.", - "value": "0 100 389 ", - "type": "SRV", - }, - { - "name": "_ldap._tcp.Default-First-Site-Name._sites.dc._msdcs.", - "value": "0 100 389 ", - "type": "SRV", - }, - # Records for Global Catalog - {"name": "_gc._tcp.", "value": "0 100 3268 ", "type": "SRV"}, - { - "name": "_ldap._tcp.Default-First-Site-Name._sites.gc._msdcs.", - "value": "0 100 3268 ", - "type": "SRV", - }, - { - "name": "_ldap._tcp.gc._msdcs.", - "value": "0 100 3268 ", - "type": "SRV", - }, -] - - -class DNSError(Exception): - """Base class for DNS exceptions.""" - - -class DNSZoneCreateError(DNSError): - """DNS zone create error.""" - - -class DNSDomainNotFoundError(DNSError): - """DNS domain not found error.""" - - -class DNSZoneValidationError(DNSError): - """DNS validation error.""" - - -class DNSZoneConfigError(DNSError): - """DNS zone config error.""" - - -class DNSZoneNotFoundError(DNSError): - """DNS zone not found error.""" - - -class DNSZoneType(StrEnum): - """DNS zone types.""" - - MASTER = "master" - FORWARD = "forward" - - -class DNSRecordType(StrEnum): - """DNS record types.""" - - A = "A" - AAAA = "AAAA" - CNAME = "CNAME" - MX = "MX" - NS = "NS" - TXT = "TXT" - SOA = "SOA" - PTR = "PTR" - SRV = "SRV" - - -@dataclass -class DNSRecord: - """Single DNS record.""" - - name: str - value: str - ttl: int - - -@dataclass -class DNSRecords: - """List of DNS records grouped by type.""" - - type: DNSRecordType - records: list[DNSRecord] - - -@dataclass -class DNSZone: - """DNS zone.""" - - name: str - type: DNSZoneType - records: list[DNSRecords] - - -@dataclass -class DNSForwardZone: - """DNS forward zone.""" - - name: str - type: DNSZoneType - forwarders: list[str] - - -class DNSZoneParamName(StrEnum): - """Possible DNS zone option names.""" - - acl = "acl" - forwarders = "forwarders" - ttl = "ttl" - - -class DNSServerParamName(StrEnum): - """Possible DNS server option names.""" - - dnssec = "dnssec-validation" - - -@dataclass -class DNSZoneParam: - """DNS zone parameter.""" - - name: DNSZoneParamName - value: str | list[str] | None - - -class DNSZoneCreateRequest(BaseModel): - """DNS zone create request scheme.""" - - zone_name: str - zone_type: DNSZoneType - nameserver: str | None - params: list[DNSZoneParam] - - -class DNSZoneUpdateRequest(BaseModel): - """DNS zone update request scheme.""" - - zone_name: str - params: list[DNSZoneParam] - - -class DNSZoneDeleteRequest(BaseModel): - """DNS zone delete request scheme.""" - - zone_name: str - - -class DNSRecordCreateRequest(BaseModel): - """DNS record create request scheme.""" - - zone_name: str - record_name: str - record_value: str - record_type: str - ttl: int - - -class DNSRecordUpdateRequest(BaseModel): - """DNS record update request scheme.""" - - zone_name: str - record_name: str - record_value: str - record_type: DNSRecordType - ttl: int - - -class DNSRecordDeleteRequest(BaseModel): - """DNS record delete request schem.""" - - zone_name: str - record_name: str - record_value: str - record_type: DNSRecordType - - -class DNSServerSetupRequest(BaseModel): - """DNS server setup request schem.""" - - zone_name: str - - -@dataclass -class DNSServerParam: - """DNS zone parameter.""" - - name: DNSServerParamName - value: str | list[str] - - -class BindDNSServerManager: - """Bind9 DNS server manager.""" - - @staticmethod - def _get_zone_obj_by_zone_name(zone_name) -> dns.zone.Zone: - """Get DNS zone object by zone name. - - Algorithm: - 1. Build the path to the zone file using the zone name. - 2. Load the zone object using dns.zone.from_file. - - Args: - zone_name (str): Name of the DNS zone. - - Returns: - dns.zone.Zone: Zone object. - - """ - zone_file = os.path.join(ZONE_FILES_DIR, f"{zone_name}.zone") - return dns.zone.from_file( - zone_file, - relativize=False, - origin=zone_name, - ) - - def _write_zone_data_to_file( - self, - zone_name: str, - zone: dns.zone.Zone, - ) -> None: - """Write zone data to file and reload the zone. - - Algorithm: - 1. Save the zone object to a file. - 2. Call reload to apply changes. - - Args: - zone_name (str): Name of the DNS zone. - zone (dns.zone.Zone): Zone object. - - """ - error = self._check_zone(zone.to_text(), zone_name) - if error: - raise DNSZoneCreateError( - f"Error while writing zone data to file {zone_name}: {error}", - ) - - zone.to_file(os.path.join(ZONE_FILES_DIR, f"{zone_name}.zone")) - self.reload(zone_name) - - def _check_config(self, config: str) -> str | None: - with tempfile.NamedTemporaryFile(mode="w") as tf: - tf.write(config) - tmp_path = tf.name - - result = subprocess.run( # noqa: S603 - ["/usr/bin/named-checkconf", tmp_path], - capture_output=True, - text=True, - ) - - return result.stderr - - def _check_zone(self, zonefile: str, zone_name: str) -> str | None: - with tempfile.NamedTemporaryFile(mode="w") as zf: - zf.write(zonefile) - tmp_path = zf.name - - result = subprocess.run( # noqa: S603 - [ - "/usr/bin/named-checkzone", - "-i", - "none", - zone_name, - tmp_path, - ], - capture_output=True, - text=True, - ) - - return result.stderr - - def _get_base_domain(self) -> str: - """Get base domain. - - Algorithm: - 1. Open named.conf.local. - 2. Get first domain. - - """ - named_local = None - - with open(NAMED_LOCAL) as file: - named_local = file.read() - - pattern = r""" - zone\s+"([^"]+)"\s*{[^}]*? - type\s+master\b[^}]*? - """ - - matches = re.search(pattern, named_local, re.DOTALL | re.VERBOSE) - - if not matches: - raise DNSDomainNotFoundError("Base domain not found") - - return matches.group(1) - - def add_zone( - self, - zone_name: str, - zone_type: str, - nameserver_ip: str | None, - params: list[DNSZoneParam], - ) -> None: - """Add a new DNS zone. - - Algorithm: - 1. Build a dictionary of zone parameters. - 2. Render the zone file and zone options templates. - 3. Process parameters (acl, forwarders, ttl, etc.) and add them - to the zone options. - 4. Write the zone options to named.conf.local. - 5. Restart the server. - - Args: - zone_name (str): Name of the DNS zone. - zone_type (str): Type of the DNS zone. - nameserver_ip (str | None): Nameserver IP address. - params (list[DNSZoneParam]): List of zone parameters. - - """ - params_dict = {param.name: param.value for param in params} - - if zone_type != DNSZoneType.FORWARD: - nameserver_ip = ( - nameserver_ip - if nameserver_ip is not None - else os.getenv("DEFAULT_NAMESERVER") - ) - nameserver = ( - self._get_base_domain() - if "in-addr.arpa" in zone_name - else zone_name - ) - - zf_template = TEMPLATES.get_template("zone.template") - zone_file = zf_template.render( - domain=zone_name, - nameserver=nameserver, - ttl=params_dict.get("ttl", 604800), - ) - - zone_error = self._check_zone(zone_file, zone_name) - if zone_error: - raise DNSZoneValidationError( - f"Error in zonefile during adding zone: {zone_error}", - ) - - with open( - os.path.join(ZONE_FILES_DIR, f"{zone_name}.zone"), - "w", - ) as file: - file.write(zone_file) - - if "in-addr.arpa" not in zone_name: - for record in [ - DNSRecord( - name=zone_name, - value=nameserver_ip, - ttl=604800, - ), - DNSRecord( - name=f"ns1.{zone_name}", - value=nameserver_ip, - ttl=604800, - ), - DNSRecord( - name=f"ns2.{zone_name}", - value="127.0.0.1", - ttl=604800, - ), - ]: - self.add_record( - record, - DNSRecordType.A, - zone_name=zone_name, - ) - - zo_template = TEMPLATES.get_template("zone_options.template") - zone_options = zo_template.render( - zone_name=zone_name, - zone_type=zone_type, - forwarders=params_dict.get("forwarders"), - ) - - for param in params: - param_name = param.name if param.name != "acl" else "allow-query" - if ( - param_name == "allow-query" - and zone_type == DNSZoneType.FORWARD - ): - continue - if isinstance(param.value, list): - param_value = "{ " + f"{'; '.join(param.value)};" + " }" - else: - param_value = param.value - - zone_options = self._add_zone_param( - zone_options, - zone_name, - param_name, - param_value, - ) - - config_error = self._check_config(zone_options) - if config_error: - raise DNSError( - f"Error with config during adding zone: {config_error}", - ) - - with open(NAMED_LOCAL, "a") as file: - file.write(zone_options) - - self.restart() - - @staticmethod - def _add_zone_param( - named_local: str, - zone_name: str, - param_name: str, - param_value: str, - ) -> str: - """Add a zone parameter to named.conf.local. - - Regex explanation: - - (zone\\s+"{zone_name}"\\s*{{[^}}]*?) - Captures the start of the zone block for the given zone_name, - including all content up to the closing '};'. - - (\\s*}};) - Captures the closing of the zone block - (with optional whitespace). - The regex is used to insert a new parameter - just before the end of the zone block. - - Algorithm: - 1. Use re.sub to add the parameter line inside the zone block. - 2. Return the modified text. - - Args: - named_local (str): Contents of named.conf.local. - zone_name (str): Name of the DNS zone. - param_name (str): Parameter name. - param_value (str): Parameter value. - - Returns: - str: Modified named.conf.local content. - - """ - pattern = rf'(zone\s+"{re.escape(zone_name)}"\s*{{[^}}]*?)(\s*}};)' - replacement = rf"\1\n {param_name} {param_value};\2" - return re.sub(pattern, replacement, named_local, flags=re.DOTALL) - - @staticmethod - def _delete_zone_param( - named_local: str, - zone_name: str, - param_name: str, - ) -> str: - """Delete a zone parameter from named.conf.local. - - Regex explanation: - - (zone\\s+"{zone_name}"\\s*{{) - Captures the start of the zone block for the given zone_name. - - (.*?) - Non-greedy match for any content up to the parameter line. - - (^\\s*{param_name}\\s+(?:[^{{;\\n}}]+|{{[^}}]+}})\\s*;\\s*\\n) - Matches the parameter line (with possible value in braces - or not), including the trailing semicolon and newline. - - (.*?}}) - Matches the rest of the zone block up to the closing brace. - The regex is used to remove the parameter line from the zone block. - - Algorithm: - 1. Use re.sub to remove the parameter line from the zone block. - 2. Return the modified text. - - Args: - named_local (str): Contents of named.conf.local. - zone_name (str): Name of the DNS zone. - param_name (str): Parameter name. - - Returns: - str: Modified named.conf.local content. - - """ - pattern = rf""" - (zone\s+"{re.escape(zone_name)}"\s*{{) - (.*?) - ^\s*{re.escape(param_name)}\s+ - (?:[^{{;\n}}]+|{{[^}}]+}}) - \s*;\s*\n - (.*?}}) - """ - - return re.sub( - pattern, - r"\1\2\3", - named_local, - flags=re.DOTALL | re.VERBOSE | re.MULTILINE, - ) - - def _update_zone_param( - self, - named_local: str, - zone_name: str, - param_name: str, - param_value: str, - ) -> str: - """Update a zone parameter in named.conf.local. - - Algorithm: - 1. Remove the old parameter value using _delete_zone_param. - 2. Add the new value using _add_zone_param. - 3. Return the modified text. - - Args: - named_local (str): Contents of named.conf.local. - zone_name (str): Name of the DNS zone. - param_name (str): Parameter name. - param_value (str): Parameter value. - - Returns: - str: Modified named.conf.local content. - - """ - new_named_local = self._delete_zone_param( - named_local, - zone_name, - param_name, - ) - return self._add_zone_param( - new_named_local, - zone_name, - param_name, - param_value, - ) - - def update_zone(self, zone_name: str, params: list[DNSZoneParam]) -> None: - """Update zone parameters. - - Regex explanation: - - ^zone\\s+"{zone_name}"\\s*{{ - Matches the start of the zone block for the given zone_name. - - [^}}]*? - Non-greedy match for any content inside the block up - to the parameter. - - \\s{param_name}\\b - Matches the parameter name as a whole word. - - \\s+(?:[^{{;\\n}}]+|{{[^}}]+}})\\s*; - Matches the parameter value (either a simple value or a block - in braces), followed by a semicolon. - This regex is used to check if the parameter exists in the zone - block. - - Algorithm: - 1. Read named.conf.local content. - 2. For each parameter, check if it exists in the zone block - using regex. - 3. If value is None, remove the parameter; otherwise, update or - add it. - 4. Write the modified config back to the file. - - Args: - zone_name (str): Name of the DNS zone. - params (list[DNSZoneParam]): List of zone parameters. - - """ - named_local = None - with open(NAMED_LOCAL) as file: - named_local = file.read() - - for param in params: - param_name = param.name if param.name != "acl" else "allow-query" - pattern = rf""" - ^zone\s+"{re.escape(zone_name)}"\s*{{ - [^}}]*? - \s{re.escape(param_name)}\b - \s+(?:[^{{;\n}}]+|{{[^}}]+}}) - \s*; - """ - has_param = bool( - re.search( - pattern, - named_local, - flags=re.MULTILINE | re.VERBOSE | re.DOTALL, - ), - ) - - if param.value is None: - named_local = self._delete_zone_param( - named_local, - zone_name, - param_name, - ) - continue - - if isinstance(param.value, list): - param_value = "{ " + f"{'; '.join(param.value)};" + " }" - else: - param_value = param.value - - if has_param: - named_local = self._update_zone_param( - named_local, - zone_name, - param_name, - param_value, - ) - else: - named_local = self._add_zone_param( - named_local, - zone_name, - param_name, - param_value, - ) - - error = self._check_config(named_local) - if error: - raise DNSZoneConfigError( - f"Error while updating zone {zone_name}: {error}", - ) - - with open(NAMED_LOCAL, "w") as file: - file.write(named_local) - - self.restart() - - def delete_zone(self, zone_name: str) -> None: - """Delete an existing zone. - - Regex explanation: - - ^\\s*zone\\s+"{zone_name}"\\s*{{ - Matches the start of the zone block for the given zone_name. - - (?:[^{{}}]|{{(?:[^{{}}]|{{[^}}]*}})*}})*? - Non-greedy match for any content inside the block, including - nested braces. - - \\s*}};\\s* - Matches the closing of the zone block (with optional - whitespace). - This regex is used to remove the entire zone block from the config. - - Algorithm: - 1. Read named.conf.local content. - 2. Determine the zone type. - 3. Remove the zone block using regex. - 4. If not a forward zone, remove the zone file. - 5. Restart the server. - - Args: - zone_name (str): Name of the DNS zone. - - """ - named_local = None - with open(NAMED_LOCAL) as file: - named_local = file.read() - - zone_type = self.get_zone_type_by_zone_name(zone_name) - - pattern = rf""" - ^\s*zone\s+"{re.escape(zone_name)}"\s*{{ - (?: - [^{{}}] - | - {{(?:[^{{}}]|{{[^}}]*}})*}} - )*? - \s*}};\s* - """ - named_local = re.sub( - pattern, - "", - named_local, - flags=re.MULTILINE | re.VERBOSE | re.DOTALL, - ) - - error = self._check_config(named_local) - if error: - raise DNSZoneConfigError( - f"Error while deleting zone {zone_name}: {error}", - ) - - with open(NAMED_LOCAL, "w") as file: - file.write(named_local) - - if zone_type != DNSZoneType.FORWARD: - with contextlib.suppress(FileNotFoundError): - os.remove(os.path.join(ZONE_FILES_DIR, f"{zone_name}.zone")) - - self.restart() - - def reload(self, zone_name: str | None = None) -> None: - """Reload a zone by name or all zones if no name is provided. - - Algorithm: - 1. Call rndc reload with the zone name or without it. - - Args: - zone_name (str | None): Name of the DNS zone or None. - - """ - subprocess.run( # noqa: S603 - [ - "/usr/sbin/rndc", - "reload", - zone_name if zone_name else "", - ], - ) - - def restart(self) -> None: - """Restart the Bind9 server (reconfig). - - Algorithm: - 1. Call rndc reconfig. - """ - subprocess.run( # noqa: S603 - [ - "/usr/sbin/rndc", - "reconfig", - ], - ) - - def first_setup(self, zone_name: str) -> str: - """Perform initial setup of the Bind9 server. - - Algorithm: - 1. Create a master zone. - 2. Add standard SRV records for services (ldap, kerberos, etc.). - - Args: - zone_name (str): Name of the DNS zone. - - """ - self.add_zone( - zone_name, - "master", - None, - params=[], - ) - - self.add_record( - DNSRecord( - name=f"gc._msdcs.{zone_name}", - value=os.getenv("DEFAULT_NAMESERVER"), - ttl=604800, - ), - DNSRecordType.A, - zone_name, - ) - - for record in FIRST_SETUP_RECORDS: - self.add_record( - DNSRecord( - name=f"{record.get('name')}{zone_name}", - value=f"{record.get('value')}{zone_name}.", - ttl=604800, - ), - record.get("type"), - zone_name, - ) - - @staticmethod - def get_zone_type_by_zone_name(zone_name: str) -> DNSZoneType: - """Get the zone type by zone name. - - Regex explanation: - - zone\\s+"{zone_name}"\\s*{{\\s*type\\s*([^;]+); - Matches the zone block for the given zone_name and captures - the type value after 'type'. - The first capturing group contains the zone type - (e.g., master, forward). - - Algorithm: - 1. Read named.conf.local content. - 2. Use regex to find the zone block and extract the type. - - Args: - zone_name (str): Name of the DNS zone. - - Returns: - DNSZoneType: Zone type. - - """ - with open(NAMED_LOCAL) as file: - named_local_settings = file.read() - - pattern = rf'zone\s*"{re.escape(zone_name)}"\s*{{\s*type\s*([^;]+);' - match = re.search(pattern, named_local_settings) - if not match: - raise DNSZoneNotFoundError(f"Zone not found: {zone_name}") - return DNSZoneType(match.group(1).strip()) - - def get_all_records_from_zone( - self, - zone_name: str, - ) -> DNSRecords: - """Get all records from a zone by name. - - Algorithm: - 1. Load the zone object. - 2. Iterate over all rdata and group by type. - 3. Return a list of DNSRecords by type. - - Args: - zone_name (str): Name of the DNS zone. - - Returns: - list[DNSRecords]: List of DNSRecords grouped by type. - - """ - result: defaultdict[str, list] = defaultdict(list) - - zone = self._get_zone_obj_by_zone_name(zone_name) - for name, ttl, rdata in zone.iterate_rdatas(): - record_type = rdata.rdtype.name - - result[record_type].append( - DNSRecord( - name=name.to_text(), - value=rdata.to_text(), - ttl=ttl, - ), - ) - - return [ - DNSRecords(type=record_type, records=records) - for record_type, records in result.items() - ] - - def get_all_records(self) -> list[DNSZone]: - """Get all records from all zones. - - Algorithm: - 1. Scan the directory for zone files. - 2. For each file, determine the zone name and type. - 3. Get all records for the zone. - 4. Return a list of DNSZone objects. - - Returns: - list[DNSZone]: List of DNSZone objects. - - """ - zone_files = os.listdir(ZONE_FILES_DIR) - - result: list[DNSZone] = [] - for file in zone_files: - if file.split(".")[-1] != "zone": - continue - zone_name = ".".join(file.split(".")[:-1]) - zone_type = self.get_zone_type_by_zone_name(zone_name) - zone_records = self.get_all_records_from_zone( - zone_name, - ) - result.append( - DNSZone( - name=zone_name, - type=zone_type, - records=zone_records, - ), - ) - - return result - - async def get_forward_zones(self) -> list[DNSForwardZone]: - """Get all forward DNS zones. - - Regex explanation: - - zone\\s+"([^"]+)"\\s*{{ - Captures the zone name. - - [^}}]*?type\\s+forward\\b[^}}]*? - Matches content up to the 'type forward' declaration. - - forwarders\\s*{{([^}}]+)}} - Captures the content inside the forwarders block - (list of forwarder IPs). - The first group is the zone name, - the second group is the forwarders list. - - Algorithm: - 1. Read named.conf.local content. - 2. Use regex to find forward zone blocks and their forwarders. - 3. Return a list of DNSForwardZone objects. - - Returns: - list[DNSForwardZone]: List of forward zones. - - """ - named_local = None - with open(NAMED_LOCAL) as file: - named_local = file.read() - - pattern = r""" - zone\s+"([^"]+)"\s*{[^}]*? - type\s+forward\b[^}]*? - forwarders\s*{([^}]+)} - """ - - matches = re.findall(pattern, named_local, re.DOTALL | re.VERBOSE) - - result = [] - for zone_name, forwarders in matches: - clean_forwarders = [ - forwarder.strip() - for forwarder in forwarders.split(";") - if forwarder.strip() - ] - result.append( - DNSForwardZone( - zone_name, - DNSZoneType.FORWARD, - clean_forwarders, - ), - ) - - return result - - def add_record( - self, - record: DNSRecord, - record_type: DNSRecordType, - zone_name: str, - ) -> None: - """Add a DNS record to a zone. - - Algorithm: - 1. Load the zone object. - 2. Build rdata by type and value. - 3. Add rdata to the rdataset. - 4. Save changes to the zone file and reload the zone. - - Args: - record (DNSRecord): DNS record to add. - record_type (DNSRecordType): Type of the DNS record. - zone_name (str): Name of the DNS zone. - - """ - zone = self._get_zone_obj_by_zone_name(zone_name) - - record_name = dns.name.from_text(record.name) - rdata = dns.rdata.from_text( - dns.rdataclass.IN, - dns.rdatatype.from_text(record_type), - record.value, - ) - - zone.find_rdataset(record_name, rdata.rdtype, create=True).add( - rdata, - ttl=record.ttl, - ) - - self._write_zone_data_to_file(zone_name, zone) - - def delete_record( - self, - record: DNSRecord, - record_type: DNSRecordType, - zone_name: str, - ) -> None: - """Delete a record from a zone. - - Algorithm: - 1. Load the zone object. - 2. Find the rdataset by name and type. - 3. If rdata is present, remove it from the rdataset. - 4. Save changes to the zone file and reload the zone. - - Args: - record (DNSRecord): DNS record to delete. - record_type (DNSRecordType): Type of the DNS record. - zone_name (str): Name of the DNS zone. - - """ - zone = self._get_zone_obj_by_zone_name(zone_name) - name = dns.name.from_text(record.name) - rdatatype = dns.rdatatype.from_text(record_type) - rdata = dns.rdata.from_text( - dns.rdataclass.IN, - rdatatype, - record.value, - ) - - if name in zone.nodes: - node = zone.nodes[name] - rdataset = node.get_rdataset(dns.rdataclass.IN, rdatatype) - if rdataset and rdata in rdataset: - rdataset.remove(rdata) - - self._write_zone_data_to_file(zone_name, zone) - - def update_record( - self, - old_record: DNSRecord, - new_record: DNSRecord, - record_type, - zone_name, - ) -> None: - """Update a record in a zone (value or TTL). - - Algorithm: - 1. Delete the old record. - 2. Add the new record with updated values. - - Args: - old_record (DNSRecord): Old DNS record. - new_record (DNSRecord): New DNS record. - record_type: Type of the DNS record. - zone_name (str): Name of the DNS zone. - - """ - self.delete_record(old_record, record_type, zone_name) - self.add_record(new_record, record_type, zone_name) - - @staticmethod - def _add_new_server_param( - named_options: str, - param_name: str, - param_value: str, - ) -> str: - """Add a new parameter to the options block in named.conf.options. - - Regex explanation: - - (options\\s*\\{{[\\s\\S]*?) - Captures the start of the options block and all its content - up to the closing '};'. - - (\\s*\\}};) - Captures the closing of the options block - (with optional whitespace). - The regex is used to insert a new parameter just before the end of - the options block. - - Algorithm: - 1. Use re.sub to add the parameter line inside the options block. - 2. Return the modified text. - - Args: - named_options (str): Contents of named.conf.options. - param_name (str): Parameter name. - param_value (str): Parameter value. - - Returns: - str: Modified named.conf.options content. - - """ - return re.sub( - r"(options\s*\{[\s\S]*?)(\s*\};)", - rf"\1 {param_name} {param_value};\2", - named_options, - flags=re.DOTALL, - ) - - def update_dns_settings(self, settings: list[DNSServerParam]) -> None: - """Update or add DNS server parameters. - - Regex explanation: - - \\b{param_name}\\s+ - Matches the parameter name as a whole word, - followed by whitespace. - - ([^;\\n{{]+|{{[^}}]+}}) - Captures the parameter value, which can be a simple value or - a block in braces. - The first capturing group contains the parameter value. - - Algorithm: - 1. Read named.conf.options content. - 2. For each parameter, search for it using regex. - 3. If not found, add it; otherwise, update it. - 4. Write the modified config back to the file. - - Args: - settings (list[DNSServerParam]): List of server parameters. - - """ - named_options = None - - with open(NAMED_OPTIONS) as file: - named_options = file.read() - - for param in settings: - if isinstance(param.value, list): - param_value = "{ " + f"{'; '.join(param.value)};" + " }" - else: - param_value = param.value - pattern = rf"\b{re.escape(param.name)}\s+([^;\n{{]+|{{[^}}]+}})" - matched_param = re.search( - pattern, - named_options, - flags=re.MULTILINE, - ) - if matched_param is None: - named_options = self._add_new_server_param( - named_options, - param.name, - param_value, - ) - else: - named_options = re.sub( - pattern, - f"{param.name} {param_value}", - named_options, - ) - - error = self._check_config(named_options) - if error: - raise DNSZoneConfigError( - f"Error while updating DNS settings: {error}", - ) - - with open(NAMED_OPTIONS, "w") as file: - file.write(named_options) - - self.restart() - - @staticmethod - def get_server_settings() -> list[DNSServerParam]: - """Get a list of modifiable DNS server settings. - - Regex explanation: - - \\b{param_name}\\s+ - Matches the parameter name as a whole word, - followed by whitespace. - - ([^;\\n{{]+|{{[^}}]+}}) - Captures the parameter value, which can be a simple value or - a block in braces. - The first capturing group contains the parameter value. - - Algorithm: - 1. Read named.conf.options content. - 2. For each parameter in DNSServerParamName, - search for its value using regex. - 3. Return a list of DNSServerParam objects. - - Returns: - list[DNSServerParam]: List of server parameters. - - """ - named_options = None - with open(NAMED_OPTIONS) as file: - named_options = file.read() - - result = [] - for param_name in DNSServerParamName: - pattern = rf"\b{re.escape(param_name)}\s+([^;\n{{]+|{{[^}}]+}})" - matched_param_value = re.search(pattern, named_options) - if not matched_param_value: - continue - result.append( - DNSServerParam( - name=param_name, - value=matched_param_value.group(1).strip(), - ), - ) - - return result - - -async def get_dns_manager() -> type[BindDNSServerManager]: - """Get DNS server manager client.""" - return BindDNSServerManager() - - -zone_router = APIRouter(prefix="/zone", tags=["zone"]) -record_router = APIRouter(prefix="/record", tags=["record"]) -server_router = APIRouter(prefix="/server", tags=["server"]) - - -@zone_router.post("") -def create_zone( - data: DNSZoneCreateRequest, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Create DNS zone.""" - dns_manager.add_zone( - data.zone_name, - data.zone_type, - data.nameserver, - data.params, - ) - - -@zone_router.patch("") -def update_zone( - data: DNSZoneUpdateRequest, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Update DNS zone settings.""" - dns_manager.update_zone(data.zone_name, data.params) - - -@zone_router.delete("") -def delete_zone( - data: DNSZoneDeleteRequest, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Delete DNS zone.""" - dns_manager.delete_zone(data.zone_name) - - -@zone_router.get("") -async def get_all_records_by_zone( - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> list[DNSZone]: - """Get all DNS records grouped by zone.""" - return dns_manager.get_all_records() - - -@zone_router.get("/forward") -async def get_forward_zones( - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> list[DNSForwardZone]: - """Get all forward DNS zones.""" - return await dns_manager.get_forward_zones() - - -@record_router.post("") -def create_record( - data: DNSRecordCreateRequest, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Create DNS record in given zone.""" - dns_manager.add_record( - DNSRecord( - data.record_name, - data.record_value, - data.ttl, - ), - data.record_type, - data.zone_name, - ) - - -@record_router.patch("") -def update_record( - data: DNSRecordUpdateRequest, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Update existing DNS record.""" - dns_manager.update_record( - old_record=DNSRecord( - data.record_name, - data.record_value, - 0, - ), - new_record=DNSRecord( - data.record_name, - data.record_value, - data.ttl, - ), - record_type=data.record_type, - zone_name=data.zone_name, - ) - - -@record_router.delete("") -def delete_record( - data: DNSRecordDeleteRequest, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Delete existing DNS record.""" - dns_manager.delete_record( - DNSRecord( - data.record_name, - data.record_value, - 0, - ), - data.record_type, - data.zone_name, - ) - - -@server_router.get("/restart") -def restart_dns_server( - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Restart DNS server via reconfig.""" - dns_manager.restart() - - -@zone_router.get("/reload/{zone_name}") -def reload_zone( - zone_name: str, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Force reload DNS zone from zone file.""" - dns_manager.reload(zone_name) - - -@server_router.patch("/settings") -def update_dns_server_settings( - settings: list[DNSServerParam], - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Update settings of DNS server.""" - dns_manager.update_dns_settings(settings) - - -@server_router.get("/settings") -async def get_server_settings( - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> list[DNSServerParam]: - """Get list of modifiable server settings.""" - return dns_manager.get_server_settings() - - -@server_router.post("/setup") -def setup_server( - data: DNSServerSetupRequest, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Init setup of DNS server.""" - dns_manager.first_setup(data.zone_name) - - -async def handle_dns_error( - request: Request, # noqa: ARG001 - exc: Exception, -) -> NoReturn: - """Handle DNS API error.""" - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) - - -def create_app() -> FastAPI: - """Create FastAPI app.""" - app = FastAPI( - name="DNSServerManager", - title="DNSServerManager", - ) - - app.include_router(record_router) - app.include_router(zone_router) - app.include_router(server_router) - - app.add_exception_handler(DNSError, handler=handle_dns_error) - - return app diff --git a/.dns/entrypoint.sh b/.dns/entrypoint.sh deleted file mode 100755 index 25e0891d9..000000000 --- a/.dns/entrypoint.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -fix_rndc_key() { - local rndc_key="/etc/bind/rndc.key" - if [ -f "$rndc_key" ]; then - chown bind:bind "$rndc_key" 2>/dev/null || chown 100:101 "$rndc_key" 2>/dev/null || true - chmod 640 "$rndc_key" 2>/dev/null || true - fi -} - -/usr/local/bin/docker-entrypoint.sh & - -fix_rndc_key - -/venvs/.venv/bin/python3.13 -m uvicorn --factory dns_api:create_app --host 0.0.0.0 --reload & - -wait -n - -exit $? diff --git a/.dns/templates/zone.template b/.dns/templates/zone.template deleted file mode 100644 index 249ebc349..000000000 --- a/.dns/templates/zone.template +++ /dev/null @@ -1,11 +0,0 @@ -$ORIGIN . -$TTL {{ ttl }} -{{ domain }} IN SOA ns1.{{ nameserver }}. support.md.ru. ( - {{ today }}01 - 10800 - 3600 - 604800 - 21600 - ) - IN NS ns1.{{ nameserver }}. - IN NS ns2.{{ nameserver }}. diff --git a/.dns/templates/zone_options.template b/.dns/templates/zone_options.template deleted file mode 100644 index 22f20a3f3..000000000 --- a/.dns/templates/zone_options.template +++ /dev/null @@ -1,10 +0,0 @@ -zone "{{ zone_name }}" { - type {{ zone_type }}; - {%- if zone_type == "master" %} - file "/opt/{{ zone_name }}.zone"; - notify no; - {%- endif %} - {%- if zone_type == "forward" %} - forward only; - {%- endif %} -}; diff --git a/.docker/bind9.Dockerfile b/.docker/bind9.Dockerfile deleted file mode 100644 index d5b8154a8..000000000 --- a/.docker/bind9.Dockerfile +++ /dev/null @@ -1,45 +0,0 @@ -FROM python:3.13-bookworm AS builder - -ENV VIRTUAL_ENV=/venvs/.venv \ - PATH="/venvs/.venv/bin:$PATH" - -WORKDIR /venvs - -RUN python -m venv .venv -RUN pip install \ - fastapi==0.115.12 \ - uvicorn==0.34.2 \ - pydantic==2.10.6 \ - jinja2==3.1.6 \ - dnspython==2.7.0 - -FROM ubuntu/bind9:latest AS runtime - -ENV LANG=C.UTF-8 \ - DEBIAN_FRONTEND=noninteractive \ - VIRTUAL_ENV=/venvs/.venv \ - PATH="/venvs/.venv/bin:$PATH" \ - PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 - -RUN apt update -RUN apt install -y python3.13 - -COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} - -RUN ln -sf /usr/bin/python3.13 /venvs/.venv/bin/python - -COPY .dns/ /server/ -WORKDIR /server - -RUN chown bind:bind /opt - -RUN mkdir /var/log/named && \ - touch /var/log/named/bind.log && \ - chown bind:bind /var/log/named && \ - chmod 755 /var/log/named && \ - chmod 644 /var/log/named/bind.log - -EXPOSE 8000 - -ENTRYPOINT [ "./entrypoint.sh" ] diff --git a/.docker/pdns_auth.Dockerfile b/.docker/pdns_auth.Dockerfile new file mode 100644 index 000000000..fbc5ed58b --- /dev/null +++ b/.docker/pdns_auth.Dockerfile @@ -0,0 +1,68 @@ +FROM alpine:3.20 AS builder + +RUN apk add --no-cache --virtual .build-deps \ + build-base \ + lmdb-dev \ + openssl-dev \ + boost-dev \ + autoconf automake libtool \ + git ragel bison flex \ + lua5.4-dev \ + curl-dev + +RUN apk add --no-cache \ + lua \ + lua-dev \ + lmdb \ + boost-libs \ + openssl-libs-static \ + curl \ + libstdc++ + +RUN git clone https://github.com/PowerDNS/pdns.git /pdns +WORKDIR /pdns + +RUN git submodule init &&\ + git submodule update &&\ + git checkout auth-5.0.1 + +RUN autoreconf -vi + +RUN mkdir /build && \ + ./configure \ + --sysconfdir=/etc/powerdns \ + --enable-option-checking=fatal \ + --with-dynmodules='lmdb' \ + --with-modules='' \ + --with-unixodbc-lib=/usr/lib/$(dpkg-architecture -q DEB_BUILD_GNU_TYPE) && \ + make clean && \ + make $MAKEFLAGS -C ext &&\ + make $MAKEFLAGS -C modules &&\ + make $MAKEFLAGS -C pdns && \ + make -C pdns install DESTDIR=/build &&\ + make -C modules install DESTDIR=/build &&\ + make clean && \ + strip /build/usr/local/bin/* /build/usr/local/sbin/* /build/usr/local/lib/pdns/*.so + +# ==================================================================================================== + +FROM alpine:3.20 AS runtime + +COPY --from=builder /build / + +RUN apk add --no-cache \ + lua \ + lua-dev \ + lmdb \ + boost-libs \ + openssl-libs-static \ + curl \ + libstdc++ + +RUN mkdir -p /etc/powerdns/pdns.d /var/run/pdns /var/lib/powerdns /etc/powerdns/templates.d /var/lib/pdns-lmdb + +COPY ./pdns.conf /etc/powerdns/pdns.conf + +EXPOSE 8082/tcp + +CMD ["/usr/local/sbin/pdns_server"] \ No newline at end of file diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index 5356e70a0..5f68d98b5 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -155,7 +155,7 @@ jobs: --build-arg BUILDKIT_INLINE_CACHE=1 \ --build-arg VERSION=beta - build-bind9: + build-pdns_auth: runs-on: ubuntu-latest needs: [build-tests, run-ssh-test, run-tests] steps: @@ -172,14 +172,14 @@ jobs: - name: Build docker image env: - TAG: ghcr.io/${{ env.REPO }}_bind9:beta + TAG: ghcr.io/${{ env.REPO }}_pdns_auth:beta DOCKER_BUILDKIT: '1' run: | echo $TAG docker build \ --push \ --target=runtime \ - -f .docker/bind9.Dockerfile . \ + -f .docker/pdns_auth.Dockerfile . \ -t $TAG \ --cache-to type=gha,mode=max \ --cache-from $TAG \ diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index a607b3c7a..367f4e0bc 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -176,7 +176,7 @@ jobs: --build-arg BUILDKIT_INLINE_CACHE=1 \ --build-arg VERSION=latest - build-bind9: + build-pdns_auth: runs-on: ubuntu-latest needs: [build-tests, run-ssh-test, run-tests] steps: @@ -193,14 +193,14 @@ jobs: - name: Build docker image env: - TAG: ghcr.io/${{ env.REPO }}_bind9:latest + TAG: ghcr.io/${{ env.REPO }}_pdns_auth:latest DOCKER_BUILDKIT: '1' run: | echo $TAG docker build \ --push \ --target=runtime \ - -f .docker/bind9.Dockerfile . \ + -f .docker/pdns_auth.Dockerfile . \ -t $TAG \ --cache-to type=gha,mode=max \ --cache-from $TAG \ diff --git a/.package/docker-compose.yml b/.package/docker-compose.yml index 839a45e89..7a50b528d 100644 --- a/.package/docker-compose.yml +++ b/.package/docker-compose.yml @@ -296,27 +296,32 @@ services: - traefik.tcp.routers.kpasswd.service=kpasswd - traefik.tcp.services.kpasswd.loadbalancer.server.port=464 - bind_dns: - image: ghcr.io/multidirectorylab/multidirectory_bind9:${VERSION:-latest} - container_name: bind9 - hostname: bind9 - restart: unless-stopped + pdns_auth: + image: ghcr.io/multidirectorylab/multidirectory_pdns_auth:${VERSION:-latest} + container_name: pdns_auth + expose: + - 8082 + - 53 volumes: - - dns_server_file:/opt/ - - dns_server_config:/etc/bind/ - tty: true - env_file: - - .env - environment: - - USE_CONFIG_FILE_LOGGING=true - depends_on: - ldap_server: - condition: service_healthy - restart: true + - dns_lmdb:/var/lib/pdns-lmdb + - dns_config:/etc/powerdns + + + pdns_recursor: + image: powerdns/pdns-recursor-51:5.1.7 + container_name: pdns_recursor + expose: + - 8083 + ports: + - "53:53/udp" + - "53:53/tcp" + volumes: + - ./recursor.conf:/etc/powerdns/recursor.conf + - forward_zones:/etc/powerdns/recursor.d/ labels: - traefik.enable=true - - traefik.udp.routers.bind_dns_udp.entrypoints=bind_dns_udp - - traefik.udp.services.bind_dns_udp.loadbalancer.server.port=53 + - traefik.udp.routers.power_dns_recursor_udp.entrypoints=power_dns_recursor_udp + - traefik.udp.services.power_dns_recursor_udp.loadbalancer.server.port=53 kea_dhcp4: image: ghcr.io/multidirectorylab/multidirectory_dhcp4:${VERSION:-latest} @@ -427,3 +432,6 @@ volumes: leases: sockets: dhcp: + dns_lmdb: + dns_config: + forward_zones: diff --git a/.package/traefik.yml b/.package/traefik.yml index 77c0f9594..32036f652 100644 --- a/.package/traefik.yml +++ b/.package/traefik.yml @@ -32,7 +32,7 @@ entryPoints: address: ":749" kpasswd: address: ":464" - bind_dns_udp: + power_dns_recursor_udp: address: ":53/udp" websecure: address: ":443" diff --git a/app/api/main/adapters/dns.py b/app/api/main/adapters/dns.py index 352099fad..7dc68858d 100644 --- a/app/api/main/adapters/dns.py +++ b/app/api/main/adapters/dns.py @@ -7,22 +7,24 @@ from api.base_adapter import BaseAdapter from api.main.schema import ( DNSServiceForwardZoneCheckRequest, + DNSServiceForwardZoneRequest, + DNSServiceMasterZoneRequest, DNSServiceRecordCreateRequest, DNSServiceRecordDeleteRequest, DNSServiceRecordUpdateRequest, - DNSServiceReloadZoneRequest, + DNSServiceSetStateRequest, DNSServiceSetupRequest, - DNSServiceZoneCreateRequest, DNSServiceZoneDeleteRequest, - DNSServiceZoneUpdateRequest, ) -from ldap_protocol.dns.base import ( - DNSForwardServerStatus, - DNSForwardZone, - DNSRecords, - DNSServerParam, - DNSZone, +from ldap_protocol.dns.base import DNSForwardServerStatus +from ldap_protocol.dns.dto import ( + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRecordDTO, + DNSRRSetDTO, + DNSSettingsDTO, ) +from ldap_protocol.dns.enums import DNSRecordType from ldap_protocol.dns.use_cases import DNSUseCase @@ -31,85 +33,157 @@ class DNSFastAPIAdapter(BaseAdapter[DNSUseCase]): async def create_record( self, + zone_id: str, data: DNSServiceRecordCreateRequest, ) -> None: """Create DNS record.""" await self._service.create_record( - data.record_name, - data.record_value, - data.record_type, - data.ttl, - data.zone_name, + zone_id, + DNSRRSetDTO( + name=data.record_name, + type=DNSRecordType(data.record_type), + records=[ + DNSRecordDTO( + content=data.record_value, + disabled=False, + ), + ], + ttl=data.ttl, + ), ) async def delete_record( self, + zone_id: str, data: DNSServiceRecordDeleteRequest, ) -> None: """Delete DNS record.""" await self._service.delete_record( - data.record_name, - data.record_value, - data.record_type, - data.zone_name, + zone_id, + DNSRRSetDTO( + name=data.record_name, + type=data.record_type, + records=[ + DNSRecordDTO( + content=data.record_value, + disabled=False, + ), + ], + ), ) async def update_record( self, + zone_id: str, data: DNSServiceRecordUpdateRequest, ) -> None: """Update DNS record.""" await self._service.update_record( - data.record_name, - data.record_value, - data.record_type, - data.ttl, - data.zone_name, + zone_id, + DNSRRSetDTO( + name=data.record_name, + type=data.record_type, + records=[ + DNSRecordDTO( + content=data.record_value, + disabled=False, + ), + ], + ttl=data.ttl, + ), ) - async def get_all_records(self) -> list[DNSRecords]: + async def get_records(self, zone_id: str) -> list[DNSRRSetDTO]: """Get all DNS records of current zone.""" - return await self._service.get_all_records() + return await self._service.get_records(zone_id) async def get_dns_status(self) -> dict[str, str | None]: """Get DNS service status.""" return await self._service.get_dns_status() + async def set_dns_state( + self, + data: DNSServiceSetStateRequest, + ) -> None: + """Set DNS manager state.""" + await self._service.set_state(data.state) + async def setup_dns(self, data: DNSServiceSetupRequest) -> None: await self._service.setup_dns( - dns_status=data.dns_status, - domain=data.domain, - dns_ip_address=data.dns_ip_address, - tsig_key=data.tsig_key, + DNSSettingsDTO( + dns_server_ip=data.dns_ip_address, + tsig_key=data.tsig_key, + domain=data.domain, + ), ) - async def get_dns_zone(self) -> list[DNSZone]: - """Get all DNS zones.""" - return await self._service.get_all_zones_records() + async def create_forward_zone( + self, + data: DNSServiceForwardZoneRequest, + ) -> None: + """Create new DNS forward zone.""" + await self._service.create_zone( + DNSForwardZoneDTO( + id=data.zone_name, + name=data.zone_name, + servers=data.servers, + ), + ) - async def get_forward_dns_zones(self) -> list[DNSForwardZone]: + async def get_dns_forward_zones(self) -> list[DNSForwardZoneDTO]: """Get list of DNS forward zones with forwarders.""" return await self._service.get_forward_zones() - async def create_zone(self, data: DNSServiceZoneCreateRequest) -> None: + async def update_forward_zone( + self, + data: DNSServiceForwardZoneRequest, + ) -> None: + """Update DNS forward zone with given params.""" + await self._service.update_zone( + DNSForwardZoneDTO( + id=data.zone_name, + name=data.zone_name, + servers=data.servers, + ), + ) + + async def delete_forward_zones( + self, + data: DNSServiceZoneDeleteRequest, + ) -> None: + """Delete DNS forward zones.""" + await self._service.delete_forward_zones(data.zone_ids) + + async def create_zone( + self, + data: DNSServiceMasterZoneRequest, + ) -> None: """Create new DNS zone.""" await self._service.create_zone( - data.zone_name, - data.zone_type, - data.nameserver, - data.params, + DNSMasterZoneDTO( + id=data.zone_name, + name=data.zone_name, + dnssec=data.dnssec, + ), ) - async def update_zone(self, data: DNSServiceZoneUpdateRequest) -> None: + async def get_dns_master_zones(self) -> list[DNSMasterZoneDTO]: + """Get all DNS master zones.""" + return await self._service.get_zones() + + async def update_zone(self, data: DNSServiceMasterZoneRequest) -> None: """Update DNS zone with given params.""" await self._service.update_zone( - data.zone_name, - data.params, + DNSMasterZoneDTO( + id=data.zone_name, + name=data.zone_name, + dnssec=data.dnssec, + ), ) - async def delete_zone(self, data: DNSServiceZoneDeleteRequest) -> None: - """Delete DNS zone.""" - await self._service.delete_zone(data.zone_names) + async def delete_zones(self, data: DNSServiceZoneDeleteRequest) -> None: + """Delete DNS zones.""" + await self._service.delete_zones(data.zone_ids) async def check_dns_forward_zone( self, @@ -117,22 +191,3 @@ async def check_dns_forward_zone( ) -> list[DNSForwardServerStatus]: """Check DNS forward zone for availability.""" return await self._service.check_dns_forward_zone(data.dns_server_ips) - - async def reload_zone(self, data: DNSServiceReloadZoneRequest) -> None: - """Reload DNS zone.""" - await self._service.reload_zone(data.zone_name) - - async def update_server_options( - self, - data: list[DNSServerParam], - ) -> None: - """Update DNS server options.""" - await self._service.update_server_options(data) - - async def get_server_options(self) -> list[DNSServerParam]: - """Get list of modifiable DNS server params.""" - return await self._service.get_server_options() - - async def restart_server(self) -> None: - """Restart DNS server.""" - await self._service.restart_server() diff --git a/app/api/main/dns_router.py b/app/api/main/dns_router.py index d93382512..7e4b27a44 100644 --- a/app/api/main/dns_router.py +++ b/app/api/main/dns_router.py @@ -20,22 +20,21 @@ from api.main.adapters.dns import DNSFastAPIAdapter from api.main.schema import ( DNSServiceForwardZoneCheckRequest, + DNSServiceForwardZoneRequest, + DNSServiceMasterZoneRequest, DNSServiceRecordCreateRequest, DNSServiceRecordDeleteRequest, DNSServiceRecordUpdateRequest, - DNSServiceReloadZoneRequest, + DNSServiceSetStateRequest, DNSServiceSetupRequest, - DNSServiceZoneCreateRequest, DNSServiceZoneDeleteRequest, - DNSServiceZoneUpdateRequest, ) from enums import DomainCodes -from ldap_protocol.dns import ( - DNSForwardServerStatus, - DNSForwardZone, - DNSRecords, - DNSServerParam, - DNSZone, +from ldap_protocol.dns import DNSForwardServerStatus +from ldap_protocol.dns.dto import ( + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRRSetDTO, ) translator = DomainErrorTranslator(DomainCodes.DNS) @@ -50,6 +49,10 @@ status=status.HTTP_400_BAD_REQUEST, translator=translator, ), + dns_exc.DNSRecordGetError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), dns_exc.DNSRecordUpdateError: rule( status=status.HTTP_400_BAD_REQUEST, translator=translator, @@ -62,6 +65,10 @@ status=status.HTTP_400_BAD_REQUEST, translator=translator, ), + dns_exc.DNSZoneGetError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), dns_exc.DNSZoneUpdateError: rule( status=status.HTTP_400_BAD_REQUEST, translator=translator, @@ -90,45 +97,49 @@ dns_router = ErrorAwareRouter( prefix="/dns", - tags=["DNS_SERVICE"], + tags=["DNS Service"], dependencies=[Depends(verify_auth)], route_class=DishkaErrorAwareRoute, ) -@dns_router.post("/record", error_map=error_map) +@dns_router.post("/record/{zone_id}", error_map=error_map) async def create_record( + zone_id: str, data: DNSServiceRecordCreateRequest, adapter: FromDishka[DNSFastAPIAdapter], ) -> None: """Create DNS record with given params.""" - await adapter.create_record(data) + await adapter.create_record(zone_id, data) -@dns_router.delete("/record", error_map=error_map) -async def delete_single_record( - data: DNSServiceRecordDeleteRequest, +@dns_router.get("/record/{zone_id}", error_map=error_map) +async def get_all_records( + zone_id: str, adapter: FromDishka[DNSFastAPIAdapter], -) -> None: - """Delete DNS record with given params.""" - await adapter.delete_record(data) +) -> list[DNSRRSetDTO]: + """Get all DNS records of current zone.""" + return await adapter.get_records(zone_id) -@dns_router.patch("/record", error_map=error_map) +@dns_router.patch("/record/{zone_id}", error_map=error_map) async def update_record( + zone_id: str, data: DNSServiceRecordUpdateRequest, adapter: FromDishka[DNSFastAPIAdapter], ) -> None: """Update DNS record with given params.""" - await adapter.update_record(data) + await adapter.update_record(zone_id, data) -@dns_router.get("/record", error_map=error_map) -async def get_all_records( +@dns_router.delete("/record/{zone_id}", error_map=error_map) +async def delete_single_record( + zone_id: str, + data: DNSServiceRecordDeleteRequest, adapter: FromDishka[DNSFastAPIAdapter], -) -> list[DNSRecords]: - """Get all DNS records of current zone.""" - return await adapter.get_all_records() +) -> None: + """Delete DNS record with given params.""" + await adapter.delete_record(zone_id, data) @dns_router.get("/status", error_map=error_map) @@ -148,20 +159,48 @@ async def setup_dns( await adapter.setup_dns(data) -@dns_router.get("/zone", error_map=error_map) -async def get_dns_zone( +@dns_router.post("/state", error_map=error_map) +async def set_dns_state( + data: DNSServiceSetStateRequest, adapter: FromDishka[DNSFastAPIAdapter], -) -> list[DNSZone]: - """Get all DNS records of all zones.""" - return await adapter.get_dns_zone() +) -> None: + """Set DNS manager state.""" + await adapter.set_dns_state(data) + + +@dns_router.post("/zone/forward", error_map=error_map) +async def create_forward_zone( + data: DNSServiceForwardZoneRequest, + adapter: FromDishka[DNSFastAPIAdapter], +) -> None: + """Create new forward DNS zone.""" + return await adapter.create_forward_zone(data) @dns_router.get("/zone/forward", error_map=error_map) async def get_forward_dns_zones( adapter: FromDishka[DNSFastAPIAdapter], -) -> list[DNSForwardZone]: +) -> list[DNSForwardZoneDTO]: """Get list of DNS forward zones with forwarders.""" - return await adapter.get_forward_dns_zones() + return await adapter.get_dns_forward_zones() + + +@dns_router.patch("/zone/forward", error_map=error_map) +async def update_forward_zone( + data: DNSServiceForwardZoneRequest, + adapter: FromDishka[DNSFastAPIAdapter], +) -> None: + """Update forward DNS zone with given params.""" + await adapter.update_forward_zone(data) + + +@dns_router.delete("/zone/forward", error_map=error_map) +async def delete_forward_zone( + data: DNSServiceZoneDeleteRequest, + adapter: FromDishka[DNSFastAPIAdapter], +) -> None: + """Delete DNS forward zone.""" + await adapter.delete_forward_zones(data) @dns_router.post( @@ -171,16 +210,24 @@ async def get_forward_dns_zones( default_client_error_translator=translator, ) async def create_zone( - data: DNSServiceZoneCreateRequest, + data: DNSServiceMasterZoneRequest, adapter: FromDishka[DNSFastAPIAdapter], ) -> None: """Create new DNS zone.""" await adapter.create_zone(data) +@dns_router.get("/zone", error_map=error_map) +async def get_dns_zones( + adapter: FromDishka[DNSFastAPIAdapter], +) -> list[DNSMasterZoneDTO]: + """Get all DNS records of all zones.""" + return await adapter.get_dns_master_zones() + + @dns_router.patch("/zone", error_map=error_map) async def update_zone( - data: DNSServiceZoneUpdateRequest, + data: DNSServiceMasterZoneRequest, adapter: FromDishka[DNSFastAPIAdapter], ) -> None: """Update DNS zone with given params.""" @@ -193,7 +240,7 @@ async def delete_zone( adapter: FromDishka[DNSFastAPIAdapter], ) -> None: """Delete DNS zone.""" - await adapter.delete_zone(data) + await adapter.delete_zones(data) @dns_router.post("/forward_check", error_map=error_map) @@ -203,37 +250,3 @@ async def check_dns_forward_zone( ) -> list[DNSForwardServerStatus]: """Check given DNS forward zone for availability.""" return await adapter.check_dns_forward_zone(data) - - -@dns_router.get("/zone/reload/", error_map=error_map) -async def reload_zone( - data: DNSServiceReloadZoneRequest, - adapter: FromDishka[DNSFastAPIAdapter], -) -> None: - """Reload given DNS zone.""" - await adapter.reload_zone(data) - - -@dns_router.patch("/server/options") -async def update_server_options( - data: list[DNSServerParam], - adapter: FromDishka[DNSFastAPIAdapter], -) -> None: - """Update DNS server options.""" - await adapter.update_server_options(data) - - -@dns_router.get("/server/options") -async def get_server_options( - adapter: FromDishka[DNSFastAPIAdapter], -) -> list[DNSServerParam]: - """Get list of modifiable DNS server params.""" - return await adapter.get_server_options() - - -@dns_router.get("/server/restart") -async def restart_server( - adapter: FromDishka[DNSFastAPIAdapter], -) -> None: - """Restart entire DNS server.""" - await adapter.restart_server() diff --git a/app/api/main/schema.py b/app/api/main/schema.py index 537b0af7c..30943193f 100644 --- a/app/api/main/schema.py +++ b/app/api/main/schema.py @@ -12,7 +12,7 @@ from sqlalchemy.sql.elements import ColumnElement, UnaryExpression from entities import Directory -from ldap_protocol.dns import DNSManagerState, DNSZoneParam, DNSZoneType +from ldap_protocol.dns.enums import DNSManagerState, DNSRecordType from ldap_protocol.filter_interpreter import ( Filter, FilterInterpreterProtocol, @@ -70,10 +70,15 @@ class KerberosSetupRequest(BaseModel): stash_password: SecretStr +class DNSServiceSetStateRequest(BaseModel): + """DNS set state request schema.""" + + state: DNSManagerState + + class DNSServiceSetupRequest(BaseModel): """DNS setup request schema.""" - dns_status: DNSManagerState domain: str dns_ip_address: IPv4Address | IPv6Address | None = None tsig_key: str | None = None @@ -83,8 +88,7 @@ class DNSServiceRecordBaseRequest(BaseModel): """DNS setup base schema.""" record_name: str - record_type: str - zone_name: str | None = None + record_type: DNSRecordType class DNSServiceRecordCreateRequest(DNSServiceRecordBaseRequest): @@ -103,36 +107,29 @@ class DNSServiceRecordDeleteRequest(DNSServiceRecordBaseRequest): class DNSServiceRecordUpdateRequest(DNSServiceRecordBaseRequest): """DNS update request schema.""" - record_value: str | None = None + record_value: str ttl: int | None = None -class DNSServiceZoneCreateRequest(BaseModel): +class DNSServiceForwardZoneRequest(BaseModel): """DNS zone create request scheme.""" zone_name: str - zone_type: DNSZoneType - nameserver: str | None = None - params: list[DNSZoneParam] + servers: list[str] -class DNSServiceZoneUpdateRequest(BaseModel): - """DNS zone update request scheme.""" +class DNSServiceMasterZoneRequest(BaseModel): + """DNS zone create request scheme.""" zone_name: str - params: list[DNSZoneParam] + nameserver_ip: str + dnssec: bool = False class DNSServiceZoneDeleteRequest(BaseModel): """DNS zone delete request scheme.""" - zone_names: list[str] - - -class DNSServiceReloadZoneRequest(BaseModel): - """DNS zone reload request scheme.""" - - zone_name: str + zone_ids: list[str] class DNSServiceForwardZoneCheckRequest(BaseModel): @@ -141,13 +138,6 @@ class DNSServiceForwardZoneCheckRequest(BaseModel): dns_server_ips: list[IPv4Address | IPv6Address] -class DNSServiceOptionsUpdateRequest(BaseModel): - """DNS server options update request scheme.""" - - name: str - value: str | list[str] = "" - - class PrimaryGroupRequest(BaseModel): """Request schema for setting primary group.""" diff --git a/app/config.py b/app/config.py index e5b82da31..45331193d 100644 --- a/app/config.py +++ b/app/config.py @@ -128,7 +128,10 @@ def POSTGRES_URI(self) -> PostgresDsn: # noqa autoescape=True, ) - DNS_BIND_HOST: str = "bind_dns" + PDNS_AUTH_SERVER_HOST: str = "pdns_auth" + PDNS_RECURSOR_SERVER_HOST: str = "pdns_recursor" + PDNS_API_KEY: str + DEFAULT_NAMESERVER: str ENABLE_SQLALCHEMY_LOGGING: bool = False PYTEST_XDIST_WORKER: str = "master" diff --git a/app/enums.py b/app/enums.py index 5258c3a0d..f9b556f32 100644 --- a/app/enums.py +++ b/app/enums.py @@ -169,16 +169,12 @@ class AuthorizationRules(IntFlag): DNS_UPDATE_RECORD = auto() DNS_GET_ALL_RECORDS = auto() DNS_GET_DNS_STATUS = auto() - DNS_GET_ALL_ZONES_RECORDS = auto() DNS_GET_FORWARD_ZONES = auto() + DNS_DELETE_FWD_ZONES = auto() DNS_CREATE_ZONE = auto() DNS_UPDATE_ZONE = auto() DNS_DELETE_ZONE = auto() DNS_CHECK_DNS_FORWARD_ZONE = auto() - DNS_RELOAD_ZONE = auto() - DNS_UPDATE_SERVER_OPTIONS = auto() - DNS_GET_SERVER_OPTIONS = auto() - DNS_RESTART_SERVER = auto() KRB_SETUP_CATALOGUE = auto() KRB_SETUP_KDC = auto() diff --git a/app/ioc.py b/app/ioc.py index 3ad06c6b4..d5e3f4a72 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -54,6 +54,7 @@ from ldap_protocol.dns import ( AbstractDNSManager, DNSManagerSettings, + PowerDNSManager, get_dns_manager_class, ) from ldap_protocol.dns.dns_gateway import DNSStateGateway @@ -146,7 +147,11 @@ SessionStorageClient = NewType("SessionStorageClient", redis.Redis) KadminHTTPClient = NewType("KadminHTTPClient", httpx.AsyncClient) -DNSManagerHTTPClient = NewType("DNSManagerHTTPClient", httpx.AsyncClient) +PDNSAuthServerClient = NewType("PDNSAuthServerClient", httpx.AsyncClient) +PDNSRecursorServerClient = NewType( + "PDNSRecursorServerClient", + httpx.AsyncClient, +) MFAHTTPClient = NewType("MFAHTTPClient", httpx.AsyncClient) DHCPManagerHTTPClient = NewType("DHCPManagerHTTPClient", httpx.AsyncClient) @@ -251,32 +256,53 @@ async def get_dns_mngr_settings( ) -> DNSManagerSettings: """Get DNS manager's settings.""" resolve_coro = resolve_dns_server_ip( - settings.DNS_BIND_HOST, + settings.PDNS_RECURSOR_SERVER_HOST, ) return await dns_state_gateway.get_dns_manager_settings( resolve_coro, ) @provide(scope=Scope.APP) - async def get_dns_http_client( + async def get_pdns_auth_server_client( + self, + settings: Settings, + ) -> AsyncIterator[PDNSAuthServerClient]: + """Get async client for PDNS auth server.""" + async with httpx.AsyncClient( + base_url=f"http://{settings.PDNS_AUTH_SERVER_HOST}:8082/api/v1/servers/localhost", + headers={"X-API-Key": settings.PDNS_API_KEY}, + ) as client: + yield PDNSAuthServerClient(client) + + @provide(scope=Scope.APP) + async def get_pdns_recursor_server_client( self, settings: Settings, - ) -> AsyncIterator[DNSManagerHTTPClient]: - """Get async client for DNS manager.""" + ) -> AsyncIterator[PDNSRecursorServerClient]: + """Get async client for PDNS recursor server.""" async with httpx.AsyncClient( - base_url=f"http://{settings.DNS_BIND_HOST}:8000", + base_url=f"http://{settings.PDNS_RECURSOR_SERVER_HOST}:8083/api/v1/servers/localhost", + headers={"X-API-Key": settings.PDNS_API_KEY}, ) as client: - yield DNSManagerHTTPClient(client) + yield PDNSRecursorServerClient(client) @provide(scope=Scope.REQUEST) async def get_dns_mngr( self, settings: DNSManagerSettings, dns_manager_class: type[AbstractDNSManager], - http_client: DNSManagerHTTPClient, + auth_server_client: PDNSAuthServerClient, + recursor_server_client: PDNSRecursorServerClient, ) -> AsyncIterator[AbstractDNSManager]: """Get DNSManager class.""" - yield dns_manager_class(settings=settings, http_client=http_client) + if issubclass(dns_manager_class, PowerDNSManager): + yield dns_manager_class( + settings=settings, + client_authoritative=auth_server_client, + client_recursor=recursor_server_client, + ) + else: + yield dns_manager_class(settings=settings) @provide(scope=Scope.APP) async def get_redis_for_sessions( diff --git a/app/ldap_protocol/dns/__init__.py b/app/ldap_protocol/dns/__init__.py index f9c97fba7..6e95df580 100644 --- a/app/ldap_protocol/dns/__init__.py +++ b/app/ldap_protocol/dns/__init__.py @@ -6,7 +6,6 @@ DNSForwardServerStatus, DNSForwardZone, DNSManagerSettings, - DNSManagerState, DNSNotImplementedError, DNSRecords, DNSServerParam, @@ -17,9 +16,10 @@ DNSZoneType, ) from .dns_gateway import DNSStateGateway +from .enums import DNSManagerState from .exceptions import DNSConnectionError, DNSError -from .remote import RemoteDNSManager -from .selfhosted import SelfHostedDNSManager +from .power_dns_manager import PowerDNSManager +from .remote_dns_manager import RemoteDNSManager from .stub import StubDNSManager @@ -29,7 +29,7 @@ async def get_dns_manager_class( """Get DNS manager class.""" dns_state = await dns_state_gateway.get_dns_state() if dns_state == DNSManagerState.SELFHOSTED: - return SelfHostedDNSManager + return PowerDNSManager elif dns_state == DNSManagerState.HOSTED: return RemoteDNSManager return StubDNSManager @@ -38,8 +38,8 @@ async def get_dns_manager_class( __all__ = [ "get_dns_manager_class", "AbstractDNSManager", + "PowerDNSManager", "RemoteDNSManager", - "SelfHostedDNSManager", "StubDNSManager", "DNSStateGateway", "DNSForwardServerStatus", diff --git a/app/ldap_protocol/dns/base.py b/app/ldap_protocol/dns/base.py index 01fe71c8c..e041c485b 100644 --- a/app/ldap_protocol/dns/base.py +++ b/app/ldap_protocol/dns/base.py @@ -9,12 +9,16 @@ from enum import StrEnum from ipaddress import IPv4Address, IPv6Address -import httpx from loguru import logger as loguru_logger -from ldap_protocol.dns.dto import DNSSettingDTO - -from .exceptions import DNSSetupError +from .dto import ( + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRRSetDTO, + DNSSettingsDTO, + DNSZoneBaseDTO, +) +from .enums import DNSForwarderServerStatus DNS_MANAGER_STATE_NAME = "DNSManagerState" DNS_MANAGER_ZONE_NAME = "DNSManagerZoneName" @@ -38,32 +42,18 @@ class DNSZoneType(StrEnum): FORWARD = "forward" -class DNSForwarderServerStatus(StrEnum): - """Forwarder DNS server statuses.""" +class DNSConnectionError(ConnectionError): + """API Error.""" + - VALIDATED = "validated" - NOT_VALIDATED = "not validated" - NOT_FOUND = "not found" +class DNSError(Exception): + """DNS Error.""" class DNSNotImplementedError(NotImplementedError): """API Not Implemented Error.""" -class DNSRecordType(StrEnum): - """DNS record types.""" - - a = "A" - aaaa = "AAAA" - cname = "CNAME" - mx = "MX" - ns = "NS" - txt = "TXT" - soa = "SOA" - ptr = "PTR" - srv = "SRV" - - class DNSZoneParamName(StrEnum): """Possible DNS zone option names.""" @@ -78,14 +68,6 @@ class DNSServerParamName(StrEnum): dnssec = "dnssec-validation" -class DNSManagerState(StrEnum): - """DNSManager state enum.""" - - NOT_CONFIGURED = "0" - SELFHOSTED = "1" - HOSTED = "2" - - @dataclass class DNSZoneParam: """DNS zone parameter.""" @@ -171,136 +153,88 @@ class AbstractDNSManager: """Abstract DNS manager class.""" _dns_settings: DNSManagerSettings - _http_client: httpx.AsyncClient def __init__( self, settings: DNSManagerSettings, - http_client: httpx.AsyncClient, ) -> None: """Set up DNS manager.""" self._dns_settings = settings - self._http_client = http_client + @abstractmethod async def setup( self, - dns_status: str, - domain: str, - dns_ip_address: str | IPv4Address | IPv6Address | None, - tsig_key: str | None, - ) -> DNSSettingDTO: + dns_server_settings: DNSSettingsDTO, + ) -> None: """Set up DNS server and DNS manager.""" - try: - if ( - dns_status == DNSManagerState.SELFHOSTED - and self._http_client is not None - ): - await self._http_client.post( - "/server/setup", - json={"zone_name": domain}, - ) - tsig_key = None - return DNSSettingDTO( - zone_name=domain, - dns_server_ip=dns_ip_address, - tsig_key=tsig_key, - ) - - except Exception as e: - raise DNSSetupError(e) + raise DNSNotImplementedError @abstractmethod async def create_record( self, - hostname: str, - ip: str, - record_type: str, - ttl: int | None, - zone_name: str | None = None, + zone_id: str, + record: DNSRRSetDTO, ) -> None: ... @abstractmethod async def update_record( self, - hostname: str, - ip: str | None, - record_type: str, - ttl: int | None, - zone_name: str | None = None, + zone_id: str, + record: DNSRRSetDTO, ) -> None: ... @abstractmethod async def delete_record( self, - hostname: str, - ip: str, - record_type: str, - zone_name: str | None = None, + zone_id: str, + record: DNSRRSetDTO, ) -> None: ... @abstractmethod - async def get_all_records(self) -> list[DNSRecords]: ... + async def get_records( + self, + zone_id: str, + ) -> list[DNSRRSetDTO]: ... @abstractmethod - async def get_all_zones_records(self) -> list[DNSZone]: - raise DNSNotImplementedError + async def get_zones(self) -> list[DNSMasterZoneDTO]: ... @abstractmethod - async def get_forward_zones(self) -> list[DNSForwardZone]: + async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: raise DNSNotImplementedError @abstractmethod async def create_zone( self, - zone_name: str, - zone_type: DNSZoneType, - nameserver: str | None, - params: list[DNSZoneParam], + zone: DNSZoneBaseDTO, ) -> None: raise DNSNotImplementedError @abstractmethod async def update_zone( self, - zone_name: str, - params: list[DNSZoneParam] | None, + zone: DNSZoneBaseDTO, ) -> None: raise DNSNotImplementedError @abstractmethod async def delete_zone( self, - zone_names: list[str], + zone_id: str, ) -> None: raise DNSNotImplementedError @abstractmethod - async def check_forward_dns_server( + async def delete_forward_zone( self, - dns_server_ip: IPv4Address | IPv6Address, - host_dns_servers: list[str], - ) -> DNSForwardServerStatus: - raise DNSNotImplementedError - - @abstractmethod - async def update_server_options( - self, - params: list[DNSServerParam], + zone_id: str, ) -> None: raise DNSNotImplementedError @abstractmethod - async def get_server_options(self) -> list[DNSServerParam]: ... - - @abstractmethod - async def restart_server( - self, - ) -> None: - raise DNSNotImplementedError - - @abstractmethod - async def reload_zone( + async def check_forward_dns_server( self, - zone_name: str, - ) -> None: + dns_server_ip: IPv4Address | IPv6Address, + host_dns_servers: list[str], + ) -> DNSForwardServerStatus: raise DNSNotImplementedError diff --git a/app/ldap_protocol/dns/constants.py b/app/ldap_protocol/dns/constants.py new file mode 100644 index 000000000..8f23894da --- /dev/null +++ b/app/ldap_protocol/dns/constants.py @@ -0,0 +1,57 @@ +"""Constants for DNS module. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from .enums import DNSRecordType + +DNS_FIRST_SETUP_RECORDS: list[dict[str, str]] = [ + {"name": "_ldap._tcp.", "value": "0 0 389 ", "type": DNSRecordType.SRV}, + {"name": "_ldaps._tcp.", "value": "0 0 636 ", "type": DNSRecordType.SRV}, + {"name": "_kerberos._tcp.", "value": "0 0 88 ", "type": DNSRecordType.SRV}, + {"name": "_kerberos._udp.", "value": "0 0 88 ", "type": DNSRecordType.SRV}, + {"name": "_kdc._tcp.", "value": "0 0 88 ", "type": DNSRecordType.SRV}, + {"name": "_kdc._udp.", "value": "0 0 88 ", "type": DNSRecordType.SRV}, + {"name": "_kpasswd._tcp.", "value": "0 0 464 ", "type": DNSRecordType.SRV}, + {"name": "_kpasswd._udp.", "value": "0 0 464 ", "type": DNSRecordType.SRV}, + # Record for PDC Emulator + { + "name": "_ldap._tcp.pdc._msdcs.", + "value": "0 100 389 ", + "type": DNSRecordType.SRV, + }, + # Records for DC Locator (for trusts) + { + "name": "_kerberos._tcp.dc._msdcs.", + "value": "0 100 88 ", + "type": DNSRecordType.SRV, + }, + { + "name": "_kerberos._tcp.Default-First-Site-Name._sites.dc._msdcs.", + "value": "0 100 88 ", + "type": DNSRecordType.SRV, + }, + { + "name": "_ldap._tcp.dc._msdcs.", + "value": "0 100 389 ", + "type": DNSRecordType.SRV, + }, + { + "name": "_ldap._tcp.Default-First-Site-Name._sites.dc._msdcs.", + "value": "0 100 389 ", + "type": DNSRecordType.SRV, + }, + # Records for Global Catalog + {"name": "_gc._tcp.", "value": "0 100 3268 ", "type": DNSRecordType.SRV}, + { + "name": "_ldap._tcp.Default-First-Site-Name._sites.gc._msdcs.", + "value": "0 100 3268 ", + "type": DNSRecordType.SRV, + }, + { + "name": "_ldap._tcp.gc._msdcs.", + "value": "0 100 3268 ", + "type": DNSRecordType.SRV, + }, +] diff --git a/app/ldap_protocol/dns/dns_gateway.py b/app/ldap_protocol/dns/dns_gateway.py index c5a525f35..543bd4319 100644 --- a/app/ldap_protocol/dns/dns_gateway.py +++ b/app/ldap_protocol/dns/dns_gateway.py @@ -16,11 +16,12 @@ DNS_MANAGER_TSIG_KEY_NAME, DNS_MANAGER_ZONE_NAME, DNSManagerSettings, - DNSManagerState, ) -from ldap_protocol.dns.dto import DNSSettingDTO +from ldap_protocol.dns.dto import DNSSettingsDTO from repo.pg.tables import queryable_attr as qa +from .enums import DNSManagerState + class DNSStateGateway: """DNS gateway.""" @@ -29,22 +30,12 @@ def __init__(self, session: AsyncSession) -> None: """Initialize DNS gateway.""" self._session = session - async def setup_dns_state( - self, - state: DNSManagerState | str, - ) -> None: - """Set up DNS server and DNS manager.""" - await self._session.execute( - update(CatalogueSetting) - .values({"value": state}) - .filter_by(name=DNS_MANAGER_STATE_NAME), - ) - async def get(self, name: str) -> CatalogueSetting | None: """Get DNS by name.""" return await self._session.scalar( - select(CatalogueSetting).filter_by(name=name), - ) + select(CatalogueSetting) + .filter_by(name=name), + ) # fmt: skip async def create(self, data: CatalogueSetting) -> None: """Create DNS.""" @@ -53,30 +44,32 @@ async def create(self, data: CatalogueSetting) -> None: async def get_dns_settings(self) -> dict[str, str]: """Get DNS managers.""" - return { - setting.name: setting.value - for setting in await self._session.scalars( - select(CatalogueSetting).filter( - qa(CatalogueSetting.name).in_( - [ - DNS_MANAGER_ZONE_NAME, - DNS_MANAGER_IP_ADDRESS_NAME, - DNS_MANAGER_TSIG_KEY_NAME, - ], - ), + settings = await self._session.scalars( + select(CatalogueSetting) + .filter( + qa(CatalogueSetting.name) + .in_( + [ + DNS_MANAGER_ZONE_NAME, + DNS_MANAGER_IP_ADDRESS_NAME, + DNS_MANAGER_TSIG_KEY_NAME, + ], ), - ) - } + ), + ) # fmt: skip + result = {setting.name: setting.value for setting in settings} + + return result async def update_settings( self, - data: DNSSettingDTO, + data: DNSSettingsDTO, ) -> None: """Update DNS settings.""" settings = [ ( qa(CatalogueSetting.name) == DNS_MANAGER_ZONE_NAME, - data.zone_name, + data.domain, ), ( qa(CatalogueSetting.name) == DNS_MANAGER_IP_ADDRESS_NAME, @@ -111,14 +104,14 @@ async def update_settings( async def create_settings( self, - data: DNSSettingDTO, + data: DNSSettingsDTO, ) -> None: """Create DNS settings.""" self._session.add_all( [ CatalogueSetting( name=DNS_MANAGER_ZONE_NAME, - value=data.zone_name or "", + value=data.domain or "", ), CatalogueSetting( name=DNS_MANAGER_IP_ADDRESS_NAME, @@ -161,3 +154,30 @@ async def get_dns_state(self) -> DNSManagerState: ) return DNSManagerState.NOT_CONFIGURED return DNSManagerState(state.value) + + async def set_state( + self, + state: DNSManagerState, + ) -> None: + """Set DNS state.""" + existing_state = await self.get(DNS_MANAGER_STATE_NAME) + if existing_state is None: + await self.create( + CatalogueSetting( + name=DNS_MANAGER_STATE_NAME, + value=state, + ), + ) + else: + await self._session.execute( + update(CatalogueSetting) + .values({"value": state}) + .filter_by(name=DNS_MANAGER_STATE_NAME), + ) + + async def get_state(self) -> DNSManagerState: + """Get DNS state.""" + state = await self.get(DNS_MANAGER_STATE_NAME) + if state is None: + return DNSManagerState.NOT_CONFIGURED + return DNSManagerState(state.value) diff --git a/app/ldap_protocol/dns/dto.py b/app/ldap_protocol/dns/dto.py index 8edd4d781..f3ddbdc50 100644 --- a/app/ldap_protocol/dns/dto.py +++ b/app/ldap_protocol/dns/dto.py @@ -4,14 +4,74 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from dataclasses import dataclass +from dataclasses import dataclass, field from ipaddress import IPv4Address, IPv6Address +from .enums import DNSRecordType, PowerDNSRecordChangeType, PowerDNSZoneType + @dataclass -class DNSSettingDTO: - """DNS settings entity.""" +class DNSSettingsDTO: + """DNS settings DTO.""" - zone_name: str | None - dns_server_ip: str | IPv4Address | IPv6Address | None + domain: str + dns_server_ip: IPv4Address | IPv6Address | None tsig_key: str | None + + +@dataclass +class DNSServerDTO: + """DNS server DTO.""" + + id: str + daemon_type: str + version: str + type: str = "server" + + +@dataclass +class DNSRecordDTO: + """DNS record DTO.""" + + content: str + disabled: bool + modified_at: int | None = None + + +@dataclass +class DNSRRSetDTO: + """DNS RRSet(Resource Record Set) DTO.""" + + name: str + type: DNSRecordType + records: list[DNSRecordDTO] + changetype: PowerDNSRecordChangeType | None = None + ttl: int | None = None + + +@dataclass +class DNSZoneBaseDTO: + """DNS zone DTO.""" + + id: str + name: str + rrsets: list[DNSRRSetDTO] = field(default_factory=list) + type: str = "zone" + + +@dataclass +class DNSMasterZoneDTO(DNSZoneBaseDTO): + """DNS master zone DTO.""" + + dnssec: bool = field(default=False) + nameservers: list[str] = field(default_factory=list) + kind: PowerDNSZoneType = PowerDNSZoneType.MASTER + + +@dataclass +class DNSForwardZoneDTO(DNSZoneBaseDTO): + """DNS forward zone DTO.""" + + servers: list[str] = field(default_factory=list) + recursion_desired: bool = field(default=False) + kind: PowerDNSZoneType = PowerDNSZoneType.FORWARDED diff --git a/app/ldap_protocol/dns/enums.py b/app/ldap_protocol/dns/enums.py new file mode 100644 index 000000000..bdc24de8d --- /dev/null +++ b/app/ldap_protocol/dns/enums.py @@ -0,0 +1,53 @@ +"""Enums for DNS module. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from enum import StrEnum + + +class DNSRecordType(StrEnum): + """PowerDNS Record Types.""" + + A = "A" + AAAA = "AAAA" + CNAME = "CNAME" + MX = "MX" + TXT = "TXT" + NS = "NS" + SOA = "SOA" + SRV = "SRV" + PTR = "PTR" + + +class PowerDNSZoneType(StrEnum): + """PowerDNS Zone Types.""" + + MASTER = "Master" + FORWARDED = "Forwarded" + + +class PowerDNSRecordChangeType(StrEnum): + """PowerDNS Record Change Types.""" + + REPLACE = "REPLACE" + DELETE = "DELETE" + EXTEND = "EXTEND" + PRUNE = "PRUNE" + + +class DNSForwarderServerStatus(StrEnum): + """Forwarder DNS server statuses.""" + + VALIDATED = "validated" + NOT_VALIDATED = "not validated" + NOT_FOUND = "not found" + + +class DNSManagerState(StrEnum): + """DNSManager state enum.""" + + NOT_CONFIGURED = "0" + SELFHOSTED = "1" + HOSTED = "2" diff --git a/app/ldap_protocol/dns/exceptions.py b/app/ldap_protocol/dns/exceptions.py index 5b9da9f5e..cb7d3048e 100644 --- a/app/ldap_protocol/dns/exceptions.py +++ b/app/ldap_protocol/dns/exceptions.py @@ -14,15 +14,18 @@ class ErrorCodes(IntEnum): BASE_ERROR = 0 DNS_SETUP_ERROR = 1 - DNS_RECORD_CREATE_ERROR = 2 - DNS_RECORD_UPDATE_ERROR = 3 - DNS_RECORD_DELETE_ERROR = 4 - DNS_ZONE_CREATE_ERROR = 5 - DNS_ZONE_UPDATE_ERROR = 6 - DNS_ZONE_DELETE_ERROR = 7 - DNS_UPDATE_SERVER_OPTIONS_ERROR = 8 - DNS_CONNECTION_ERROR = 9 - DNS_NOT_IMPLEMENTED_ERROR = 10 + DNS_RECORD_GET_ERROR = 2 + DNS_RECORD_CREATE_ERROR = 3 + DNS_RECORD_UPDATE_ERROR = 4 + DNS_RECORD_DELETE_ERROR = 5 + DNS_ZONE_GET_ERROR = 6 + DNS_ZONE_CREATE_ERROR = 7 + DNS_ZONE_UPDATE_ERROR = 8 + DNS_ZONE_DELETE_ERROR = 9 + DNS_UPDATE_SERVER_OPTIONS_ERROR = 10 + DNS_CONNECTION_ERROR = 11 + DNS_NOT_IMPLEMENTED_ERROR = 12 + DNS_UNAVAILABLE_ERROR = 13 class DNSError(BaseDomainException): @@ -43,6 +46,12 @@ class DNSRecordCreateError(DNSError): code = ErrorCodes.DNS_RECORD_CREATE_ERROR +class DNSRecordGetError(DNSError): + """DNS record get error.""" + + code = ErrorCodes.DNS_RECORD_GET_ERROR + + class DNSRecordUpdateError(DNSError): """DNS record update error.""" @@ -61,6 +70,12 @@ class DNSZoneCreateError(DNSError): code = ErrorCodes.DNS_ZONE_CREATE_ERROR +class DNSZoneGetError(DNSError): + """DNS zone get error.""" + + code = ErrorCodes.DNS_ZONE_GET_ERROR + + class DNSZoneUpdateError(DNSError): """DNS zone update error.""" @@ -89,3 +104,33 @@ class DNSNotImplementedError(DNSError): """DNS not implemented error.""" code = ErrorCodes.DNS_NOT_IMPLEMENTED_ERROR + + +class DNSUnavailableError(DNSError): + """DNS server is unavailable.""" + + code = ErrorCodes.DNS_UNAVAILABLE_ERROR + + +class DNSCreateEntryError(DNSError): + """DNS create entry error.""" + + +class DNSDeleteEntryError(DNSError): + """DNS delete entry error.""" + + +class DNSUpdateEntryError(DNSError): + """DNS update entry error.""" + + +class DNSEntryNotFoundError(DNSError): + """DNS entry not found error.""" + + +class DNSValidationError(DNSError): + """DNS validation error.""" + + +class DNSNotSupportedError(DNSError): + """DNS not supported error.""" diff --git a/app/ldap_protocol/dns/power_dns_manager.py b/app/ldap_protocol/dns/power_dns_manager.py new file mode 100644 index 000000000..6bf02588c --- /dev/null +++ b/app/ldap_protocol/dns/power_dns_manager.py @@ -0,0 +1,346 @@ +"""PowerDNS API manager module. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +import asyncio +from ipaddress import IPv4Address, IPv6Address + +import dns.asyncresolver +import httpx +from adaptix import Retort +from fastapi import status + +from .base import ( + AbstractDNSManager, + DNSForwarderServerStatus, + DNSForwardServerStatus, + DNSManagerSettings, +) +from .constants import DNS_FIRST_SETUP_RECORDS +from .dto import ( + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRecordDTO, + DNSRRSetDTO, + DNSSettingsDTO, + DNSZoneBaseDTO, +) +from .enums import DNSRecordType, PowerDNSRecordChangeType +from .exceptions import ( + DNSEntryNotFoundError, + DNSError, + DNSNotSupportedError, + DNSRecordCreateError, + DNSRecordDeleteError, + DNSRecordGetError, + DNSRecordUpdateError, + DNSSetupError, + DNSUnavailableError, + DNSValidationError, + DNSZoneCreateError, + DNSZoneDeleteError, + DNSZoneGetError, + DNSZoneUpdateError, +) +from .utils import create_initial_zone_records + +base_retort = Retort() + + +class PowerDNSManager(AbstractDNSManager): + """Manager for interacting with the PowerDNS API.""" + + _client_authoritative: httpx.AsyncClient + _client_recursor: httpx.AsyncClient + + def __init__( + self, + settings: DNSManagerSettings, + client_authoritative: httpx.AsyncClient, + client_recursor: httpx.AsyncClient, + ) -> None: + """Initialize the PowerDNS API repository.""" + super().__init__(settings) + self._client_authoritative = client_authoritative + self._client_recursor = client_recursor + + @staticmethod + def _normalize_dns_name(name: str) -> str: + """Normalize DNS name by ensuring it ends with a dot.""" + return name if name.endswith(".") else f"{name}." + + def _get_client_by_zone_kind( + self, + zone: DNSZoneBaseDTO, + ) -> httpx.AsyncClient: + """Get the appropriate HTTP client based on zone kind.""" + if isinstance(zone, DNSForwardZoneDTO): + return self._client_recursor + else: + return self._client_authoritative + + async def _validate_response(self, response: httpx.Response) -> None: + """Validate the API response.""" + match response.status_code: + case status.HTTP_400_BAD_REQUEST: + raise DNSNotSupportedError(response.text or "Bad Request") + case status.HTTP_404_NOT_FOUND: + raise DNSEntryNotFoundError(response.text or "Not Found") + case status.HTTP_422_UNPROCESSABLE_ENTITY: + raise DNSValidationError( + response.text or "Unprocessable Entity", + ) + case status.HTTP_500_INTERNAL_SERVER_ERROR: + raise DNSUnavailableError( + response.text or "Internal Server Error", + ) + + async def setup(self, dns_server_settings: DNSSettingsDTO) -> None: + """Set up DNS server and DNS manager.""" + records = [] + + for record in DNS_FIRST_SETUP_RECORDS: + records.append( + DNSRRSetDTO( + name=f"{record['name']}{dns_server_settings.domain}.", + type=DNSRecordType(record["type"]), + records=[ + DNSRecordDTO( + content=f"{record['value']}{dns_server_settings.domain}.", + disabled=False, + modified_at=None, + ), + ], + changetype=PowerDNSRecordChangeType.EXTEND, + ttl=3600, + ), + ) + + try: + await self.create_zone( + DNSMasterZoneDTO( + id=dns_server_settings.domain, + name=dns_server_settings.domain, + dnssec=False, + rrsets=records, + ), + ) + except DNSZoneCreateError as e: + raise DNSSetupError(f"Failed to set up DNS: {e}") + + async def create_record(self, zone_id: str, record: DNSRRSetDTO) -> None: + """Create a DNS record in the specified zone.""" + record.name = self._normalize_dns_name(record.name) + + record.changetype = PowerDNSRecordChangeType.REPLACE + + response = await self._client_authoritative.patch( + f"/zones/{zone_id}", + json={"rrsets": [base_retort.dump(record)]}, + ) + + try: + await self._validate_response(response) + except DNSError as e: + raise DNSRecordCreateError(f"Failed to create DNS record: {e}") + + async def get_records(self, zone_id: str) -> list[DNSRRSetDTO]: + """Retrieve all DNS records for the specified zone.""" + response = await self._client_authoritative.get(f"/zones/{zone_id}") + + try: + await self._validate_response(response) + except DNSError as e: + raise DNSRecordGetError(f"Failed to get DNS records: {e}") + + zone = base_retort.load(response.json(), DNSMasterZoneDTO) + + return zone.rrsets + + async def update_record(self, zone_id: str, record: DNSRRSetDTO) -> None: + """Update a DNS record in the specified zone.""" + record.name = self._normalize_dns_name(record.name) + + record.changetype = PowerDNSRecordChangeType.REPLACE + + response = await self._client_authoritative.patch( + f"/zones/{zone_id}", + json={"rrsets": [base_retort.dump(record)]}, + ) + + try: + await self._validate_response(response) + except DNSError as e: + raise DNSRecordUpdateError(f"Failed to update DNS record: {e}") + + async def delete_record(self, zone_id: str, record: DNSRRSetDTO) -> None: + """Delete a DNS record from the specified zone.""" + record.name = self._normalize_dns_name(record.name) + + record.changetype = PowerDNSRecordChangeType.DELETE + + response = await self._client_authoritative.patch( + f"/zones/{zone_id}", + json={"rrsets": [base_retort.dump(record)]}, + ) + + try: + await self._validate_response(response) + except DNSError as e: + raise DNSRecordDeleteError(f"Failed to delete DNS record: {e}") + + async def create_zone(self, zone: DNSZoneBaseDTO) -> None: + """Create a DNS zone.""" + zone.name = self._normalize_dns_name(zone.name) + + client = self._get_client_by_zone_kind(zone) + + if isinstance(zone, DNSMasterZoneDTO): + zone.nameservers.append(f"ns1.{zone.name}") + + records = await create_initial_zone_records( + zone.name, + str(self._dns_settings.dns_server_ip), + ) + zone.rrsets.extend(records) + + response = await client.post("/zones", json=base_retort.dump(zone)) + + try: + await self._validate_response(response) + except DNSError as e: + raise DNSZoneCreateError(f"Failed to create DNS zone: {e}") + + async def get_zones(self) -> list[DNSMasterZoneDTO]: + """Retrieve all DNS zones.""" + response = await self._client_authoritative.get("/zones") + try: + await self._validate_response(response) + except DNSError as e: + raise DNSZoneGetError(f"Failed to get DNS zones: {e}") + + zones = base_retort.load(response.json(), list[DNSMasterZoneDTO]) + for zone in zones: + zone.rrsets = await self.get_records(zone.id) + + return zones + + async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: + """Retrieve all forward DNS zones.""" + response = await self._client_recursor.get("/zones") + await self._validate_response(response) + + zones = base_retort.load(response.json(), list[DNSForwardZoneDTO]) + + filtered_zones = [] + for zone in zones: + if zone.kind == "Native": + continue + filtered_zones.append(zone) + + return filtered_zones + + async def update_zone(self, zone: DNSZoneBaseDTO) -> None: + """Update a DNS zone.""" + zone.name = self._normalize_dns_name(zone.name) + + client = self._get_client_by_zone_kind(zone) + + response = await client.put( + f"/zones/{zone.id}", + json=base_retort.dump(zone), + ) + + try: + await self._validate_response(response) + except DNSError as e: + raise DNSZoneUpdateError(f"Failed to update DNS zone: {e}") + + async def delete_zone(self, zone_id: str) -> None: + """Delete a DNS zone.""" + response = await self._client_authoritative.delete(f"/zones/{zone_id}") + + try: + await self._validate_response(response) + except DNSError as e: + raise DNSZoneDeleteError(f"Failed to delete DNS zone: {e}") + + async def delete_forward_zone(self, zone_id: str) -> None: + """Delete a DNS forward zone.""" + response = await self._client_recursor.delete(f"/zones/{zone_id}") + + try: + await self._validate_response(response) + except DNSError as e: + raise DNSZoneDeleteError(f"Failed to delete DNS zone: {e}") + + async def find_forward_dns_fqdn( + self, + dns_server_ip: IPv4Address | IPv6Address, + host_dns_servers: list[str], + ) -> str | None: + """Find forward DNS FQDN.""" + reversed_ip = ( + ".".join(reversed((str(dns_server_ip)).split("."))) + + ".in-addr.arpa" + ) + + async def get_fqdn_and_latency( + server: str, + ) -> tuple[float, str | None]: + resolver = dns.asyncresolver.Resolver() + resolver.nameservers = [server] + resolver.timeout = 10 + + try: + event_loop = asyncio.get_running_loop() + start_time = event_loop.time() + fqdn = await resolver.resolve(reversed_ip, DNSRecordType.PTR) + latency = event_loop.time() - start_time + + return (latency, fqdn[0].to_text()) + except ( + dns.asyncresolver.NoAnswer, + dns.asyncresolver.NXDOMAIN, + ): + return (float("inf"), None) + + fqdn_list = await asyncio.gather( + *(get_fqdn_and_latency(server) for server in host_dns_servers), + ) + fqdn_list.sort(key=lambda x: x[0]) + return fqdn_list[0][1] if fqdn_list else None + + async def check_forward_dns_server( + self, + dns_server_ip: IPv4Address | IPv6Address, + host_dns_servers: list[str], + ) -> DNSForwardServerStatus: + str_dns_server_ip = str(dns_server_ip) + + try: + fqdn = await self.find_forward_dns_fqdn( + dns_server_ip, + host_dns_servers, + ) + except (dns.asyncresolver.NoAnswer, dns.asyncresolver.NXDOMAIN): + return DNSForwardServerStatus( + str_dns_server_ip, + DNSForwarderServerStatus.NOT_VALIDATED, + None, + ) + + if not fqdn: + return DNSForwardServerStatus( + str_dns_server_ip, + DNSForwarderServerStatus.NOT_FOUND, + None, + ) + + return DNSForwardServerStatus( + str_dns_server_ip, + DNSForwarderServerStatus.VALIDATED, + fqdn, + ) diff --git a/app/ldap_protocol/dns/remote.py b/app/ldap_protocol/dns/remote_dns_manager.py similarity index 60% rename from app/ldap_protocol/dns/remote.py rename to app/ldap_protocol/dns/remote_dns_manager.py index 1c2cb25fd..2195909e5 100644 --- a/app/ldap_protocol/dns/remote.py +++ b/app/ldap_protocol/dns/remote_dns_manager.py @@ -4,8 +4,6 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from collections import defaultdict - from dns.asyncquery import inbound_xfr as make_inbound_xfr, tcp as asynctcp from dns.message import Message, make_query as make_dns_query from dns.name import from_text @@ -15,7 +13,8 @@ from dns.update import Update from dns.zone import Zone -from .base import AbstractDNSManager, DNSRecord, DNSRecords +from .base import AbstractDNSManager +from .dto import DNSRecordDTO, DNSRRSetDTO from .exceptions import DNSConnectionError from .utils import logger_wraps @@ -39,20 +38,22 @@ async def _send(self, action: Message) -> None: @logger_wraps() async def create_record( self, - hostname: str, - ip: str, - record_type: str, - ttl: int | None, - zone_name: str | None = None, + zone_id: str, + record: DNSRRSetDTO, ) -> None: """Create DNS record.""" - action = Update(self._dns_settings.zone_name or zone_name) - action.add(hostname, ttl, record_type, ip) + action = Update(self._dns_settings.zone_name or zone_id) + action.add( + record.name, + record.ttl, + record.type, + record.records[0].content, + ) await self._send(action) @logger_wraps() - async def get_all_records(self) -> list[DNSRecords]: + async def get_all_records(self) -> list[DNSRRSetDTO]: """Get all DNS records.""" if ( self._dns_settings.dns_server_ip is None @@ -75,51 +76,48 @@ async def get_all_records(self) -> list[DNSRecords]: zone_tm, ) - result: defaultdict[str, list] = defaultdict(list) - for name, ttl, rdata in zone_tm.iterate_rdatas(): - record_type = rdata.rdtype.name - - if record_type == "SOA": - continue - - result[record_type].append( - DNSRecord( - name=(name.to_text() + f".{self._dns_settings.zone_name}"), - value=rdata.to_text(), - ttl=ttl, - ), - ) - return [ - DNSRecords(type=record_type, records=records) - for record_type, records in result.items() + DNSRRSetDTO( + name=name.to_text() + f".{self._dns_settings.zone_name}.", + type=rdata.rdtype.name, + records=[ + DNSRecordDTO( + content=rdata.to_text(), + disabled=False, + ), + ], + ttl=ttl, + ) + for name, ttl, rdata in zone_tm.iterate_rdatas() ] @logger_wraps() async def update_record( self, - hostname: str, - ip: str | None, - record_type: str, - ttl: int | None, - zone_name: str | None = None, + zone_id: str, + record: DNSRRSetDTO, ) -> None: """Update DNS record.""" - action = Update(self._dns_settings.zone_name or zone_name) - action.replace(hostname, ttl, record_type, ip) - + action = Update(self._dns_settings.zone_name or zone_id) + action.replace( + record.name, + record.ttl, + record.type, + record.records[0].content, + ) await self._send(action) @logger_wraps() async def delete_record( self, - hostname: str, - ip: str, - record_type: str, - zone_name: str | None = None, + zone_id: str, + record: DNSRRSetDTO, ) -> None: """Delete DNS record.""" - action = Update(self._dns_settings.zone_name or zone_name) - action.delete(hostname, record_type, ip) - + action = Update(self._dns_settings.zone_name or zone_id) + action.delete( + record.name, + record.type, + record.records[0].content, + ) await self._send(action) diff --git a/app/ldap_protocol/dns/selfhosted.py b/app/ldap_protocol/dns/selfhosted.py deleted file mode 100644 index 4870e2e70..000000000 --- a/app/ldap_protocol/dns/selfhosted.py +++ /dev/null @@ -1,286 +0,0 @@ -"""DNS service for SelfHosted DNS server managing. - -Copyright (c) 2024 MultiFactor -License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE -""" - -import asyncio -from dataclasses import asdict -from ipaddress import IPv4Address, IPv6Address - -import dns.asyncresolver - -import ldap_protocol.dns.exceptions as dns_exc - -from .base import ( - AbstractDNSManager, - DNSForwarderServerStatus, - DNSForwardServerStatus, - DNSForwardZone, - DNSRecords, - DNSRecordType, - DNSServerParam, - DNSZone, - DNSZoneParam, - DNSZoneType, -) -from .utils import logger_wraps - - -class SelfHostedDNSManager(AbstractDNSManager): - """Manager for selfhosted Bind9 DNS server.""" - - @logger_wraps() - async def create_record( - self, - hostname: str, - ip: str, - record_type: DNSRecordType, - ttl: int, - zone_name: str | None = None, - ) -> None: - """Create DNS record.""" - response = await self._http_client.post( - "/record", - json={ - "zone_name": zone_name, - "record_name": hostname, - "record_type": record_type, - "record_value": ip, - "ttl": ttl, - }, - ) - - if response.status_code != 200: - raise dns_exc.DNSRecordCreateError(response.text) - - @logger_wraps() - async def update_record( - self, - hostname: str, - ip: str | None, - record_type: str, - ttl: int | None, - zone_name: str | None = None, - ) -> None: - response = await self._http_client.patch( - "/record", - json={ - "zone_name": zone_name, - "record_name": hostname, - "record_type": record_type, - "record_value": ip, - "ttl": ttl, - }, - ) - - if response.status_code != 200: - raise dns_exc.DNSRecordUpdateError(response.text) - - @logger_wraps() - async def delete_record( - self, - hostname: str, - ip: str, - record_type: str, - zone_name: str | None = None, - ) -> None: - response = await self._http_client.request( - "delete", - "/record", - json={ - "zone_name": zone_name, - "record_name": hostname, - "record_type": record_type, - "record_value": ip, - }, - ) - - if response.status_code != 200: - raise dns_exc.DNSRecordDeleteError(response.text) - - @logger_wraps() - async def get_all_records(self) -> list[DNSRecords]: - response = await self._http_client.get("/zone") - - response_data = response.json() - - if ( - isinstance(response_data, list) - and len(response_data) > 0 - and "records" in response_data[0] - ): - return response_data[0]["records"] - else: - return [] - - @logger_wraps() - async def get_all_zones_records(self) -> list[DNSZone]: - response = await self._http_client.get("/zone") - - return response.json() - - @logger_wraps() - async def get_forward_zones(self) -> list[DNSForwardZone]: - response = await self._http_client.get("/zone/forward") - - return response.json() - - @logger_wraps() - async def create_zone( - self, - zone_name: str, - zone_type: DNSZoneType, - nameserver: str | None, - params: list[DNSZoneParam], - ) -> None: - response = await self._http_client.post( - "/zone", - json={ - "zone_name": zone_name, - "zone_type": zone_type, - "nameserver": nameserver, - "params": [asdict(param) for param in params], - }, - ) - - if response.status_code != 200: - raise dns_exc.DNSZoneCreateError(response.text) - - @logger_wraps() - async def update_zone( - self, - zone_name: str, - params: list[DNSZoneParam], - ) -> None: - response = await self._http_client.patch( - "/zone", - json={ - "zone_name": zone_name, - "params": [asdict(param) for param in params], - }, - ) - - if response.status_code != 200: - raise dns_exc.DNSZoneUpdateError(response.text) - - @logger_wraps() - async def delete_zone( - self, - zone_names: list[str], - ) -> None: - for zone_name in zone_names: - response = await self._http_client.request( - "delete", - "/zone", - json={"zone_name": zone_name}, - ) - - if response.status_code != 200: - raise dns_exc.DNSZoneDeleteError(response.text) - - @logger_wraps() - async def find_forward_dns_fqdn( - self, - dns_server_ip: IPv4Address | IPv6Address, - host_dns_servers: list[str], - ) -> str | None: - """Find forward DNS FQDN.""" - reversed_ip = ( - ".".join(reversed((str(dns_server_ip)).split("."))) - + ".in-addr.arpa" - ) - - async def get_fqdn_and_latency( - server: str, - ) -> tuple[float, str | None]: - resolver = dns.asyncresolver.Resolver() - resolver.nameservers = [server] - resolver.timeout = 10 - - try: - event_loop = asyncio.get_running_loop() - start_time = event_loop.time() - fqdn = await resolver.resolve( - reversed_ip, - "PTR", - ) - latency = event_loop.time() - start_time - - return (latency, fqdn[0].to_text()) - except ( - dns.asyncresolver.NoAnswer, - dns.asyncresolver.NXDOMAIN, - ): - return (float("inf"), None) - - fqdn_list = await asyncio.gather( - *(get_fqdn_and_latency(server) for server in host_dns_servers), - ) - fqdn_list.sort(key=lambda x: x[0]) - return fqdn_list[0][1] if fqdn_list else None - - @logger_wraps() - async def check_forward_dns_server( - self, - dns_server_ip: IPv4Address | IPv6Address, - host_dns_servers: list[str], - ) -> DNSForwardServerStatus: - str_dns_server_ip = str(dns_server_ip) - - try: - fqdn = await self.find_forward_dns_fqdn( - str_dns_server_ip, - host_dns_servers, - ) - except (dns.asyncresolver.NoAnswer, dns.asyncresolver.NXDOMAIN): - return DNSForwardServerStatus( - str_dns_server_ip, - DNSForwarderServerStatus.NOT_VALIDATED, - None, - ) - - if not fqdn: - return DNSForwardServerStatus( - str_dns_server_ip, - DNSForwarderServerStatus.NOT_FOUND, - None, - ) - - return DNSForwardServerStatus( - str_dns_server_ip, - DNSForwarderServerStatus.VALIDATED, - fqdn, - ) - - @logger_wraps() - async def update_server_options( - self, - params: list[DNSServerParam], - ) -> None: - response = await self._http_client.patch( - "/server/settings", - json=[asdict(param) for param in params], - ) - - if response.status_code != 200: - raise dns_exc.DNSUpdateServerOptionsError(response.text) - - @logger_wraps() - async def get_server_options(self) -> list[DNSServerParam]: - response = await self._http_client.get("/server/settings") - - return response.json() - - @logger_wraps() - async def restart_server( - self, - ) -> None: - await self._http_client.get("/server/restart") - - @logger_wraps() - async def reload_zone( - self, - zone_name: str, - ) -> None: - await self._http_client.get(f"/zone/{zone_name}") diff --git a/app/ldap_protocol/dns/stub.py b/app/ldap_protocol/dns/stub.py index 836a98a62..27ae8dcd4 100644 --- a/app/ldap_protocol/dns/stub.py +++ b/app/ldap_protocol/dns/stub.py @@ -4,13 +4,12 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from .base import ( - AbstractDNSManager, - DNSForwardZone, - DNSRecords, - DNSServerParam, - DNSZoneParam, - DNSZoneType, +from .base import AbstractDNSManager +from .dto import ( + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRRSetDTO, + DNSZoneBaseDTO, ) from .utils import logger_wraps @@ -21,59 +20,55 @@ class StubDNSManager(AbstractDNSManager): @logger_wraps(is_stub=True) async def create_record( self, - hostname: str, - ip: str, - record_type: str, - ttl: int | None, - zone_name: str | None = None, + zone_id: str, + record: DNSRRSetDTO, ) -> None: ... @logger_wraps(is_stub=True) async def update_record( self, - hostname: str, - ip: str, - record_type: str, - ttl: int, - zone_name: str | None = None, + zone_id: str, + record: DNSRRSetDTO, ) -> None: ... @logger_wraps(is_stub=True) async def delete_record( self, - hostname: str, - ip: str, - record_type: str, - zone_name: str | None = None, + zone_id: str, + record: DNSRRSetDTO, ) -> None: ... @logger_wraps(is_stub=True) - async def get_all_zones_records(self) -> None: ... + async def get_records( + self, + zone_id: str, # noqa: ARG002 + ) -> list[DNSRRSetDTO]: + return [] + + @logger_wraps(is_stub=True) + async def get_zones(self) -> list[DNSMasterZoneDTO]: + return [] @logger_wraps(is_stub=True) - async def get_forward_zones(self) -> list[DNSForwardZone]: + async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: return [] @logger_wraps(is_stub=True) async def create_zone( self, - zone_name: str, - zone_type: DNSZoneType, - nameserver: str | None, - params: list[DNSZoneParam], + zone: DNSZoneBaseDTO, ) -> None: ... @logger_wraps(is_stub=True) async def update_zone( self, - zone_name: str, - params: list[DNSZoneParam] | None, + zone: DNSZoneBaseDTO, ) -> None: ... @logger_wraps(is_stub=True) async def delete_zone( self, - zone_names: list[str], + zone_id: str, ) -> None: ... @logger_wraps(is_stub=True) @@ -83,27 +78,7 @@ async def check_forward_dns_server( ) -> None: ... @logger_wraps(is_stub=True) - async def update_server_options( + async def delete_forward_zone( self, - params: list[DNSServerParam], + zone_id: str, ) -> None: ... - - @logger_wraps(is_stub=True) - async def get_server_options(self) -> list[DNSServerParam]: - return [] - - @logger_wraps(is_stub=True) - async def restart_server( - self, - ) -> None: ... - - @logger_wraps(is_stub=True) - async def reload_zone( - self, - zone_name: str, - ) -> None: ... - - @logger_wraps(is_stub=True) - async def get_all_records(self) -> list[DNSRecords]: - """Stub DNS manager get all records.""" - return [] diff --git a/app/ldap_protocol/dns/use_cases.py b/app/ldap_protocol/dns/use_cases.py index 0b5f32291..9215d97c5 100644 --- a/app/ldap_protocol/dns/use_cases.py +++ b/app/ldap_protocol/dns/use_cases.py @@ -13,15 +13,18 @@ from ldap_protocol.dns.base import ( AbstractDNSManager, DNSForwardServerStatus, - DNSForwardZone, DNSManagerSettings, - DNSRecords, - DNSServerParam, - DNSZone, - DNSZoneParam, - DNSZoneType, ) from ldap_protocol.dns.dns_gateway import DNSStateGateway +from ldap_protocol.dns.dto import ( + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRRSetDTO, + DNSSettingsDTO, + DNSZoneBaseDTO, +) + +from .enums import DNSManagerState class DNSUseCase(AbstractService): @@ -42,112 +45,62 @@ def __init__( async def setup_dns( self, - dns_status: str, - domain: str, - dns_ip_address: str | IPv4Address | IPv6Address | None, - tsig_key: str | None, + dns_server_settings: DNSSettingsDTO, ) -> None: """Set up DNS server and DNS manager.""" - setup_data = await self._dns_manager.setup( - dns_status, - domain, - dns_ip_address or self._settings.DNS_BIND_HOST, - tsig_key, + await self._dns_manager.setup( + dns_server_settings, ) if self._dns_settings.domain is not None: - await self._dns_gateway.update_settings(setup_data) + await self._dns_gateway.update_settings(dns_server_settings) else: - await self._dns_gateway.create_settings(setup_data) - - await self._dns_gateway.setup_dns_state(dns_status) + await self._dns_gateway.create_settings(dns_server_settings) async def create_record( self, - hostname: str, - ip: str, - record_type: str, - ttl: int | None, - zone_name: str | None = None, + zone_id: str, + record: DNSRRSetDTO, ) -> None: """Create DNS record.""" - await self._dns_manager.create_record( - hostname, - ip, - record_type, - ttl, - zone_name, - ) + await self._dns_manager.create_record(zone_id, record) - async def delete_record( - self, - hostname: str, - ip: str, - record_type: str, - zone_name: str | None = None, - ) -> None: - """Delete DNS record.""" - await self._dns_manager.delete_record( - hostname, - ip, - record_type, - zone_name, - ) + async def get_records(self, zone_id: str) -> list[DNSRRSetDTO]: + """Get all DNS records.""" + return await self._dns_manager.get_records(zone_id) - async def update_record( - self, - hostname: str, - ip: str | None, - record_type: str, - ttl: int | None, - zone_name: str | None = None, - ) -> None: + async def update_record(self, zone_id: str, record: DNSRRSetDTO) -> None: """Update DNS record.""" - await self._dns_manager.update_record( - hostname, - ip, - record_type, - ttl, - zone_name, - ) + await self._dns_manager.update_record(zone_id, record) - async def get_all_records(self) -> list[DNSRecords]: - """Get all DNS records.""" - return await self._dns_manager.get_all_records() + async def delete_record(self, zone_id: str, record: DNSRRSetDTO) -> None: + """Delete DNS record.""" + await self._dns_manager.delete_record(zone_id, record) + + async def create_zone(self, zone: DNSZoneBaseDTO) -> None: + """Create DNS zone.""" + await self._dns_manager.create_zone(zone) - async def get_all_zones_records(self) -> list[DNSZone]: + async def get_zones(self) -> list[DNSMasterZoneDTO]: """Get all DNS zones.""" - return await self._dns_manager.get_all_zones_records() + return await self._dns_manager.get_zones() - async def get_forward_zones(self) -> list[DNSForwardZone]: + async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: """Get all forward zones.""" return await self._dns_manager.get_forward_zones() - async def create_zone( - self, - zone_name: str, - zone_type: DNSZoneType, - nameserver: str | None, - params: list[DNSZoneParam], - ) -> None: - """Create DNS zone.""" - await self._dns_manager.create_zone( - zone_name, - zone_type, - nameserver, - params, - ) - - async def update_zone( - self, - zone_name: str, - params: list[DNSZoneParam] | None, - ) -> None: + async def update_zone(self, zone: DNSZoneBaseDTO) -> None: """Update DNS zone.""" - await self._dns_manager.update_zone(zone_name, params) + await self._dns_manager.update_zone(zone) + + async def delete_zones(self, zone_ids: list[str]) -> None: + """Delete DNS zones.""" + for zone_id in zone_ids: + await self._dns_manager.delete_zone(zone_id) - async def delete_zone(self, zone_names: list[str]) -> None: - """Delete DNS zone.""" - await self._dns_manager.delete_zone(zone_names) + async def delete_forward_zones(self, zone_ids: list[str]) -> None: + """Delete DNS forward zones.""" + for zone_id in zone_ids: + await self._dns_manager.delete_forward_zone(zone_id) async def check_forward_dns_server( self, @@ -160,33 +113,18 @@ async def check_forward_dns_server( host_dns_servers, ) - async def update_server_options( - self, - params: list[DNSServerParam], - ) -> None: - """Update DNS server options.""" - await self._dns_manager.update_server_options(params) - - async def restart_server(self) -> None: - """Restart DNS server.""" - await self._dns_manager.restart_server() - - async def reload_zone(self, zone_name: str) -> None: - """Reload DNS zone.""" - await self._dns_manager.reload_zone(zone_name) - - async def get_server_options(self) -> list[DNSServerParam]: - """Get DNS server options.""" - return await self._dns_manager.get_server_options() - async def get_dns_status(self) -> dict[str, str | None]: """Get DNS status.""" return { "dns_status": await self._dns_gateway.get_dns_state(), - "zone_name": self._dns_settings.zone_name, - "dns_server_ip": self._dns_settings.dns_server_ip, + "zone_name": self._dns_settings.domain, + "dns_server_ip": str(self._dns_settings.dns_server_ip), } + async def set_state(self, state: DNSManagerState) -> None: + """Set DNS manager state.""" + await self._dns_gateway.set_state(state) + async def check_dns_forward_zone( self, data: list[IPv4Address | IPv6Address], @@ -205,16 +143,12 @@ async def check_dns_forward_zone( create_record.__name__: AuthorizationRules.DNS_CREATE_RECORD, delete_record.__name__: AuthorizationRules.DNS_DELETE_RECORD, update_record.__name__: AuthorizationRules.DNS_UPDATE_RECORD, - get_all_records.__name__: AuthorizationRules.DNS_GET_ALL_RECORDS, + get_records.__name__: AuthorizationRules.DNS_GET_ALL_RECORDS, get_dns_status.__name__: AuthorizationRules.DNS_GET_DNS_STATUS, - get_all_zones_records.__name__: AuthorizationRules.DNS_GET_ALL_ZONES_RECORDS, # noqa: E501 + delete_forward_zones.__name__: AuthorizationRules.DNS_DELETE_FWD_ZONES, get_forward_zones.__name__: AuthorizationRules.DNS_GET_FORWARD_ZONES, create_zone.__name__: AuthorizationRules.DNS_CREATE_ZONE, update_zone.__name__: AuthorizationRules.DNS_UPDATE_ZONE, - delete_zone.__name__: AuthorizationRules.DNS_DELETE_ZONE, + delete_zones.__name__: AuthorizationRules.DNS_DELETE_ZONE, check_dns_forward_zone.__name__: AuthorizationRules.DNS_CHECK_DNS_FORWARD_ZONE, # noqa: E501 - reload_zone.__name__: AuthorizationRules.DNS_RELOAD_ZONE, - update_server_options.__name__: AuthorizationRules.DNS_UPDATE_SERVER_OPTIONS, # noqa: E501 - get_server_options.__name__: AuthorizationRules.DNS_GET_SERVER_OPTIONS, - restart_server.__name__: AuthorizationRules.DNS_RESTART_SERVER, } diff --git a/app/ldap_protocol/dns/utils.py b/app/ldap_protocol/dns/utils.py index 9adc21fe9..b20985e86 100644 --- a/app/ldap_protocol/dns/utils.py +++ b/app/ldap_protocol/dns/utils.py @@ -10,6 +10,8 @@ from dns.asyncresolver import Resolver as AsyncResolver from .base import log +from .dto import DNSRecordDTO, DNSRRSetDTO +from .enums import DNSRecordType, PowerDNSRecordChangeType from .exceptions import DNSConnectionError @@ -48,3 +50,52 @@ async def resolve_dns_server_ip(host: str) -> str: if dns_server_ip_resolve is None or dns_server_ip_resolve.rrset is None: raise DNSConnectionError return dns_server_ip_resolve.rrset[0].address + + +async def create_initial_zone_records( + domain: str, + nameserver: str, +) -> list[DNSRRSetDTO]: + """Get initial records for new zone.""" + return [ + DNSRRSetDTO( + name=f"{domain}", + type=DNSRecordType.A, + records=[ + DNSRecordDTO( + content=nameserver, + disabled=False, + modified_at=None, + ), + ], + changetype=PowerDNSRecordChangeType.EXTEND, + ttl=3600, + ), + DNSRRSetDTO( + name=f"ns1.{domain}", + type=DNSRecordType.A, + records=[ + DNSRecordDTO( + content=nameserver, + disabled=False, + modified_at=None, + ), + ], + changetype=PowerDNSRecordChangeType.EXTEND, + ttl=3600, + ), + DNSRRSetDTO( + name=f"{domain}", + type=DNSRecordType.SOA, + records=[ + DNSRecordDTO( + content=f"ns1.{domain} hostmaster.{domain}" + + " 1 10800 3600 604800 3600", + disabled=False, + modified_at=None, + ), + ], + changetype=PowerDNSRecordChangeType.EXTEND, + ttl=3600, + ), + ] diff --git a/docker-compose.remote.test.yml b/docker-compose.remote.test.yml index 96b9c8ce1..70664921a 100644 --- a/docker-compose.remote.test.yml +++ b/docker-compose.remote.test.yml @@ -5,6 +5,8 @@ services: environment: DEBUG: 1 DOMAIN: md.test + DEFAULT_NAMESERVER: 192.168.1.1 + PDNS_API_KEY: testkey123 POSTGRES_USER: user1 POSTGRES_PASSWORD: password123 SECRET_KEY: 6a0452ae20cab4e21b6e9d18fa4b7bf397dd66ec3968b2d7407694278fd84cce diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 221a4e4bf..cb582d35e 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -16,6 +16,8 @@ services: DOMAIN: md.test POSTGRES_USER: user1 POSTGRES_PASSWORD: password123 + DEFAULT_NAMESERVER: 192.168.1.1 + PDNS_API_KEY: testkey123 SECRET_KEY: 6a0452ae20cab4e21b6e9d18fa4b7bf397dd66ec3968b2d7407694278fd84cce POSTGRES_HOST: postgres # PYTHONTRACEMALLOC: 1 diff --git a/docker-compose.yml b/docker-compose.yml index 891095345..d3c4d8c5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -234,31 +234,6 @@ services: working_dir: /server command: ./entrypoint.sh - bind_dns: - build: - context: . - dockerfile: ./.docker/bind9.Dockerfile - image: bind9md - container_name: bind9 - hostname: bind9 - restart: unless-stopped - environment: - - DEFAULT_NAMESERVER=127.0.0.2 - - USE_CONFIG_FILE_LOGGING=true - volumes: - - dns_server_file:/opt/ - - dns_server_config:/etc/bind/ - - .dns/:/server/ - tty: true - depends_on: - ldap_server: - condition: service_healthy - restart: true - labels: - - traefik.enable=true - - traefik.udp.routers.bind_dns_udp.entrypoints=bind_dns_udp - - traefik.udp.services.bind_dns_udp.loadbalancer.server.port=53 - kea_dhcp4: image: kea_image:0.1 network_mode: host @@ -474,6 +449,39 @@ services: privileged: true restart: always + pdns_auth: + build: + context: . + dockerfile: ./.docker/pdns_auth.Dockerfile + args: + DOCKER_BUILDKIT: 1 + image: pdns_auth_md + container_name: pdns_auth + expose: + - 8082 + - 53 + volumes: + - dns_lmdb:/var/lib/pdns-lmdb + - dns_config:/etc/powerdns + + + pdns_recursor: + image: powerdns/pdns-recursor-51:5.1.7 + container_name: pdns_recursor + expose: + - 8083 + ports: + - "53:53/udp" + - "53:53/tcp" + volumes: + - ./recursor.conf:/etc/powerdns/recursor.conf + - forward_zones:/etc/powerdns/recursor.d/ + labels: + - traefik.enable=true + - traefik.udp.routers.power_dns_recursor_udp.entrypoints=power_dns_recursor_udp + - traefik.udp.services.power_dns_recursor_udp.loadbalancer.server.port=53 + + volumes: postgres: pgadmin: @@ -486,3 +494,6 @@ volumes: leases: sockets: dhcp: + dns_lmdb: + dns_config: + forward_zones: diff --git a/local.env b/local.env index 8eb377378..e56d95cb8 100644 --- a/local.env +++ b/local.env @@ -6,3 +6,5 @@ POSTGRES_PASSWORD=password123 SECRET_KEY=6a0452ae20cab4e21b6e9d18fa4b7bf397dd66ec3968b2d7407694278fd84cce MFA_API_SOURCE=dev ACCESS_TOKEN_EXPIRE_MINUTES=180 +DEFAULT_NAMESERVER=127.0.0.1 +PDNS_API_KEY=supersecretapikey diff --git a/pdns.conf b/pdns.conf new file mode 100644 index 000000000..80635dc2b --- /dev/null +++ b/pdns.conf @@ -0,0 +1,11 @@ +launch=lmdb +lmdb-filename=/var/lib/pdns-lmdb/pdns.lmdb +daemon=no +local-address=0.0.0.0 +local-port=53 +api=yes +api-key=supersecretapikey +webserver-allow-from=0.0.0.0/0 +webserver=yes +webserver-address=0.0.0.0 +webserver-port=8082 diff --git a/recursor.conf b/recursor.conf new file mode 100644 index 000000000..682702675 --- /dev/null +++ b/recursor.conf @@ -0,0 +1,10 @@ +local-address=0.0.0.0 +webserver-allow-from=0.0.0.0/0 +forward-zones-recurse= +forward-zones= +api-config-dir=/etc/powerdns/recursor.d/ +include-dir=/etc/powerdns/recursor.d/ +webserver=yes +webserver-address=0.0.0.0 +webserver-port=8083 +api-key=supersecretapikey diff --git a/tests/conftest.py b/tests/conftest.py index b97a8ce4a..82c1e5c6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ import weakref from contextlib import suppress from dataclasses import dataclass +from ipaddress import IPv4Address from typing import AsyncGenerator, AsyncIterator, Generator, Iterator from unittest.mock import AsyncMock, Mock @@ -77,7 +78,7 @@ StubDNSManager, ) from ldap_protocol.dns.dns_gateway import DNSStateGateway -from ldap_protocol.dns.dto import DNSSettingDTO +from ldap_protocol.dns.dto import DNSSettingsDTO from ldap_protocol.dns.use_cases import DNSUseCase from ldap_protocol.identity import IdentityProvider from ldap_protocol.identity.provider_gateway import IdentityProviderGateway @@ -213,55 +214,58 @@ async def get_dns_mngr(self) -> AsyncIterator[AsyncMock]: """Get mock DNS manager.""" dns_manager = AsyncMock(spec=StubDNSManager) - dns_manager.setup.return_value = DNSSettingDTO( - zone_name="example.com", - dns_server_ip="127.0.0.1", + dns_manager.setup.return_value = DNSSettingsDTO( + domain="example.com", + dns_server_ip=IPv4Address("127.0.0.1"), tsig_key=None, ) - dns_manager.get_all_records.return_value = [ + dns_manager.get_records.return_value = [ { + "name": "example.com", "type": "A", "records": [ { - "name": "example.com", - "value": "127.0.0.1", - "ttl": 3600, + "content": "127.0.0.1", + "disabled": False, + "modified_at": None, }, ], - }, - ] - dns_manager.get_server_options.return_value = [ - { - "name": "dnssec-validation", - "value": "no", + "ttl": 3600, }, ] dns_manager.get_forward_zones.return_value = [ { - "name": "test.local", - "type": "forward", - "forwarders": [ - "127.0.0.1", - "127.0.0.2", - ], + "id": "forward1", + "name": "forward1.", + "rrsets": [], + "kind": "Forwarded", + "type": "zone", + "servers": ["127.0.0.1"], + "recursion_desired": False, }, ] - dns_manager.get_all_zones_records.return_value = [ + dns_manager.get_zones.return_value = [ { - "name": "test.local", - "type": "master", - "records": [ + "id": "zone1", + "name": "example.com.", + "rrsets": [ { + "name": "example.com", "type": "A", "records": [ { - "name": "example.com", - "value": "127.0.0.1", - "ttl": 3600, + "content": "127.0.0.1", + "disabled": False, + "modified_at": None, }, ], + "ttl": 3600, }, ], + "dnssec": False, + "nameservers": ["ns1.example.com."], + "kind": "Master", + "type": "zone", }, ] diff --git a/tests/test_api/test_main/test_dns.py b/tests/test_api/test_main/test_dns.py index 6d521408c..5ed625039 100644 --- a/tests/test_api/test_main/test_dns.py +++ b/tests/test_api/test_main/test_dns.py @@ -1,19 +1,11 @@ """Test DNS service.""" -from dataclasses import asdict - import pytest from httpx import AsyncClient from starlette import status -from ldap_protocol.dns import ( - AbstractDNSManager, - DNSManagerState, - DNSServerParam, - DNSServerParamName, - DNSZoneParam, - DNSZoneParamName, -) +from ldap_protocol.dns import AbstractDNSManager +from ldap_protocol.dns.dto import DNSMasterZoneDTO, DNSRecordDTO, DNSRRSetDTO @pytest.mark.asyncio @@ -29,9 +21,8 @@ async def test_dns_create_record( record_type = "A" ttl = 3600 response = await http_client.post( - "/dns/record", + f"/dns/record/{zone_name}", json={ - "zone_name": zone_name, "record_name": hostname, "record_value": ip, "record_type": record_type, @@ -42,7 +33,20 @@ async def test_dns_create_record( dns_manager.create_record.assert_called() # type: ignore assert ( dns_manager.create_record.call_args.args # type: ignore - ) == (hostname, ip, record_type, int(ttl), zone_name) + ) == ( + zone_name, + DNSRRSetDTO( + name=hostname, + type=record_type, + records=[ + DNSRecordDTO( + content=ip, + disabled=False, + ), + ], + ttl=ttl, + ), + ) assert response.status_code == status.HTTP_200_OK @@ -60,9 +64,8 @@ async def test_dns_delete_record( record_type = "A" response = await http_client.request( "DELETE", - "/dns/record", + f"/dns/record/{zone_name}", json={ - "zone_name": zone_name, "record_name": hostname, "record_value": ip, "record_type": record_type, @@ -72,7 +75,19 @@ async def test_dns_delete_record( dns_manager.delete_record.assert_called() # type: ignore assert ( dns_manager.delete_record.call_args.args # type: ignore - ) == (hostname, ip, record_type, zone_name) + ) == ( + zone_name, + DNSRRSetDTO( + name=hostname, + type=record_type, + records=[ + DNSRecordDTO( + content=ip, + disabled=False, + ), + ], + ), + ) assert response.status_code == status.HTTP_200_OK @@ -91,9 +106,8 @@ async def test_dns_update_record( ttl = 3600 response = await http_client.request( "PATCH", - "/dns/record", + f"/dns/record/{zone_name}", json={ - "zone_name": zone_name, "record_name": hostname, "record_value": ip, "record_type": record_type, @@ -104,7 +118,20 @@ async def test_dns_update_record( dns_manager.update_record.assert_called() # type: ignore assert ( dns_manager.update_record.call_args.args # type: ignore - ) == (hostname, ip, record_type, int(ttl), zone_name) + ) == ( + zone_name, + DNSRRSetDTO( + name=hostname, + type=record_type, + records=[ + DNSRecordDTO( + content=ip, + disabled=False, + ), + ], + ttl=ttl, + ), + ) assert response.status_code == status.HTTP_200_OK @@ -113,21 +140,25 @@ async def test_dns_update_record( @pytest.mark.usefixtures("session") async def test_dns_get_all_records(http_client: AsyncClient) -> None: """DNS Manager get all records test.""" - response = await http_client.get("/dns/record") + zone_name = "hello.zone" + response = await http_client.get(f"/dns/record/{zone_name}") assert response.status_code == status.HTTP_200_OK data = response.json() assert data == [ { + "name": "example.com", "type": "A", + "changetype": None, "records": [ { - "name": "example.com", - "value": "127.0.0.1", - "ttl": 3600, + "content": "127.0.0.1", + "disabled": False, + "modified_at": None, }, ], + "ttl": 3600, }, ] @@ -139,14 +170,12 @@ async def test_dns_setup_selfhosted( dns_manager: AbstractDNSManager, ) -> None: """DNS Manager setup test.""" - dns_status = DNSManagerState.SELFHOSTED domain = "example.com" tsig_key = None dns_ip_address = "127.0.0.1" response = await http_client.post( "/dns/setup", json={ - "dns_status": dns_status, "domain": domain, "dns_ip_address": dns_ip_address, "tsig_key": tsig_key, @@ -168,7 +197,7 @@ async def test_dns_get_status(http_client: AsyncClient) -> None: assert response.status_code == status.HTTP_200_OK assert response.json() == { "dns_status": "2", - "zone_name": "example.com", + "zone_name": "example.com.", "dns_server_ip": "127.0.0.1", } @@ -182,20 +211,13 @@ async def test_dns_create_zone( ) -> None: """DNS Manager create zone test.""" zone_name = "hello" - zone_type = "master" - nameserver = None - params = [ - DNSZoneParam( - DNSZoneParamName.acl, - ["127.0.0.1"], - ), - ] + nameserver = "192.168.1.1" response = await http_client.post( "/dns/zone", json={ "zone_name": zone_name, - "zone_type": zone_type, - "params": [asdict(param) for param in params], + "nameserver_ip": nameserver, + "dnssec": False, }, ) @@ -203,7 +225,17 @@ async def test_dns_create_zone( dns_manager.create_zone.assert_called() # type: ignore assert ( dns_manager.create_zone.call_args.args # type: ignore - ) == (zone_name, zone_type, nameserver, params) + ) == ( + DNSMasterZoneDTO( + id=zone_name, + rrsets=[], + name=zone_name, + dnssec=False, + type="zone", + nameservers=[], + kind="Master", + ), + ) @pytest.mark.asyncio @@ -215,17 +247,13 @@ async def test_dns_update_zone( ) -> None: """DNS Manager update zone test.""" zone_name = "hello" - params = [ - DNSZoneParam( - DNSZoneParamName.acl, - ["127.0.0.1"], - ), - ] + nameserver = "192.168.1.1" response = await http_client.patch( "/dns/zone", json={ "zone_name": zone_name, - "params": [asdict(param) for param in params], + "nameserver_ip": nameserver, + "dnssec": False, }, ) @@ -233,7 +261,17 @@ async def test_dns_update_zone( dns_manager.update_zone.assert_called() # type: ignore assert ( dns_manager.update_zone.call_args.args # type: ignore - ) == (zone_name, params) + ) == ( + DNSMasterZoneDTO( + id=zone_name, + rrsets=[], + name=zone_name, + dnssec=False, + type="zone", + nameservers=[], + kind="Master", + ), + ) @pytest.mark.asyncio @@ -244,67 +282,19 @@ async def test_dns_delete_zone( dns_manager: AbstractDNSManager, ) -> None: """DNS Manager delete zone test.""" - zone_names = ["hello"] + zone_ids = ["hello"] response = await http_client.request( "DELETE", "/dns/zone", - json={"zone_names": zone_names}, + json={"zone_ids": zone_ids}, ) assert response.status_code == status.HTTP_200_OK dns_manager.delete_zone.assert_called() # type: ignore assert ( dns_manager.delete_zone.call_args.args # type: ignore - ) == (zone_names,) - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("add_dns_settings") -@pytest.mark.usefixtures("session") -async def test_dns_update_server_options( - http_client: AsyncClient, - dns_manager: AbstractDNSManager, -) -> None: - """DNS Manager update DNS server options test.""" - params = [ - DNSServerParam( - DNSServerParamName.dnssec, - ["127.0.0.1"], - ), - ] - response = await http_client.patch( - "/dns/server/options", - json=[asdict(param) for param in params], - ) - - assert response.status_code == status.HTTP_200_OK - dns_manager.update_server_options.assert_called() # type: ignore - assert ( - dns_manager.update_server_options.call_args.args # type: ignore - ) == (params,) - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("add_dns_settings") -@pytest.mark.usefixtures("session") -async def test_dns_get_server_options( - http_client: AsyncClient, - dns_manager: AbstractDNSManager, -) -> None: - """DNS Manager get DNS server options test.""" - response = await http_client.get("/dns/server/options") - - assert response.status_code == status.HTTP_200_OK - dns_manager.get_server_options.assert_called() # type: ignore - - data = response.json() - assert data == [ - { - "name": "dnssec-validation", - "value": "no", - }, - ] + ) == (zone_ids[0],) @pytest.mark.asyncio @@ -318,25 +308,32 @@ async def test_dns_get_all_zones_with_records( response = await http_client.get("/dns/zone") assert response.status_code == status.HTTP_200_OK - dns_manager.get_all_zones_records.assert_called() # type: ignore + dns_manager.get_zones.assert_called() # type: ignore data = response.json() assert data == [ { - "name": "test.local", - "type": "master", - "records": [ + "id": "zone1", + "name": "example.com.", + "rrsets": [ { + "name": "example.com", "type": "A", + "changetype": None, "records": [ { - "name": "example.com", - "value": "127.0.0.1", - "ttl": 3600, + "content": "127.0.0.1", + "disabled": False, + "modified_at": None, }, ], + "ttl": 3600, }, ], + "dnssec": False, + "nameservers": ["ns1.example.com."], + "kind": "Master", + "type": "zone", }, ] @@ -357,11 +354,12 @@ async def test_dns_get_all_forward_zones( data = response.json() assert data == [ { - "name": "test.local", - "type": "forward", - "forwarders": [ - "127.0.0.1", - "127.0.0.2", - ], + "id": "forward1", + "name": "forward1.", + "rrsets": [], + "kind": "Forwarded", + "type": "zone", + "servers": ["127.0.0.1"], + "recursion_desired": False, }, ] diff --git a/traefik.yml b/traefik.yml index 7b2384086..944a40b8d 100644 --- a/traefik.yml +++ b/traefik.yml @@ -26,7 +26,7 @@ entryPoints: address: ":749" kpasswd: address: ":464" - bind_dns_udp: + power_dns_recursor_udp: address: ":53/udp" tls: