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()), + )) +}