From 83fbb03cade044b8b033009cd39ae9fe5cdb2933 Mon Sep 17 00:00:00 2001 From: Aaron Guise Date: Wed, 25 Feb 2026 14:37:14 +1300 Subject: [PATCH] =?UTF-8?q?fix:=20relativize=20zone-apex=20hostnames=20to?= =?UTF-8?q?=20'@'=20for=20CoreDNS=20MySQL=20=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoreDNS MySQL (cybercinch fork) expects '@' for zone-apex references in record RDATA. Storing the full FQDN (e.g. 'ithome.net.nz.') caused CoreDNS to strip the zone suffix and serve 'MX 0 .' / 'CNAME .' instead of the correct apex target. - Add _relativize_name(): converts zone FQDN → '@', in-zone subdomains → relative label, external FQDNs left unchanged. Handles both already- relativized output from dnspython ($ORIGIN present) and absolute FQDNs when $ORIGIN is absent from the zone file. - Replace _normalize_cname_data() with _relativize_name(); add _normalize_mx_data(), _normalize_ns_data(), _normalize_srv_data() using the same helper. - _parse_zone_to_record_set() now normalizes MX, NS, SRV alongside CNAME. - _ensure_zone_exists() sets managed_by='directadmin' on create and back-fills NULL rows from pre-migration installs. - Zone.managed_by changed to nullable=True to match ALTER TABLE migration where existing rows have no value. - schema/coredns_mysql.sql updated to reflect actual two-table schema with managed_by column and migration comment. - 11 new tests (130 total, all passing). --- directdnsonly/app/backends/coredns_mysql.py | 83 +++++++++++--- schema/coredns_mysql.sql | 43 +++++-- tests/test_coredns_mysql.py | 121 ++++++++++++++++++++ 3 files changed, 217 insertions(+), 30 deletions(-) diff --git a/directdnsonly/app/backends/coredns_mysql.py b/directdnsonly/app/backends/coredns_mysql.py index 951a4fa..c6b92b0 100644 --- a/directdnsonly/app/backends/coredns_mysql.py +++ b/directdnsonly/app/backends/coredns_mysql.py @@ -14,6 +14,7 @@ class Zone(Base): __tablename__ = "zones" id = Column(Integer, primary_key=True) zone_name = Column(String(255), nullable=False, index=True, unique=True) + managed_by = Column(String(255), nullable=True) # 'directadmin' | 'direct' | NULL (legacy) class Record(Base): @@ -240,37 +241,75 @@ class CoreDNSMySQLBackend(DNSBackend): session.close() def _ensure_zone_exists(self, session, zone_name: str) -> Zone: - """Ensure a zone exists in the database, creating it if necessary""" + """Ensure a zone exists in the database, creating it if necessary.""" zone = session.execute( select(Zone).filter_by(zone_name=self.dot_fqdn(zone_name)) ).scalar_one_or_none() if not zone: logger.debug(f"Creating new zone: {self.dot_fqdn(zone_name)}") - zone = Zone(zone_name=self.dot_fqdn(zone_name)) + zone = Zone( + zone_name=self.dot_fqdn(zone_name), + managed_by="directadmin", + ) session.add(zone) - session.flush() # Get the zone ID + session.flush() + elif not zone.managed_by: + # Migrate pre-existing rows that were created before this field was added + zone.managed_by = "directadmin" return zone - def _normalize_cname_data(self, zone_name: str, record_content: str) -> str: - """Normalize CNAME record data to ensure consistent FQDN format. + def _relativize_name(self, zone_name: str, name: str) -> str: + """Normalise a DNS hostname for CoreDNS MySQL storage. - This ensures CNAME targets are always stored as fully-qualified domain - names so that record comparison between the BIND zone source and the - database is deterministic. + CoreDNS MySQL (cybercinch fork) expects: + ``@`` — zone apex + ``sub`` — in-zone hostname (relative, no trailing dot) + ``other.domain.`` — out-of-zone FQDN (trailing dot) - Args: - zone_name: The zone name for relative-name expansion - record_content: The raw CNAME target from the parsed zone + When a zone file lacks a ``$ORIGIN`` directive dnspython cannot + relativize names and returns absolute FQDNs. This method converts + both the already-relative form (from dnspython) and the absolute FQDN + form into the format CoreDNS MySQL understands. - Returns: - The normalized CNAME target string + Storing the zone FQDN as-is (e.g. ``ithome.net.nz.``) causes CoreDNS + to strip the zone suffix and serve ``MX 0 .`` / ``CNAME .`` instead of + the correct apex target — hence the conversion to ``@``. """ - if record_content.startswith("@"): - logger.debug(f"CNAME target starts with '@', replacing with zone FQDN") - record_content = self.dot_fqdn(zone_name) - elif not record_content.endswith("."): - logger.debug(f"CNAME target {record_content} is relative, appending zone") - record_content = ".".join([record_content, self.dot_fqdn(zone_name)]) + if name in ("@", "."): + return "@" + zone_fqdn = self.dot_fqdn(zone_name) + if name == zone_fqdn: + return "@" + suffix = "." + zone_fqdn + if name.endswith(suffix): + return name[: -len(suffix)] + return name + + def _normalize_cname_data(self, zone_name: str, record_content: str) -> str: + return self._relativize_name(zone_name, record_content) + + def _normalize_mx_data(self, zone_name: str, record_content: str) -> str: + """Normalize MX RDATA: relativize the exchange hostname. + + ``rdata.to_text()`` returns ``"priority exchange"``. When the exchange + is the zone apex it may arrive as ``@`` (dnspython-relativized) or as + the full FQDN (no ``$ORIGIN`` in zone). Both are converted to ``@``. + """ + parts = record_content.split(None, 1) + if len(parts) == 2: + priority, exchange = parts + return f"{priority} {self._relativize_name(zone_name, exchange)}" + return record_content + + def _normalize_ns_data(self, zone_name: str, record_content: str) -> str: + return self._relativize_name(zone_name, record_content) + + def _normalize_srv_data(self, zone_name: str, record_content: str) -> str: + """Normalize SRV RDATA: relativize the target (last field).""" + parts = record_content.rsplit(None, 1) + if len(parts) == 2: + prefix, target = parts + return f"{prefix} {self._relativize_name(zone_name, target)}" return record_content def _parse_zone_to_record_set( @@ -301,6 +340,12 @@ class CoreDNSMySQLBackend(DNSBackend): if record_type == "CNAME": record_content = self._normalize_cname_data(zone_name, record_content) + elif record_type == "MX": + record_content = self._normalize_mx_data(zone_name, record_content) + elif record_type == "NS": + record_content = self._normalize_ns_data(zone_name, record_content) + elif record_type == "SRV": + record_content = self._normalize_srv_data(zone_name, record_content) records.add((record_name, record_type, record_content, ttl)) diff --git a/schema/coredns_mysql.sql b/schema/coredns_mysql.sql index 644850e..8e38fee 100644 --- a/schema/coredns_mysql.sql +++ b/schema/coredns_mysql.sql @@ -1,12 +1,33 @@ -CREATE TABLE IF NOT EXISTS `records` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `zone` varchar(255) NOT NULL, - `name` varchar(255) NOT NULL, - `ttl` int(11) DEFAULT NULL, - `type` varchar(10) NOT NULL, - `data` text NOT NULL, +-- DirectDNSOnly — CoreDNS MySQL schema +-- Compatible with cybercinch/coredns_mysql_extend +-- +-- managed_by values: +-- 'directadmin' zone is managed via directdnsonly / DirectAdmin push +-- 'direct' zone was created directly (not via DA) +-- NULL legacy row created before this column was added + +CREATE TABLE IF NOT EXISTS `zones` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `zone_name` varchar(255) NOT NULL, + `managed_by` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`), - KEY `idx_zone` (`zone`), - KEY `idx_name` (`name`), - KEY `idx_type` (`type`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; \ No newline at end of file + UNIQUE KEY `uq_zone_name` (`zone_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `records` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `zone_id` int(11) NOT NULL, + `hostname` varchar(255) NOT NULL, + `type` varchar(10) NOT NULL, + `data` text NOT NULL, + `ttl` int(11) DEFAULT NULL, + `online` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_zone_id` (`zone_id`), + KEY `idx_hostname` (`hostname`), + CONSTRAINT `fk_records_zone` FOREIGN KEY (`zone_id`) REFERENCES `zones` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Migration: add managed_by to an existing installation +-- ALTER TABLE `zones` ADD COLUMN `managed_by` varchar(255) DEFAULT NULL; +-- UPDATE `zones` SET `managed_by` = 'directadmin' WHERE `managed_by` IS NULL; diff --git a/tests/test_coredns_mysql.py b/tests/test_coredns_mysql.py index 80b1e36..de2f609 100644 --- a/tests/test_coredns_mysql.py +++ b/tests/test_coredns_mysql.py @@ -165,3 +165,124 @@ def test_reconcile_no_changes_when_zone_matches(mysql_backend): success, removed = mysql_backend.reconcile_zone_records("example.com", ZONE_DATA) assert success assert removed == 0 + + +# --------------------------------------------------------------------------- +# managed_by field +# --------------------------------------------------------------------------- + + +def test_write_zone_sets_managed_by_directadmin(mysql_backend): + mysql_backend.write_zone("example.com", ZONE_DATA) + session = mysql_backend.Session() + zone = session.execute( + select(Zone).filter_by(zone_name="example.com.") + ).scalar_one_or_none() + assert zone.managed_by == "directadmin" + session.close() + + +def test_write_zone_migrates_null_managed_by(mysql_backend): + """Zones that pre-exist without managed_by get it set on next write.""" + session = mysql_backend.Session() + zone = Zone(zone_name="example.com.", managed_by=None) + session.add(zone) + session.commit() + session.close() + + mysql_backend.write_zone("example.com", ZONE_DATA) + + session = mysql_backend.Session() + zone = session.execute( + select(Zone).filter_by(zone_name="example.com.") + ).scalar_one_or_none() + assert zone.managed_by == "directadmin" + session.close() + + +# --------------------------------------------------------------------------- +# _relativize_name — apex/in-zone/external normalisation for CoreDNS MySQL +# --------------------------------------------------------------------------- + + +def test_relativize_apex_symbol(mysql_backend): + assert mysql_backend._relativize_name("example.com", "@") == "@" + + +def test_relativize_dot(mysql_backend): + assert mysql_backend._relativize_name("example.com", ".") == "@" + + +def test_relativize_zone_fqdn_to_apex(mysql_backend): + """Full zone FQDN must become '@' — storing it as-is causes CoreDNS to serve '.'.""" + assert mysql_backend._relativize_name("example.com", "example.com.") == "@" + + +def test_relativize_in_zone_subdomain(mysql_backend): + assert mysql_backend._relativize_name("example.com", "mail.example.com.") == "mail" + + +def test_relativize_external_fqdn_unchanged(mysql_backend): + assert mysql_backend._relativize_name("example.com", "mail.google.com.") == "mail.google.com." + + +def test_relativize_already_relative_unchanged(mysql_backend): + assert mysql_backend._relativize_name("example.com", "mail") == "mail" + + +# --------------------------------------------------------------------------- +# MX record normalization via write_zone +# --------------------------------------------------------------------------- + +MX_APEX_ZONE = """\ +$ORIGIN example.com. +$TTL 300 +example.com. 300 IN SOA ns.example.com. admin.example.com. (2023 3600 1800 604800 86400) +example.com. 300 IN MX 0 example.com. +example.com. 300 IN MX 10 mail.google.com. +""" + +MX_RELATIVE_ZONE = """\ +$ORIGIN example.com. +$TTL 300 +example.com. 300 IN SOA ns.example.com. admin.example.com. (2023 3600 1800 604800 86400) +example.com. 300 IN MX 0 @ +example.com. 300 IN MX 10 mail.google.com. +""" + + +def _get_mx_data(mysql_backend, zone_name="example.com"): + session = mysql_backend.Session() + zone = session.execute( + select(Zone).filter_by(zone_name=zone_name + ".") + ).scalar_one_or_none() + records = ( + session.execute( + select(Record).filter_by(zone_id=zone.id, type="MX") + ).scalars().all() + ) + result = {r.data for r in records} + session.close() + return result + + +def test_mx_apex_fqdn_stored_as_at_symbol(mysql_backend): + """MX pointing to zone FQDN must be stored as '0 @'.""" + mysql_backend.write_zone("example.com", MX_APEX_ZONE) + mx_data = _get_mx_data(mysql_backend) + assert "0 @" in mx_data + assert not any("example.com" in d for d in mx_data) + + +def test_mx_apex_at_symbol_stored_as_at_symbol(mysql_backend): + """MX '0 @' (already relative) must remain '0 @'.""" + mysql_backend.write_zone("example.com", MX_RELATIVE_ZONE) + mx_data = _get_mx_data(mysql_backend) + assert "0 @" in mx_data + + +def test_mx_external_fqdn_stored_unchanged(mysql_backend): + """External MX target must be stored as absolute FQDN.""" + mysql_backend.write_zone("example.com", MX_APEX_ZONE) + mx_data = _get_mx_data(mysql_backend) + assert "10 mail.google.com." in mx_data