diff --git a/ampg/dynadot_dns.py b/ampg/dynadot_dns.py index 7506f48..2894274 100644 --- a/ampg/dynadot_dns.py +++ b/ampg/dynadot_dns.py @@ -50,6 +50,34 @@ DYNADOT_API_ORIGINS = { } +def _is_mislabelled_apex_mx(record_type: str, value1: str | None, value2: str | None) -> bool: + """Dynadot returns an apex MX record typed "stealth". + + Observed on suchshop.lol across three reads on two days: the zone's three + apex records come back as stealth/txt/a, and comparing them one to one with + what the apex actually serves gives stealth = MX 10 mail.suchshop.lol. There + is no fourth record and no forwarding configured. Writing that record back + with the type Dynadot supplied is refused by Dynadot itself, with "Please + enter a valid stealth forwarding url", so a zone carrying an apex MX could + not be updated at all: the whole POST fails and no record in it is written. + + The two shapes do not overlap, which is what makes this safe to correct + rather than guess at. A genuine stealth forward stores a URL in value1 and + leaves value2 empty; Dynadot's own error names the format it requires. A + hostname in value1 with an integer preference in value2 is not a stealth + record in any valid configuration. + """ + if record_type != "STEALTH" or value1 is None or value2 is None: + return False + if "://" in value1: + return False + try: + preference = int(value2) + except ValueError: + return False + return 0 <= preference <= 65535 + + def dynadot_signature( api_key: str, api_secret: str, @@ -282,6 +310,8 @@ class DynadotDNSProvider: value2 = _record_value(item, "record_value2") if value2 is None: value2 = _record_value(item, "value2") + if _is_mislabelled_apex_mx(record_type, value1, value2): + record_type = "MX" if not name or value1 is None: raise RuntimeError("Dynadot returned an incomplete DNS record") mx_pref = None diff --git a/tests/test_dynadot_dns.py b/tests/test_dynadot_dns.py index 722b2bc..b1e975c 100644 --- a/tests/test_dynadot_dns.py +++ b/tests/test_dynadot_dns.py @@ -490,7 +490,14 @@ class DynadotDNSTests(unittest.TestCase): self.assertEqual("v=DKIM1;k=rsa;p=AAAA", by_name[("modoboa._domainkey", "TXT")].value) self.assertEqual("mail.example.test", by_name[("mail", "MX")].value) self.assertEqual(10, by_name[("mail", "MX")].mx_pref) - self.assertIn(("@", "STEALTH"), by_name, "an unusual type is still a record") + # This fixture is a copy of suchshop.lol, whose apex record Dynadot types + # "stealth" while the apex serves MX 10 mail.suchshop.lol. Preserving that + # type verbatim, which this test used to require, is exactly what made a + # preserve-unmanaged write impossible: Dynadot rejects its own spelling on + # the way back in, and takes the whole batch down with it. + self.assertNotIn(("@", "STEALTH"), by_name) + self.assertEqual("mail.example.test", by_name[("@", "MX")].value) + self.assertEqual(10, by_name[("@", "MX")].mx_pref) def test_the_write_spelling_is_still_accepted(self): # Kept so the two spellings cannot quietly become one. @@ -527,3 +534,54 @@ class DynadotDNSTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +from ampg import dynadot_dns # noqa: E402 + + +class MislabelledApexMxTest(unittest.TestCase): + """Dynadot types an apex MX record "stealth" and then refuses it back. + + suchshop.lol carries MX 10 mail.suchshop.lol at the apex. Dynadot's records + endpoint returns it as record_type "stealth" with the host in value1 and the + preference in value2. Echoing that back, which a preserve-unmanaged write has + to do, is rejected with "Please enter a valid stealth forwarding url" and the + whole POST fails, so not one record in the batch is written. Six DMARC report + authorizations were blocked by it. + """ + + def test_a_hostname_with_a_preference_is_an_mx_record(self) -> None: + self.assertTrue( + dynadot_dns._is_mislabelled_apex_mx("STEALTH", "mail.suchshop.lol", "10") + ) + + def test_a_real_stealth_forward_is_left_alone(self) -> None: + # value1 is a URL and value2 is empty: the shape Dynadot documents. + self.assertFalse( + dynadot_dns._is_mislabelled_apex_mx( + "STEALTH", "http://subdomain.example.com", None + ) + ) + self.assertFalse( + dynadot_dns._is_mislabelled_apex_mx( + "STEALTH", "https://shop.example.com/path", "0" + ) + ) + + def test_nothing_else_is_touched(self) -> None: + for record_type in ("TXT", "A", "MX", "CNAME", "FORWARD"): + with self.subTest(record_type=record_type): + self.assertFalse( + dynadot_dns._is_mislabelled_apex_mx( + record_type, "mail.suchshop.lol", "10" + ) + ) + + def test_a_preference_outside_the_range_is_not_assumed(self) -> None: + for value2 in ("-1", "65536", "ten", "", "1.5"): + with self.subTest(value2=value2): + self.assertFalse( + dynadot_dns._is_mislabelled_apex_mx( + "STEALTH", "mail.suchshop.lol", value2 + ) + )