From c1604d4cc1353f5c7ba461401eee631407af785e Mon Sep 17 00:00:00 2001 From: Shon Thomas Date: Sat, 9 May 2026 10:34:18 -0800 Subject: [PATCH] fix(namecheap): lowercase HostName for setHosts API compat Namecheap's domains.dns.setHosts API validates HostName values case-sensitively and rejects uppercase letters with: 2050900: INVALID_NAME: Host name: '_' is invalid. DNS itself is case-insensitive at resolution time, but Namecheap's input validator is not. Sectigo's CSR-hash CNAME response delivers the host portion as uppercase MD5 hex (e.g. `_442EBF5636AF...`). Without normalization, every Sectigo CSR-hash renewal blows up at the publish step. Confirmed against Namecheap's API directly with a manual setHosts call: identical request body with the uppercase hostname returns 2050900; lowercasing the host portion is the only difference and the call succeeds. The accepted CNAME already on oneiric.dev (from a prior provisioning) uses lowercase hex, matching this behavior. Fix: split_record_name normalizes subdomain + sld + tld to ASCII lowercase. SLD/TLD were already-lowercase by convention but cheap to defend against; the real workhorse is the subdomain lowercase. Sectigo's validator does a case-insensitive DNS lookup so the record still resolves correctly. Test pinning the case normalization against the exact Sectigo CSR-hash hostname shape (`_.`). --- .../rota-daemon/src/backends/namecheap/dcv.rs | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/rota-daemon/src/backends/namecheap/dcv.rs b/crates/rota-daemon/src/backends/namecheap/dcv.rs index bf80bf9..57529b1 100644 --- a/crates/rota-daemon/src/backends/namecheap/dcv.rs +++ b/crates/rota-daemon/src/backends/namecheap/dcv.rs @@ -179,6 +179,15 @@ struct SplitName { /// Split `_acme-challenge.example.com` into /// `(subdomain="_acme-challenge", sld="example", tld="com")`. The /// Namecheap DNS API addresses domains as separate SLD + TLD parts. +/// +/// The subdomain is lowercased: Namecheap's `domains.dns.setHosts` +/// validates HostName case-sensitively and rejects uppercase letters +/// with `2050900: INVALID_NAME` — even though DNS itself is +/// case-insensitive at resolution time. Sectigo's CSR-hash CNAME +/// response delivers an uppercase MD5 hex (e.g. `_6958EA56...`); +/// without normalization rota's setHosts call dies before the record +/// is published. Sectigo's validator does a case-insensitive lookup +/// so the lowercased CNAME still resolves correctly. fn split_record_name(record_name: &str) -> Result { let parts: Vec<&str> = record_name.trim_end_matches('.').split('.').collect(); if parts.len() < 2 { @@ -186,10 +195,10 @@ fn split_record_name(record_name: &str) -> Result { "record name not splittable into sld + tld: {record_name}" ))); } - let tld = parts[parts.len() - 1].to_owned(); - let sld = parts[parts.len() - 2].to_owned(); + let tld = parts[parts.len() - 1].to_ascii_lowercase(); + let sld = parts[parts.len() - 2].to_ascii_lowercase(); let subdomain = if parts.len() > 2 { - parts[..parts.len() - 2].join(".") + parts[..parts.len() - 2].join(".").to_ascii_lowercase() } else { "@".to_owned() }; @@ -265,6 +274,19 @@ mod tests { assert_eq!(s.tld, "com"); } + #[test] + fn lowercases_uppercase_subdomain_for_namecheap_compat() { + // Sectigo's CSR-hash CNAME response delivers an uppercase MD5; + // Namecheap's setHosts rejects uppercase HostNames with 2050900. + // split_record_name normalizes to lowercase so the publish path + // doesn't blow up. DNS resolution is case-insensitive so this + // doesn't break Sectigo's validator. + let s = split_record_name("_6958EA56A4FE23DDF2C3EDA7B9B956A5.Oneiric.Dev").unwrap(); + assert_eq!(s.subdomain, "_6958ea56a4fe23ddf2c3eda7b9b956a5"); + assert_eq!(s.sld, "oneiric"); + assert_eq!(s.tld, "dev"); + } + #[test] fn splits_apex_only() { let s = split_record_name("example.com").unwrap();