A human-friendly DSL for ATProto Lexicons
27
fork

Configure Feed

Select the types of activity you want to include in your feed.

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.

authored by stavola.xyz and committed by

Tangled 1db25cb8 f2d31684

+739
+23
Cargo.lock
··· 2346 2346 ] 2347 2347 2348 2348 [[package]] 2349 + name = "mlf-dns-namecheap" 2350 + version = "0.1.0" 2351 + dependencies = [ 2352 + "mlf-plugin-host", 2353 + "quick-xml", 2354 + "reqwest", 2355 + "serde", 2356 + "serde_json", 2357 + "thiserror 2.0.17", 2358 + "tokio", 2359 + ] 2360 + 2361 + [[package]] 2349 2362 name = "mlf-dns-porkbun" 2350 2363 version = "0.1.0" 2351 2364 dependencies = [ ··· 2866 2879 checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" 2867 2880 dependencies = [ 2868 2881 "unicode-ident", 2882 + ] 2883 + 2884 + [[package]] 2885 + name = "quick-xml" 2886 + version = "0.36.2" 2887 + source = "registry+https://github.com/rust-lang/crates.io-index" 2888 + checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" 2889 + dependencies = [ 2890 + "memchr", 2891 + "serde", 2869 2892 ] 2870 2893 2871 2894 [[package]]
+1
Cargo.toml
··· 8 8 "mlf-atproto", 9 9 "mlf-cli", 10 10 "dns-plugins/mlf-dns-godaddy", 11 + "dns-plugins/mlf-dns-namecheap", 11 12 "dns-plugins/mlf-dns-porkbun", 12 13 "dns-plugins/mlf-dns-route53", 13 14 "mlf-plugin-host",
+19
dns-plugins/mlf-dns-namecheap/Cargo.toml
··· 1 + [package] 2 + name = "mlf-dns-namecheap" 3 + version = "0.1.0" 4 + edition = "2024" 5 + license = "MIT" 6 + description = "Official MLF DNS provider plugin for Namecheap" 7 + 8 + [[bin]] 9 + name = "mlf-dns-namecheap" 10 + path = "src/main.rs" 11 + 12 + [dependencies] 13 + mlf-plugin-host = { path = "../../mlf-plugin-host" } 14 + quick-xml = { version = "0.36", features = ["serialize"] } 15 + reqwest = { version = "0.12", features = ["json"] } 16 + serde = { version = "1", features = ["derive"] } 17 + serde_json = "1" 18 + thiserror = "2" 19 + tokio = { version = "1", features = ["io-util", "macros", "rt"] }
+382
dns-plugins/mlf-dns-namecheap/src/api.rs
··· 1 + //! Thin Namecheap API wrapper. 2 + //! 3 + //! Namecheap is a quirky, XML-over-GET legacy API. Every call goes to 4 + //! the same `xml.response` endpoint with query params, and the caller's 5 + //! IP must be whitelisted in Namecheap's admin panel before production 6 + //! API access works. 7 + //! 8 + //! The replace-the-world semantics of `namecheap.domains.dns.setHosts` 9 + //! (which overwrites every DNS record on the domain, not just the TXT 10 + //! we're touching) means every upsert/delete does a round-trip: 11 + //! `getHosts` → modify in memory → `setHosts`. Be careful. 12 + 13 + use crate::Credentials; 14 + use quick_xml::events::Event; 15 + use quick_xml::reader::Reader; 16 + use std::collections::HashMap; 17 + use thiserror::Error; 18 + 19 + const PRODUCTION: &str = "https://api.namecheap.com/xml.response"; 20 + 21 + #[derive(Error, Debug)] 22 + pub enum NamecheapError { 23 + #[error("Namecheap HTTP error: {0}")] 24 + Http(String), 25 + #[error("Namecheap API error: {0}")] 26 + Api(String), 27 + #[error("XML parse error: {0}")] 28 + Xml(String), 29 + #[error("IP `{ip}` is not whitelisted in Namecheap's API access list")] 30 + IpNotWhitelisted { ip: String }, 31 + } 32 + 33 + impl From<reqwest::Error> for NamecheapError { 34 + fn from(e: reqwest::Error) -> Self { 35 + NamecheapError::Http(e.to_string()) 36 + } 37 + } 38 + 39 + pub struct NamecheapClient { 40 + client: reqwest::Client, 41 + api_user: String, 42 + api_key: String, 43 + user_name: String, 44 + client_ip: String, 45 + } 46 + 47 + impl NamecheapClient { 48 + pub fn new(creds: &Credentials) -> Self { 49 + Self { 50 + client: reqwest::Client::new(), 51 + api_user: creds.api_user.clone(), 52 + api_key: creds.api_key.clone(), 53 + user_name: creds.user_name.clone().unwrap_or(creds.api_user.clone()), 54 + client_ip: creds.client_ip.clone(), 55 + } 56 + } 57 + 58 + async fn call( 59 + &self, 60 + command: &str, 61 + extra: &[(&str, &str)], 62 + ) -> Result<String, NamecheapError> { 63 + let mut query: Vec<(String, String)> = vec![ 64 + ("ApiUser".into(), self.api_user.clone()), 65 + ("ApiKey".into(), self.api_key.clone()), 66 + ("UserName".into(), self.user_name.clone()), 67 + ("ClientIp".into(), self.client_ip.clone()), 68 + ("Command".into(), command.into()), 69 + ]; 70 + for (k, v) in extra { 71 + query.push(((*k).into(), (*v).into())); 72 + } 73 + let resp = self.client.get(PRODUCTION).query(&query).send().await?; 74 + if !resp.status().is_success() { 75 + let status = resp.status(); 76 + let body = resp.text().await.unwrap_or_default(); 77 + return Err(NamecheapError::Api(format!("HTTP {status}: {body}"))); 78 + } 79 + let body = resp.text().await?; 80 + // Look for "IP not allowed" tone in the response so we surface 81 + // the most common Namecheap footgun cleanly. 82 + if body.contains("IP is not in the whitelist") || body.contains("is not allowed") { 83 + return Err(NamecheapError::IpNotWhitelisted { 84 + ip: self.client_ip.clone(), 85 + }); 86 + } 87 + Ok(body) 88 + } 89 + 90 + pub async fn verify(&self) -> Result<String, NamecheapError> { 91 + // `getList` lists domains — cheap and confirms the IP + key. 92 + let body = self.call("namecheap.domains.getList", &[]).await?; 93 + if !body.contains("Status=\"OK\"") { 94 + return Err(NamecheapError::Api(extract_error_message(&body))); 95 + } 96 + Ok(format!("namecheap ({})", self.client_ip)) 97 + } 98 + 99 + pub async fn list_domains(&self) -> Result<Vec<String>, NamecheapError> { 100 + let body = self.call("namecheap.domains.getList", &[]).await?; 101 + if !body.contains("Status=\"OK\"") { 102 + return Err(NamecheapError::Api(extract_error_message(&body))); 103 + } 104 + Ok(extract_attrs(&body, "Domain", "Name")) 105 + } 106 + 107 + pub async fn find_zone_for(&self, dns_name: &str) -> Result<Option<String>, NamecheapError> { 108 + let stripped = dns_name.strip_prefix("_lexicon.").unwrap_or(dns_name); 109 + let domains = self.list_domains().await?; 110 + for candidate in parent_domains(stripped) { 111 + if domains.iter().any(|d| d.eq_ignore_ascii_case(&candidate)) { 112 + return Ok(Some(candidate)); 113 + } 114 + } 115 + Ok(None) 116 + } 117 + 118 + pub async fn get_hosts(&self, zone: &str) -> Result<Vec<Host>, NamecheapError> { 119 + let (sld, tld) = split_domain(zone); 120 + let body = self 121 + .call( 122 + "namecheap.domains.dns.getHosts", 123 + &[("SLD", sld), ("TLD", tld)], 124 + ) 125 + .await?; 126 + if !body.contains("Status=\"OK\"") { 127 + return Err(NamecheapError::Api(extract_error_message(&body))); 128 + } 129 + parse_hosts(&body) 130 + } 131 + 132 + /// Namecheap's setHosts REPLACES every record on the domain, so 133 + /// upsert/delete need to round-trip the full host list. 134 + pub async fn set_hosts(&self, zone: &str, hosts: &[Host]) -> Result<(), NamecheapError> { 135 + let (sld, tld) = split_domain(zone); 136 + let mut query: Vec<(String, String)> = vec![ 137 + ("SLD".into(), sld.into()), 138 + ("TLD".into(), tld.into()), 139 + ]; 140 + for (i, h) in hosts.iter().enumerate() { 141 + let n = i + 1; 142 + query.push((format!("HostName{n}"), h.name.clone())); 143 + query.push((format!("RecordType{n}"), h.record_type.clone())); 144 + query.push((format!("Address{n}"), h.address.clone())); 145 + query.push((format!("TTL{n}"), h.ttl.to_string())); 146 + if let Some(mx) = h.mx_pref { 147 + query.push((format!("MXPref{n}"), mx.to_string())); 148 + } 149 + } 150 + let extra: Vec<(&str, &str)> = query 151 + .iter() 152 + .map(|(k, v)| (k.as_str(), v.as_str())) 153 + .collect(); 154 + let body = self 155 + .call("namecheap.domains.dns.setHosts", &extra) 156 + .await?; 157 + if !body.contains("Status=\"OK\"") { 158 + return Err(NamecheapError::Api(extract_error_message(&body))); 159 + } 160 + Ok(()) 161 + } 162 + } 163 + 164 + #[derive(Debug, Clone)] 165 + pub struct Host { 166 + pub name: String, 167 + pub record_type: String, 168 + pub address: String, 169 + pub ttl: u32, 170 + pub mx_pref: Option<u32>, 171 + } 172 + 173 + fn parent_domains(name: &str) -> Vec<String> { 174 + let parts: Vec<&str> = name.split('.').collect(); 175 + (0..parts.len()).map(|i| parts[i..].join(".")).collect() 176 + } 177 + 178 + /// Split `example.com` → (`example`, `com`). Namecheap wants SLD+TLD 179 + /// separately. For multi-label TLDs (e.g. `.co.uk`), Namecheap expects 180 + /// the whole thing as the TLD — we don't try to split those; callers 181 + /// would need to use the exact domain shape. 182 + fn split_domain(domain: &str) -> (&str, &str) { 183 + match domain.find('.') { 184 + Some(idx) => (&domain[..idx], &domain[idx + 1..]), 185 + None => (domain, ""), 186 + } 187 + } 188 + 189 + /// Pull attribute values from every `<Tag ... attr="value" ... />` in 190 + /// the body. Used because Namecheap's XML is simple and we already 191 + /// depend on string matching elsewhere. 192 + fn extract_attrs(body: &str, tag: &str, attr: &str) -> Vec<String> { 193 + let mut reader = Reader::from_str(body); 194 + reader.config_mut().trim_text(true); 195 + let mut out = Vec::new(); 196 + loop { 197 + match reader.read_event() { 198 + Ok(Event::Empty(e)) | Ok(Event::Start(e)) => { 199 + if e.name().as_ref() == tag.as_bytes() { 200 + for a in e.attributes().flatten() { 201 + if a.key.as_ref() == attr.as_bytes() 202 + && let Ok(v) = a.unescape_value() 203 + { 204 + out.push(v.into_owned()); 205 + } 206 + } 207 + } 208 + } 209 + Ok(Event::Eof) => break, 210 + Err(_) => break, 211 + _ => {} 212 + } 213 + } 214 + out 215 + } 216 + 217 + fn extract_error_message(body: &str) -> String { 218 + let mut reader = Reader::from_str(body); 219 + reader.config_mut().trim_text(true); 220 + let mut out = String::new(); 221 + let mut in_error = false; 222 + loop { 223 + match reader.read_event() { 224 + Ok(Event::Start(e)) if e.name().as_ref() == b"Error" => in_error = true, 225 + Ok(Event::End(e)) if e.name().as_ref() == b"Error" => in_error = false, 226 + Ok(Event::Text(t)) if in_error => { 227 + if let Ok(s) = t.unescape() { 228 + if !out.is_empty() { 229 + out.push_str("; "); 230 + } 231 + out.push_str(&s); 232 + } 233 + } 234 + Ok(Event::Eof) => break, 235 + Err(_) => break, 236 + _ => {} 237 + } 238 + } 239 + if out.is_empty() { 240 + "Namecheap returned a non-OK status".into() 241 + } else { 242 + out 243 + } 244 + } 245 + 246 + fn parse_hosts(body: &str) -> Result<Vec<Host>, NamecheapError> { 247 + let mut reader = Reader::from_str(body); 248 + reader.config_mut().trim_text(true); 249 + let mut out = Vec::new(); 250 + loop { 251 + match reader.read_event() { 252 + Ok(Event::Empty(e)) | Ok(Event::Start(e)) => { 253 + if e.name().as_ref() == b"host" || e.name().as_ref() == b"Host" { 254 + let mut attrs: HashMap<String, String> = HashMap::new(); 255 + for a in e.attributes().flatten() { 256 + let key = 257 + String::from_utf8_lossy(a.key.as_ref()).to_string(); 258 + if let Ok(v) = a.unescape_value() { 259 + attrs.insert(key, v.into_owned()); 260 + } 261 + } 262 + let name = attrs.remove("Name").unwrap_or_default(); 263 + let record_type = attrs.remove("Type").unwrap_or_default(); 264 + let address = attrs.remove("Address").unwrap_or_default(); 265 + let ttl = attrs 266 + .remove("TTL") 267 + .and_then(|s| s.parse().ok()) 268 + .unwrap_or(1800); 269 + let mx_pref = attrs.remove("MXPref").and_then(|s| s.parse().ok()); 270 + out.push(Host { 271 + name, 272 + record_type, 273 + address, 274 + ttl, 275 + mx_pref, 276 + }); 277 + } 278 + } 279 + Ok(Event::Eof) => break, 280 + Err(e) => return Err(NamecheapError::Xml(e.to_string())), 281 + _ => {} 282 + } 283 + } 284 + Ok(out) 285 + } 286 + 287 + /// Zone-relative host name (what Namecheap stores). Apex is `@`. 288 + pub fn relative_name(name: &str, zone: &str) -> String { 289 + let name = name.trim_end_matches('.'); 290 + let zone = zone.trim_end_matches('.'); 291 + if name.eq_ignore_ascii_case(zone) { 292 + return "@".into(); 293 + } 294 + name.strip_suffix(&format!(".{zone}")) 295 + .map(|s| s.to_string()) 296 + .unwrap_or_else(|| name.to_string()) 297 + } 298 + 299 + #[cfg(test)] 300 + mod tests { 301 + use super::*; 302 + 303 + #[test] 304 + fn parent_domains_walks_up() { 305 + assert_eq!( 306 + parent_domains("_lexicon.forum.example.com"), 307 + vec![ 308 + "_lexicon.forum.example.com", 309 + "forum.example.com", 310 + "example.com", 311 + "com", 312 + ] 313 + ); 314 + } 315 + 316 + #[test] 317 + fn split_domain_sld_tld() { 318 + assert_eq!(split_domain("example.com"), ("example", "com")); 319 + assert_eq!(split_domain("example.co.uk"), ("example", "co.uk")); 320 + } 321 + 322 + #[test] 323 + fn relative_name_handles_apex() { 324 + assert_eq!(relative_name("example.com", "example.com"), "@"); 325 + assert_eq!( 326 + relative_name("_lexicon.example.com", "example.com"), 327 + "_lexicon" 328 + ); 329 + } 330 + 331 + #[test] 332 + fn parse_hosts_reads_attributes() { 333 + let xml = r#" 334 + <?xml version="1.0"?> 335 + <ApiResponse Status="OK"> 336 + <CommandResponse> 337 + <DomainDNSGetHostsResult Domain="example.com"> 338 + <host HostId="1" Name="_lexicon" Type="TXT" Address="did=did:plc:abc" TTL="1800" /> 339 + <host HostId="2" Name="@" Type="A" Address="1.2.3.4" TTL="300" /> 340 + </DomainDNSGetHostsResult> 341 + </CommandResponse> 342 + </ApiResponse> 343 + "#; 344 + let hosts = parse_hosts(xml).unwrap(); 345 + assert_eq!(hosts.len(), 2); 346 + assert_eq!(hosts[0].name, "_lexicon"); 347 + assert_eq!(hosts[0].record_type, "TXT"); 348 + assert_eq!(hosts[0].address, "did=did:plc:abc"); 349 + assert_eq!(hosts[0].ttl, 1800); 350 + } 351 + 352 + #[test] 353 + fn extract_attrs_pulls_domain_names() { 354 + let xml = r#" 355 + <?xml version="1.0"?> 356 + <ApiResponse Status="OK"> 357 + <CommandResponse> 358 + <DomainGetListResult> 359 + <Domain Name="example.com" /> 360 + <Domain Name="example.net" /> 361 + </DomainGetListResult> 362 + </CommandResponse> 363 + </ApiResponse> 364 + "#; 365 + assert_eq!( 366 + extract_attrs(xml, "Domain", "Name"), 367 + vec!["example.com", "example.net"] 368 + ); 369 + } 370 + 371 + #[test] 372 + fn extract_error_message_from_error_block() { 373 + let xml = r#" 374 + <ApiResponse Status="ERROR"> 375 + <Errors> 376 + <Error Number="1010102">IP is not in the whitelist</Error> 377 + </Errors> 378 + </ApiResponse> 379 + "#; 380 + assert!(extract_error_message(xml).contains("whitelist")); 381 + } 382 + }
+314
dns-plugins/mlf-dns-namecheap/src/main.rs
··· 1 + //! Official MLF DNS provider plugin for Namecheap. 2 + //! 3 + //! Options schema: 4 + //! - `api_user` (secret, required) — Namecheap API username 5 + //! - `api_key` (secret, required) — Namecheap API key 6 + //! - `user_name` (secret, optional) — defaults to `api_user` 7 + //! - `client_ip` (non-secret, required) — caller's public IP; must be 8 + //! whitelisted in Namecheap's API access settings. 9 + //! 10 + //! Namecheap's setHosts endpoint REPLACES every DNS record on the 11 + //! domain, so every upsert/delete here does a getHosts + modify + 12 + //! setHosts round-trip. Don't mutate the record list anywhere outside 13 + //! this plugin between a get and a set for the same domain. 14 + 15 + mod api; 16 + 17 + use api::{Host, NamecheapClient, NamecheapError, relative_name}; 18 + use mlf_plugin_host::plugin::{Server, empty_data, params_as}; 19 + use mlf_plugin_host::protocol::{HelloData, OptionField, PROTOCOL_VERSION, Request}; 20 + use serde::{Deserialize, Serialize}; 21 + use serde_json::{Value, json}; 22 + 23 + #[tokio::main(flavor = "current_thread")] 24 + async fn main() -> std::io::Result<()> { 25 + let mut server = Server::stdio(); 26 + 27 + let identity = HelloData { 28 + name: "namecheap".into(), 29 + protocol_version: PROTOCOL_VERSION, 30 + kind: Some("dns".into()), 31 + capabilities: vec![ 32 + "login".into(), 33 + "list_txt".into(), 34 + "upsert_txt".into(), 35 + "delete_txt".into(), 36 + "resolve_zone".into(), 37 + ], 38 + options_schema: vec![ 39 + OptionField { 40 + name: "api_user".into(), 41 + label: "Namecheap API username".into(), 42 + help: Some( 43 + "Your Namecheap username. Enable API access and \ 44 + whitelist your IP at https://ap.www.namecheap.com/settings/tools/apiaccess/." 45 + .into(), 46 + ), 47 + secret: true, 48 + required: true, 49 + default: None, 50 + }, 51 + OptionField { 52 + name: "api_key".into(), 53 + label: "Namecheap API key".into(), 54 + help: None, 55 + secret: true, 56 + required: true, 57 + default: None, 58 + }, 59 + OptionField { 60 + name: "user_name".into(), 61 + label: "Namecheap user name (defaults to api_user)".into(), 62 + help: None, 63 + secret: true, 64 + required: false, 65 + default: None, 66 + }, 67 + OptionField { 68 + name: "client_ip".into(), 69 + label: "Your public IP (must be whitelisted on Namecheap)".into(), 70 + help: Some( 71 + "Namecheap rejects any call from an IP that isn't on the \ 72 + whitelist in the API-access settings page." 73 + .into(), 74 + ), 75 + secret: false, 76 + required: true, 77 + default: None, 78 + }, 79 + ], 80 + }; 81 + 82 + if server.handshake(identity).await.is_err() { 83 + return Ok(()); 84 + } 85 + 86 + let mut creds: Option<Credentials> = None; 87 + 88 + while let Ok(Some(req)) = server.next_request().await { 89 + if let Err(e) = dispatch(&mut server, &req, &mut creds).await { 90 + let _ = server.reply_err("internal", &e.to_string(), false).await; 91 + } 92 + } 93 + 94 + Ok(()) 95 + } 96 + 97 + #[derive(Debug, Clone, Serialize, Deserialize)] 98 + pub struct Credentials { 99 + pub api_user: String, 100 + pub api_key: String, 101 + #[serde(default)] 102 + pub user_name: Option<String>, 103 + pub client_ip: String, 104 + } 105 + 106 + #[derive(Debug, Deserialize)] 107 + struct InitParams { 108 + #[serde(default)] 109 + credentials: Option<Credentials>, 110 + } 111 + 112 + #[derive(Debug, Deserialize)] 113 + struct ResolveZoneParams { 114 + domain: String, 115 + } 116 + 117 + #[derive(Debug, Deserialize)] 118 + struct ListTxtParams { 119 + name: String, 120 + } 121 + 122 + #[derive(Debug, Deserialize)] 123 + struct UpsertTxtParams { 124 + name: String, 125 + value: String, 126 + #[serde(default)] 127 + ttl: Option<u32>, 128 + } 129 + 130 + #[derive(Debug, Deserialize)] 131 + struct DeleteTxtParams { 132 + name: String, 133 + #[allow(dead_code)] 134 + record_id: String, 135 + } 136 + 137 + #[derive(thiserror::Error, Debug)] 138 + enum DispatchError { 139 + #[error("{0}")] 140 + Plugin(#[from] mlf_plugin_host::plugin::PluginError), 141 + #[error("{0}")] 142 + Namecheap(#[from] NamecheapError), 143 + } 144 + 145 + async fn dispatch<W, R>( 146 + server: &mut Server<W, R>, 147 + req: &Request, 148 + creds: &mut Option<Credentials>, 149 + ) -> Result<(), DispatchError> 150 + where 151 + W: tokio::io::AsyncWrite + Unpin, 152 + R: tokio::io::AsyncBufReadExt + Unpin, 153 + { 154 + match req.op.as_str() { 155 + "init" => { 156 + let InitParams { credentials } = params_as(req)?; 157 + *creds = credentials; 158 + server.reply_ok(empty_data()).await?; 159 + } 160 + "login" => { 161 + let Some(c) = creds.as_ref() else { 162 + server 163 + .reply_err( 164 + "no_credentials", 165 + "login called before init set credentials", 166 + false, 167 + ) 168 + .await?; 169 + return Ok(()); 170 + }; 171 + match NamecheapClient::new(c).verify().await { 172 + Ok(name) => { 173 + server 174 + .reply_ok(json!({"credentials": c, "display_name": name})) 175 + .await?; 176 + } 177 + Err(e) => { 178 + server 179 + .reply_err("invalid_credentials", &e.to_string(), false) 180 + .await?; 181 + } 182 + } 183 + } 184 + "logout" => { 185 + *creds = None; 186 + server.reply_ok(empty_data()).await?; 187 + } 188 + "resolve_zone" => { 189 + let ResolveZoneParams { domain } = params_as(req)?; 190 + let c = require_creds(server, creds).await?; 191 + let client = NamecheapClient::new(&c); 192 + match client.find_zone_for(&domain).await? { 193 + Some(zone) => { 194 + server 195 + .reply_ok(json!({"zone_id": zone, "covered": true})) 196 + .await?; 197 + } 198 + None => { 199 + server 200 + .reply_ok(json!({"zone_id": Value::Null, "covered": false})) 201 + .await?; 202 + } 203 + } 204 + } 205 + "list_txt" => { 206 + let ListTxtParams { name } = params_as(req)?; 207 + let c = require_creds(server, creds).await?; 208 + let client = NamecheapClient::new(&c); 209 + let zone = match client.find_zone_for(&name).await? { 210 + Some(z) => z, 211 + None => { 212 + server 213 + .reply_err("unknown_zone", &format!("no zone covers {name}"), false) 214 + .await?; 215 + return Ok(()); 216 + } 217 + }; 218 + let rel = relative_name(&name, &zone); 219 + let hosts = client.get_hosts(&zone).await?; 220 + let matching: Vec<_> = hosts 221 + .iter() 222 + .filter(|h| h.record_type == "TXT" && h.name == rel) 223 + .map(|h| { 224 + json!({ 225 + "id": format!("{}/TXT/{}", zone, h.name), 226 + "value": h.address, 227 + }) 228 + }) 229 + .collect(); 230 + server.reply_ok(json!({ "records": matching })).await?; 231 + } 232 + "upsert_txt" => { 233 + let UpsertTxtParams { name, value, ttl } = params_as(req)?; 234 + let c = require_creds(server, creds).await?; 235 + let client = NamecheapClient::new(&c); 236 + let zone = match client.find_zone_for(&name).await? { 237 + Some(z) => z, 238 + None => { 239 + server 240 + .reply_err("unknown_zone", &format!("no zone covers {name}"), false) 241 + .await?; 242 + return Ok(()); 243 + } 244 + }; 245 + let rel = relative_name(&name, &zone); 246 + let mut hosts = client.get_hosts(&zone).await?; 247 + // Remove any existing TXT at (rel) — setHosts replaces the whole 248 + // zone, so we rebuild the list ourselves. 249 + hosts.retain(|h| !(h.record_type == "TXT" && h.name == rel)); 250 + hosts.push(Host { 251 + name: rel.clone(), 252 + record_type: "TXT".into(), 253 + address: value.clone(), 254 + ttl: ttl.unwrap_or(1800), 255 + mx_pref: None, 256 + }); 257 + client.set_hosts(&zone, &hosts).await?; 258 + server 259 + .reply_ok(json!({ "record_id": format!("{}/TXT/{}", zone, rel) })) 260 + .await?; 261 + } 262 + "delete_txt" => { 263 + let DeleteTxtParams { name, record_id: _ } = params_as(req)?; 264 + let c = require_creds(server, creds).await?; 265 + let client = NamecheapClient::new(&c); 266 + let zone = match client.find_zone_for(&name).await? { 267 + Some(z) => z, 268 + None => { 269 + server 270 + .reply_err("unknown_zone", &format!("no zone covers {name}"), false) 271 + .await?; 272 + return Ok(()); 273 + } 274 + }; 275 + let rel = relative_name(&name, &zone); 276 + let mut hosts = client.get_hosts(&zone).await?; 277 + let before = hosts.len(); 278 + hosts.retain(|h| !(h.record_type == "TXT" && h.name == rel)); 279 + if hosts.len() < before { 280 + client.set_hosts(&zone, &hosts).await?; 281 + } 282 + server.reply_ok(empty_data()).await?; 283 + } 284 + other => { 285 + server 286 + .reply_err("unknown_op", &format!("unsupported op `{other}`"), false) 287 + .await?; 288 + } 289 + } 290 + Ok(()) 291 + } 292 + 293 + async fn require_creds<W, R>( 294 + server: &mut Server<W, R>, 295 + creds: &Option<Credentials>, 296 + ) -> Result<Credentials, DispatchError> 297 + where 298 + W: tokio::io::AsyncWrite + Unpin, 299 + R: tokio::io::AsyncBufReadExt + Unpin, 300 + { 301 + if let Some(c) = creds.clone() { 302 + return Ok(c); 303 + } 304 + server 305 + .reply_err( 306 + "no_credentials", 307 + "host hasn't called init with credentials yet", 308 + false, 309 + ) 310 + .await?; 311 + Err(DispatchError::Plugin( 312 + mlf_plugin_host::plugin::PluginError::Unexpected("missing credentials".into()), 313 + )) 314 + }