From 1db25cb82ba776c081671e0dd57c4229cb249589 Mon Sep 17 00:00:00 2001 From: Matt Stavola Date: Fri, 17 Apr 2026 21:37:03 -0400 Subject: [PATCH] Add mlf-dns-namecheap plugin Namecheap's XML-over-GET legacy API. Every call hits xml.response with ApiUser/ApiKey/UserName/ClientIp params; production access requires whitelisting the caller's IP in Namecheap's admin panel (the plugin surfaces the common "IP is not in the whitelist" response cleanly as IpNotWhitelisted rather than an opaque HTTP 200 with an error body). setHosts replaces every DNS record on the zone, so upsert/delete round-trip through getHosts, modify-in-memory, setHosts. Zone lookup walks parent domains against domains.getList. Options schema takes api_user, api_key, user_name (defaults to api_user), and a required non-secret client_ip so the user can set it correctly per environment. --- Cargo.lock | 23 ++ Cargo.toml | 1 + dns-plugins/mlf-dns-namecheap/Cargo.toml | 19 ++ dns-plugins/mlf-dns-namecheap/src/api.rs | 382 ++++++++++++++++++++++ dns-plugins/mlf-dns-namecheap/src/main.rs | 314 ++++++++++++++++++ 5 files changed, 739 insertions(+) create mode 100644 dns-plugins/mlf-dns-namecheap/Cargo.toml create mode 100644 dns-plugins/mlf-dns-namecheap/src/api.rs create mode 100644 dns-plugins/mlf-dns-namecheap/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 4b13493..5835a20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2345,6 +2345,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "mlf-dns-namecheap" +version = "0.1.0" +dependencies = [ + "mlf-plugin-host", + "quick-xml", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "mlf-dns-porkbun" version = "0.1.0" @@ -2868,6 +2881,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.41" diff --git a/Cargo.toml b/Cargo.toml index c87e02b..059cf9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "mlf-atproto", "mlf-cli", "dns-plugins/mlf-dns-godaddy", + "dns-plugins/mlf-dns-namecheap", "dns-plugins/mlf-dns-porkbun", "dns-plugins/mlf-dns-route53", "mlf-plugin-host", diff --git a/dns-plugins/mlf-dns-namecheap/Cargo.toml b/dns-plugins/mlf-dns-namecheap/Cargo.toml new file mode 100644 index 0000000..299e078 --- /dev/null +++ b/dns-plugins/mlf-dns-namecheap/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "mlf-dns-namecheap" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Official MLF DNS provider plugin for Namecheap" + +[[bin]] +name = "mlf-dns-namecheap" +path = "src/main.rs" + +[dependencies] +mlf-plugin-host = { path = "../../mlf-plugin-host" } +quick-xml = { version = "0.36", features = ["serialize"] } +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tokio = { version = "1", features = ["io-util", "macros", "rt"] } diff --git a/dns-plugins/mlf-dns-namecheap/src/api.rs b/dns-plugins/mlf-dns-namecheap/src/api.rs new file mode 100644 index 0000000..07f3997 --- /dev/null +++ b/dns-plugins/mlf-dns-namecheap/src/api.rs @@ -0,0 +1,382 @@ +//! Thin Namecheap API wrapper. +//! +//! Namecheap is a quirky, XML-over-GET legacy API. Every call goes to +//! the same `xml.response` endpoint with query params, and the caller's +//! IP must be whitelisted in Namecheap's admin panel before production +//! API access works. +//! +//! The replace-the-world semantics of `namecheap.domains.dns.setHosts` +//! (which overwrites every DNS record on the domain, not just the TXT +//! we're touching) means every upsert/delete does a round-trip: +//! `getHosts` → modify in memory → `setHosts`. Be careful. + +use crate::Credentials; +use quick_xml::events::Event; +use quick_xml::reader::Reader; +use std::collections::HashMap; +use thiserror::Error; + +const PRODUCTION: &str = "https://api.namecheap.com/xml.response"; + +#[derive(Error, Debug)] +pub enum NamecheapError { + #[error("Namecheap HTTP error: {0}")] + Http(String), + #[error("Namecheap API error: {0}")] + Api(String), + #[error("XML parse error: {0}")] + Xml(String), + #[error("IP `{ip}` is not whitelisted in Namecheap's API access list")] + IpNotWhitelisted { ip: String }, +} + +impl From for NamecheapError { + fn from(e: reqwest::Error) -> Self { + NamecheapError::Http(e.to_string()) + } +} + +pub struct NamecheapClient { + client: reqwest::Client, + api_user: String, + api_key: String, + user_name: String, + client_ip: String, +} + +impl NamecheapClient { + pub fn new(creds: &Credentials) -> Self { + Self { + client: reqwest::Client::new(), + api_user: creds.api_user.clone(), + api_key: creds.api_key.clone(), + user_name: creds.user_name.clone().unwrap_or(creds.api_user.clone()), + client_ip: creds.client_ip.clone(), + } + } + + async fn call( + &self, + command: &str, + extra: &[(&str, &str)], + ) -> Result { + let mut query: Vec<(String, String)> = vec![ + ("ApiUser".into(), self.api_user.clone()), + ("ApiKey".into(), self.api_key.clone()), + ("UserName".into(), self.user_name.clone()), + ("ClientIp".into(), self.client_ip.clone()), + ("Command".into(), command.into()), + ]; + for (k, v) in extra { + query.push(((*k).into(), (*v).into())); + } + let resp = self.client.get(PRODUCTION).query(&query).send().await?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(NamecheapError::Api(format!("HTTP {status}: {body}"))); + } + let body = resp.text().await?; + // Look for "IP not allowed" tone in the response so we surface + // the most common Namecheap footgun cleanly. + if body.contains("IP is not in the whitelist") || body.contains("is not allowed") { + return Err(NamecheapError::IpNotWhitelisted { + ip: self.client_ip.clone(), + }); + } + Ok(body) + } + + pub async fn verify(&self) -> Result { + // `getList` lists domains — cheap and confirms the IP + key. + let body = self.call("namecheap.domains.getList", &[]).await?; + if !body.contains("Status=\"OK\"") { + return Err(NamecheapError::Api(extract_error_message(&body))); + } + Ok(format!("namecheap ({})", self.client_ip)) + } + + pub async fn list_domains(&self) -> Result, NamecheapError> { + let body = self.call("namecheap.domains.getList", &[]).await?; + if !body.contains("Status=\"OK\"") { + return Err(NamecheapError::Api(extract_error_message(&body))); + } + Ok(extract_attrs(&body, "Domain", "Name")) + } + + pub async fn find_zone_for(&self, dns_name: &str) -> Result, NamecheapError> { + let stripped = dns_name.strip_prefix("_lexicon.").unwrap_or(dns_name); + let domains = self.list_domains().await?; + for candidate in parent_domains(stripped) { + if domains.iter().any(|d| d.eq_ignore_ascii_case(&candidate)) { + return Ok(Some(candidate)); + } + } + Ok(None) + } + + pub async fn get_hosts(&self, zone: &str) -> Result, NamecheapError> { + let (sld, tld) = split_domain(zone); + let body = self + .call( + "namecheap.domains.dns.getHosts", + &[("SLD", sld), ("TLD", tld)], + ) + .await?; + if !body.contains("Status=\"OK\"") { + return Err(NamecheapError::Api(extract_error_message(&body))); + } + parse_hosts(&body) + } + + /// Namecheap's setHosts REPLACES every record on the domain, so + /// upsert/delete need to round-trip the full host list. + pub async fn set_hosts(&self, zone: &str, hosts: &[Host]) -> Result<(), NamecheapError> { + let (sld, tld) = split_domain(zone); + let mut query: Vec<(String, String)> = vec![ + ("SLD".into(), sld.into()), + ("TLD".into(), tld.into()), + ]; + for (i, h) in hosts.iter().enumerate() { + let n = i + 1; + query.push((format!("HostName{n}"), h.name.clone())); + query.push((format!("RecordType{n}"), h.record_type.clone())); + query.push((format!("Address{n}"), h.address.clone())); + query.push((format!("TTL{n}"), h.ttl.to_string())); + if let Some(mx) = h.mx_pref { + query.push((format!("MXPref{n}"), mx.to_string())); + } + } + let extra: Vec<(&str, &str)> = query + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + let body = self + .call("namecheap.domains.dns.setHosts", &extra) + .await?; + if !body.contains("Status=\"OK\"") { + return Err(NamecheapError::Api(extract_error_message(&body))); + } + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct Host { + pub name: String, + pub record_type: String, + pub address: String, + pub ttl: u32, + pub mx_pref: Option, +} + +fn parent_domains(name: &str) -> Vec { + let parts: Vec<&str> = name.split('.').collect(); + (0..parts.len()).map(|i| parts[i..].join(".")).collect() +} + +/// Split `example.com` → (`example`, `com`). Namecheap wants SLD+TLD +/// separately. For multi-label TLDs (e.g. `.co.uk`), Namecheap expects +/// the whole thing as the TLD — we don't try to split those; callers +/// would need to use the exact domain shape. +fn split_domain(domain: &str) -> (&str, &str) { + match domain.find('.') { + Some(idx) => (&domain[..idx], &domain[idx + 1..]), + None => (domain, ""), + } +} + +/// Pull attribute values from every `` in +/// the body. Used because Namecheap's XML is simple and we already +/// depend on string matching elsewhere. +fn extract_attrs(body: &str, tag: &str, attr: &str) -> Vec { + let mut reader = Reader::from_str(body); + reader.config_mut().trim_text(true); + let mut out = Vec::new(); + loop { + match reader.read_event() { + Ok(Event::Empty(e)) | Ok(Event::Start(e)) => { + if e.name().as_ref() == tag.as_bytes() { + for a in e.attributes().flatten() { + if a.key.as_ref() == attr.as_bytes() + && let Ok(v) = a.unescape_value() + { + out.push(v.into_owned()); + } + } + } + } + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + out +} + +fn extract_error_message(body: &str) -> String { + let mut reader = Reader::from_str(body); + reader.config_mut().trim_text(true); + let mut out = String::new(); + let mut in_error = false; + loop { + match reader.read_event() { + Ok(Event::Start(e)) if e.name().as_ref() == b"Error" => in_error = true, + Ok(Event::End(e)) if e.name().as_ref() == b"Error" => in_error = false, + Ok(Event::Text(t)) if in_error => { + if let Ok(s) = t.unescape() { + if !out.is_empty() { + out.push_str("; "); + } + out.push_str(&s); + } + } + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + if out.is_empty() { + "Namecheap returned a non-OK status".into() + } else { + out + } +} + +fn parse_hosts(body: &str) -> Result, NamecheapError> { + let mut reader = Reader::from_str(body); + reader.config_mut().trim_text(true); + let mut out = Vec::new(); + loop { + match reader.read_event() { + Ok(Event::Empty(e)) | Ok(Event::Start(e)) => { + if e.name().as_ref() == b"host" || e.name().as_ref() == b"Host" { + let mut attrs: HashMap = HashMap::new(); + for a in e.attributes().flatten() { + let key = + String::from_utf8_lossy(a.key.as_ref()).to_string(); + if let Ok(v) = a.unescape_value() { + attrs.insert(key, v.into_owned()); + } + } + let name = attrs.remove("Name").unwrap_or_default(); + let record_type = attrs.remove("Type").unwrap_or_default(); + let address = attrs.remove("Address").unwrap_or_default(); + let ttl = attrs + .remove("TTL") + .and_then(|s| s.parse().ok()) + .unwrap_or(1800); + let mx_pref = attrs.remove("MXPref").and_then(|s| s.parse().ok()); + out.push(Host { + name, + record_type, + address, + ttl, + mx_pref, + }); + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(NamecheapError::Xml(e.to_string())), + _ => {} + } + } + Ok(out) +} + +/// Zone-relative host name (what Namecheap stores). Apex is `@`. +pub fn relative_name(name: &str, zone: &str) -> String { + let name = name.trim_end_matches('.'); + let zone = zone.trim_end_matches('.'); + if name.eq_ignore_ascii_case(zone) { + return "@".into(); + } + name.strip_suffix(&format!(".{zone}")) + .map(|s| s.to_string()) + .unwrap_or_else(|| name.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parent_domains_walks_up() { + assert_eq!( + parent_domains("_lexicon.forum.example.com"), + vec![ + "_lexicon.forum.example.com", + "forum.example.com", + "example.com", + "com", + ] + ); + } + + #[test] + fn split_domain_sld_tld() { + assert_eq!(split_domain("example.com"), ("example", "com")); + assert_eq!(split_domain("example.co.uk"), ("example", "co.uk")); + } + + #[test] + fn relative_name_handles_apex() { + assert_eq!(relative_name("example.com", "example.com"), "@"); + assert_eq!( + relative_name("_lexicon.example.com", "example.com"), + "_lexicon" + ); + } + + #[test] + fn parse_hosts_reads_attributes() { + let xml = r#" + + + + + + + + + + "#; + let hosts = parse_hosts(xml).unwrap(); + assert_eq!(hosts.len(), 2); + assert_eq!(hosts[0].name, "_lexicon"); + assert_eq!(hosts[0].record_type, "TXT"); + assert_eq!(hosts[0].address, "did=did:plc:abc"); + assert_eq!(hosts[0].ttl, 1800); + } + + #[test] + fn extract_attrs_pulls_domain_names() { + let xml = r#" + + + + + + + + + + "#; + assert_eq!( + extract_attrs(xml, "Domain", "Name"), + vec!["example.com", "example.net"] + ); + } + + #[test] + fn extract_error_message_from_error_block() { + let xml = r#" + + + IP is not in the whitelist + + + "#; + assert!(extract_error_message(xml).contains("whitelist")); + } +} diff --git a/dns-plugins/mlf-dns-namecheap/src/main.rs b/dns-plugins/mlf-dns-namecheap/src/main.rs new file mode 100644 index 0000000..a2f7a23 --- /dev/null +++ b/dns-plugins/mlf-dns-namecheap/src/main.rs @@ -0,0 +1,314 @@ +//! Official MLF DNS provider plugin for Namecheap. +//! +//! Options schema: +//! - `api_user` (secret, required) — Namecheap API username +//! - `api_key` (secret, required) — Namecheap API key +//! - `user_name` (secret, optional) — defaults to `api_user` +//! - `client_ip` (non-secret, required) — caller's public IP; must be +//! whitelisted in Namecheap's API access settings. +//! +//! Namecheap's setHosts endpoint REPLACES every DNS record on the +//! domain, so every upsert/delete here does a getHosts + modify + +//! setHosts round-trip. Don't mutate the record list anywhere outside +//! this plugin between a get and a set for the same domain. + +mod api; + +use api::{Host, NamecheapClient, NamecheapError, relative_name}; +use mlf_plugin_host::plugin::{Server, empty_data, params_as}; +use mlf_plugin_host::protocol::{HelloData, OptionField, PROTOCOL_VERSION, Request}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> std::io::Result<()> { + let mut server = Server::stdio(); + + let identity = HelloData { + name: "namecheap".into(), + protocol_version: PROTOCOL_VERSION, + kind: Some("dns".into()), + capabilities: vec![ + "login".into(), + "list_txt".into(), + "upsert_txt".into(), + "delete_txt".into(), + "resolve_zone".into(), + ], + options_schema: vec![ + OptionField { + name: "api_user".into(), + label: "Namecheap API username".into(), + help: Some( + "Your Namecheap username. Enable API access and \ + whitelist your IP at https://ap.www.namecheap.com/settings/tools/apiaccess/." + .into(), + ), + secret: true, + required: true, + default: None, + }, + OptionField { + name: "api_key".into(), + label: "Namecheap API key".into(), + help: None, + secret: true, + required: true, + default: None, + }, + OptionField { + name: "user_name".into(), + label: "Namecheap user name (defaults to api_user)".into(), + help: None, + secret: true, + required: false, + default: None, + }, + OptionField { + name: "client_ip".into(), + label: "Your public IP (must be whitelisted on Namecheap)".into(), + help: Some( + "Namecheap rejects any call from an IP that isn't on the \ + whitelist in the API-access settings page." + .into(), + ), + secret: false, + required: true, + default: None, + }, + ], + }; + + if server.handshake(identity).await.is_err() { + return Ok(()); + } + + let mut creds: Option = None; + + while let Ok(Some(req)) = server.next_request().await { + if let Err(e) = dispatch(&mut server, &req, &mut creds).await { + let _ = server.reply_err("internal", &e.to_string(), false).await; + } + } + + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Credentials { + pub api_user: String, + pub api_key: String, + #[serde(default)] + pub user_name: Option, + pub client_ip: String, +} + +#[derive(Debug, Deserialize)] +struct InitParams { + #[serde(default)] + credentials: Option, +} + +#[derive(Debug, Deserialize)] +struct ResolveZoneParams { + domain: String, +} + +#[derive(Debug, Deserialize)] +struct ListTxtParams { + name: String, +} + +#[derive(Debug, Deserialize)] +struct UpsertTxtParams { + name: String, + value: String, + #[serde(default)] + ttl: Option, +} + +#[derive(Debug, Deserialize)] +struct DeleteTxtParams { + name: String, + #[allow(dead_code)] + record_id: String, +} + +#[derive(thiserror::Error, Debug)] +enum DispatchError { + #[error("{0}")] + Plugin(#[from] mlf_plugin_host::plugin::PluginError), + #[error("{0}")] + Namecheap(#[from] NamecheapError), +} + +async fn dispatch( + server: &mut Server, + req: &Request, + creds: &mut Option, +) -> Result<(), DispatchError> +where + W: tokio::io::AsyncWrite + Unpin, + R: tokio::io::AsyncBufReadExt + Unpin, +{ + match req.op.as_str() { + "init" => { + let InitParams { credentials } = params_as(req)?; + *creds = credentials; + server.reply_ok(empty_data()).await?; + } + "login" => { + let Some(c) = creds.as_ref() else { + server + .reply_err( + "no_credentials", + "login called before init set credentials", + false, + ) + .await?; + return Ok(()); + }; + match NamecheapClient::new(c).verify().await { + Ok(name) => { + server + .reply_ok(json!({"credentials": c, "display_name": name})) + .await?; + } + Err(e) => { + server + .reply_err("invalid_credentials", &e.to_string(), false) + .await?; + } + } + } + "logout" => { + *creds = None; + server.reply_ok(empty_data()).await?; + } + "resolve_zone" => { + let ResolveZoneParams { domain } = params_as(req)?; + let c = require_creds(server, creds).await?; + let client = NamecheapClient::new(&c); + match client.find_zone_for(&domain).await? { + Some(zone) => { + server + .reply_ok(json!({"zone_id": zone, "covered": true})) + .await?; + } + None => { + server + .reply_ok(json!({"zone_id": Value::Null, "covered": false})) + .await?; + } + } + } + "list_txt" => { + let ListTxtParams { name } = params_as(req)?; + let c = require_creds(server, creds).await?; + let client = NamecheapClient::new(&c); + let zone = match client.find_zone_for(&name).await? { + Some(z) => z, + None => { + server + .reply_err("unknown_zone", &format!("no zone covers {name}"), false) + .await?; + return Ok(()); + } + }; + let rel = relative_name(&name, &zone); + let hosts = client.get_hosts(&zone).await?; + let matching: Vec<_> = hosts + .iter() + .filter(|h| h.record_type == "TXT" && h.name == rel) + .map(|h| { + json!({ + "id": format!("{}/TXT/{}", zone, h.name), + "value": h.address, + }) + }) + .collect(); + server.reply_ok(json!({ "records": matching })).await?; + } + "upsert_txt" => { + let UpsertTxtParams { name, value, ttl } = params_as(req)?; + let c = require_creds(server, creds).await?; + let client = NamecheapClient::new(&c); + let zone = match client.find_zone_for(&name).await? { + Some(z) => z, + None => { + server + .reply_err("unknown_zone", &format!("no zone covers {name}"), false) + .await?; + return Ok(()); + } + }; + let rel = relative_name(&name, &zone); + let mut hosts = client.get_hosts(&zone).await?; + // Remove any existing TXT at (rel) — setHosts replaces the whole + // zone, so we rebuild the list ourselves. + hosts.retain(|h| !(h.record_type == "TXT" && h.name == rel)); + hosts.push(Host { + name: rel.clone(), + record_type: "TXT".into(), + address: value.clone(), + ttl: ttl.unwrap_or(1800), + mx_pref: None, + }); + client.set_hosts(&zone, &hosts).await?; + server + .reply_ok(json!({ "record_id": format!("{}/TXT/{}", zone, rel) })) + .await?; + } + "delete_txt" => { + let DeleteTxtParams { name, record_id: _ } = params_as(req)?; + let c = require_creds(server, creds).await?; + let client = NamecheapClient::new(&c); + let zone = match client.find_zone_for(&name).await? { + Some(z) => z, + None => { + server + .reply_err("unknown_zone", &format!("no zone covers {name}"), false) + .await?; + return Ok(()); + } + }; + let rel = relative_name(&name, &zone); + let mut hosts = client.get_hosts(&zone).await?; + let before = hosts.len(); + hosts.retain(|h| !(h.record_type == "TXT" && h.name == rel)); + if hosts.len() < before { + client.set_hosts(&zone, &hosts).await?; + } + server.reply_ok(empty_data()).await?; + } + other => { + server + .reply_err("unknown_op", &format!("unsupported op `{other}`"), false) + .await?; + } + } + Ok(()) +} + +async fn require_creds( + server: &mut Server, + creds: &Option, +) -> Result +where + W: tokio::io::AsyncWrite + Unpin, + R: tokio::io::AsyncBufReadExt + Unpin, +{ + if let Some(c) = creds.clone() { + return Ok(c); + } + server + .reply_err( + "no_credentials", + "host hasn't called init with credentials yet", + false, + ) + .await?; + Err(DispatchError::Plugin( + mlf_plugin_host::plugin::PluginError::Unexpected("missing credentials".into()), + )) +} -- 2.51.2