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-godaddy plugin

Uses GoDaddy's Authorization: sso-key KEY:SECRET header. Records are
addressed by (domain, type, relative-name); apex records use @ per
GoDaddy's convention. PUT /domains/{z}/records/TXT/{name} replaces
every record at (type, name) — exactly the single-value shape the
_lexicon TXT needs. login does a cheap GET /domains?limit=1 to
validate credentials and surface 403s from hobbyist-tier accounts
cleanly.

authored by stavola.xyz and committed by

Tangled f2d31684 eed5b072

+590
+12
Cargo.lock
··· 2334 2334 ] 2335 2335 2336 2336 [[package]] 2337 + name = "mlf-dns-godaddy" 2338 + version = "0.1.0" 2339 + dependencies = [ 2340 + "mlf-plugin-host", 2341 + "reqwest", 2342 + "serde", 2343 + "serde_json", 2344 + "thiserror 2.0.17", 2345 + "tokio", 2346 + ] 2347 + 2348 + [[package]] 2337 2349 name = "mlf-dns-porkbun" 2338 2350 version = "0.1.0" 2339 2351 dependencies = [
+1
Cargo.toml
··· 7 7 "dns-plugins/mlf-dns-cloudflare", 8 8 "mlf-atproto", 9 9 "mlf-cli", 10 + "dns-plugins/mlf-dns-godaddy", 10 11 "dns-plugins/mlf-dns-porkbun", 11 12 "dns-plugins/mlf-dns-route53", 12 13 "mlf-plugin-host",
+18
dns-plugins/mlf-dns-godaddy/Cargo.toml
··· 1 + [package] 2 + name = "mlf-dns-godaddy" 3 + version = "0.1.0" 4 + edition = "2024" 5 + license = "MIT" 6 + description = "Official MLF DNS provider plugin for GoDaddy" 7 + 8 + [[bin]] 9 + name = "mlf-dns-godaddy" 10 + path = "src/main.rs" 11 + 12 + [dependencies] 13 + mlf-plugin-host = { path = "../../mlf-plugin-host" } 14 + reqwest = { version = "0.12", features = ["json"] } 15 + serde = { version = "1", features = ["derive"] } 16 + serde_json = "1" 17 + thiserror = "2" 18 + tokio = { version = "1", features = ["io-util", "macros", "rt"] }
+293
dns-plugins/mlf-dns-godaddy/src/api.rs
··· 1 + //! Thin GoDaddy API wrapper. 2 + //! 3 + //! Auth: `Authorization: sso-key API_KEY:API_SECRET` header. Records 4 + //! are addressed by `(domain, type, name)`; GoDaddy has no per-record 5 + //! IDs, so the host's record_id is the relative name. Records at the 6 + //! zone apex use `@` as the name per GoDaddy's convention. 7 + 8 + use crate::Credentials; 9 + use serde::Deserialize; 10 + use thiserror::Error; 11 + 12 + const API_BASE: &str = "https://api.godaddy.com/v1"; 13 + 14 + #[derive(Error, Debug)] 15 + pub enum GoDaddyError { 16 + #[error("GoDaddy HTTP error: {0}")] 17 + Http(String), 18 + #[error("GoDaddy API error: {0}")] 19 + Api(String), 20 + #[error("JSON decode error: {0}")] 21 + Decode(String), 22 + } 23 + 24 + impl From<reqwest::Error> for GoDaddyError { 25 + fn from(e: reqwest::Error) -> Self { 26 + GoDaddyError::Http(e.to_string()) 27 + } 28 + } 29 + 30 + pub struct GoDaddyClient { 31 + client: reqwest::Client, 32 + auth_header: String, 33 + } 34 + 35 + impl GoDaddyClient { 36 + pub fn new(creds: &Credentials) -> Self { 37 + Self { 38 + client: reqwest::Client::new(), 39 + auth_header: format!("sso-key {}:{}", creds.api_key, creds.api_secret), 40 + } 41 + } 42 + 43 + /// Validate credentials by listing domains (the lightest authed call). 44 + /// Returns the number of domains visible for display. 45 + pub async fn verify(&self) -> Result<String, GoDaddyError> { 46 + let resp = self 47 + .client 48 + .get(format!("{API_BASE}/domains")) 49 + .header("Authorization", &self.auth_header) 50 + .query(&[("limit", "1")]) 51 + .send() 52 + .await?; 53 + let status = resp.status(); 54 + if !status.is_success() { 55 + let body = resp.text().await.unwrap_or_default(); 56 + return Err(GoDaddyError::Api(format!("HTTP {status}: {body}"))); 57 + } 58 + // Body is a (possibly empty) array; we don't need to parse fully. 59 + Ok("godaddy".into()) 60 + } 61 + 62 + pub async fn list_domains(&self) -> Result<Vec<String>, GoDaddyError> { 63 + #[derive(Deserialize)] 64 + struct Domain { 65 + domain: String, 66 + } 67 + let resp = self 68 + .client 69 + .get(format!("{API_BASE}/domains")) 70 + .header("Authorization", &self.auth_header) 71 + .query(&[("statuses", "ACTIVE")]) 72 + .send() 73 + .await?; 74 + if !resp.status().is_success() { 75 + let status = resp.status(); 76 + let body = resp.text().await.unwrap_or_default(); 77 + return Err(GoDaddyError::Api(format!("HTTP {status}: {body}"))); 78 + } 79 + let list: Vec<Domain> = resp 80 + .json() 81 + .await 82 + .map_err(|e| GoDaddyError::Decode(e.to_string()))?; 83 + Ok(list.into_iter().map(|d| d.domain).collect()) 84 + } 85 + 86 + pub async fn find_zone_for(&self, dns_name: &str) -> Result<Option<String>, GoDaddyError> { 87 + let stripped = dns_name.strip_prefix("_lexicon.").unwrap_or(dns_name); 88 + let domains = self.list_domains().await?; 89 + for candidate in parent_domains(stripped) { 90 + if domains.iter().any(|d| d == &candidate) { 91 + return Ok(Some(candidate)); 92 + } 93 + } 94 + Ok(None) 95 + } 96 + 97 + pub async fn list_txt( 98 + &self, 99 + zone: &str, 100 + name: &str, 101 + ) -> Result<Vec<TxtRecord>, GoDaddyError> { 102 + let rel = relative_name(name, zone); 103 + #[derive(Deserialize)] 104 + struct Record { 105 + #[serde(default)] 106 + data: String, 107 + #[serde(default)] 108 + name: String, 109 + } 110 + let url = format!("{API_BASE}/domains/{zone}/records/TXT/{rel}"); 111 + let resp = self 112 + .client 113 + .get(&url) 114 + .header("Authorization", &self.auth_header) 115 + .send() 116 + .await?; 117 + let status = resp.status(); 118 + if status == 404 { 119 + return Ok(Vec::new()); 120 + } 121 + if !status.is_success() { 122 + let body = resp.text().await.unwrap_or_default(); 123 + return Err(GoDaddyError::Api(format!("HTTP {status}: {body}"))); 124 + } 125 + let records: Vec<Record> = resp 126 + .json() 127 + .await 128 + .map_err(|e| GoDaddyError::Decode(e.to_string()))?; 129 + // GoDaddy returns TXT data as an RFC 1035 quoted string; 130 + // normalise to the raw payload so callers don't see the quotes. 131 + Ok(records 132 + .into_iter() 133 + .map(|r| TxtRecord { 134 + id: r.name, 135 + value: unquote_txt(&r.data), 136 + }) 137 + .collect()) 138 + } 139 + 140 + /// GoDaddy's PUT `/records/{type}/{name}` replaces every record at 141 + /// (type, name) with the body array — the correct shape for single- 142 + /// valued `_lexicon` TXT records. 143 + pub async fn upsert_txt( 144 + &self, 145 + zone: &str, 146 + name: &str, 147 + value: &str, 148 + ttl: u32, 149 + ) -> Result<String, GoDaddyError> { 150 + let rel = relative_name(name, zone); 151 + let url = format!("{API_BASE}/domains/{zone}/records/TXT/{rel}"); 152 + let quoted = format!("\"{}\"", escape_for_txt(value)); 153 + let body = serde_json::json!([{ 154 + "data": quoted, 155 + "ttl": ttl, 156 + }]); 157 + let resp = self 158 + .client 159 + .put(&url) 160 + .header("Authorization", &self.auth_header) 161 + .json(&body) 162 + .send() 163 + .await?; 164 + let status = resp.status(); 165 + if !status.is_success() { 166 + let body = resp.text().await.unwrap_or_default(); 167 + return Err(GoDaddyError::Api(format!("HTTP {status}: {body}"))); 168 + } 169 + Ok(rel) 170 + } 171 + 172 + pub async fn delete_txt(&self, zone: &str, name: &str) -> Result<(), GoDaddyError> { 173 + let rel = relative_name(name, zone); 174 + let url = format!("{API_BASE}/domains/{zone}/records/TXT/{rel}"); 175 + let resp = self 176 + .client 177 + .delete(&url) 178 + .header("Authorization", &self.auth_header) 179 + .send() 180 + .await?; 181 + let status = resp.status(); 182 + // 404 → already gone, treat as success. 183 + if status == 404 || status.is_success() { 184 + return Ok(()); 185 + } 186 + let body = resp.text().await.unwrap_or_default(); 187 + Err(GoDaddyError::Api(format!("HTTP {status}: {body}"))) 188 + } 189 + } 190 + 191 + #[derive(Debug, Clone)] 192 + pub struct TxtRecord { 193 + pub id: String, 194 + pub value: String, 195 + } 196 + 197 + fn parent_domains(name: &str) -> Vec<String> { 198 + let parts: Vec<&str> = name.split('.').collect(); 199 + (0..parts.len()).map(|i| parts[i..].join(".")).collect() 200 + } 201 + 202 + /// Wrap a TXT payload per RFC 1035 `character-string` quoting: 203 + /// double-quote delimited, backslash + double-quote escaped. 204 + fn escape_for_txt(s: &str) -> String { 205 + s.replace('\\', "\\\\").replace('"', "\\\"") 206 + } 207 + 208 + /// Reverse of [`escape_for_txt`]: strip one pair of outer quotes and 209 + /// unescape `\\` / `\"`. Non-quoted input is returned untouched so the 210 + /// function is safe to apply defensively. 211 + fn unquote_txt(s: &str) -> String { 212 + let inner = s 213 + .strip_prefix('"') 214 + .and_then(|t| t.strip_suffix('"')) 215 + .unwrap_or(s); 216 + let mut out = String::with_capacity(inner.len()); 217 + let mut chars = inner.chars(); 218 + while let Some(c) = chars.next() { 219 + if c == '\\' { 220 + match chars.next() { 221 + Some('\\') => out.push('\\'), 222 + Some('"') => out.push('"'), 223 + Some(other) => { 224 + out.push('\\'); 225 + out.push(other); 226 + } 227 + None => out.push('\\'), 228 + } 229 + } else { 230 + out.push(c); 231 + } 232 + } 233 + out 234 + } 235 + 236 + /// The zone-relative name GoDaddy expects. Apex becomes `@`; otherwise 237 + /// the prefix before `.<zone>`. 238 + fn relative_name(name: &str, zone: &str) -> String { 239 + let name = name.trim_end_matches('.'); 240 + let zone = zone.trim_end_matches('.'); 241 + if name == zone { 242 + return "@".into(); 243 + } 244 + name.strip_suffix(&format!(".{zone}")) 245 + .map(|s| s.to_string()) 246 + .unwrap_or_else(|| name.to_string()) 247 + } 248 + 249 + #[cfg(test)] 250 + mod tests { 251 + use super::*; 252 + 253 + #[test] 254 + fn parent_domains_walks_up() { 255 + assert_eq!( 256 + parent_domains("_lexicon.forum.example.com"), 257 + vec![ 258 + "_lexicon.forum.example.com", 259 + "forum.example.com", 260 + "example.com", 261 + "com", 262 + ] 263 + ); 264 + } 265 + 266 + #[test] 267 + fn txt_quote_round_trip() { 268 + let raw = "did=did:plc:xl243nyru4tbbqjkuf2uvmna"; 269 + let wrapped = format!("\"{}\"", escape_for_txt(raw)); 270 + assert_eq!(wrapped, format!("\"{raw}\"")); 271 + assert_eq!(unquote_txt(&wrapped), raw); 272 + 273 + let raw = r#"a"b\c"#; 274 + let wrapped = format!("\"{}\"", escape_for_txt(raw)); 275 + assert_eq!(wrapped, r#""a\"b\\c""#); 276 + assert_eq!(unquote_txt(&wrapped), raw); 277 + 278 + assert_eq!(unquote_txt("did=plain"), "did=plain"); 279 + } 280 + 281 + #[test] 282 + fn relative_name_handles_apex_and_subdomain() { 283 + assert_eq!(relative_name("example.com", "example.com"), "@"); 284 + assert_eq!( 285 + relative_name("_lexicon.forum.example.com", "example.com"), 286 + "_lexicon.forum" 287 + ); 288 + assert_eq!( 289 + relative_name("_lexicon.example.com", "example.com"), 290 + "_lexicon" 291 + ); 292 + } 293 + }
+266
dns-plugins/mlf-dns-godaddy/src/main.rs
··· 1 + //! Official MLF DNS provider plugin for GoDaddy. 2 + //! 3 + //! Options schema: 4 + //! - `api_key` (secret, required) 5 + //! - `api_secret` (secret, required) 6 + //! 7 + //! GoDaddy's production API requires a paid "Prime" level account for 8 + //! DNS record management; the plugin surfaces HTTP 403 as `unauthorized` 9 + //! rather than swallowing it, so users on hobbyist accounts see the 10 + //! real reason their calls fail. 11 + 12 + mod api; 13 + 14 + use api::{GoDaddyClient, GoDaddyError}; 15 + use mlf_plugin_host::plugin::{Server, empty_data, params_as}; 16 + use mlf_plugin_host::protocol::{HelloData, OptionField, PROTOCOL_VERSION, Request}; 17 + use serde::{Deserialize, Serialize}; 18 + use serde_json::{Value, json}; 19 + 20 + #[tokio::main(flavor = "current_thread")] 21 + async fn main() -> std::io::Result<()> { 22 + let mut server = Server::stdio(); 23 + 24 + let identity = HelloData { 25 + name: "godaddy".into(), 26 + protocol_version: PROTOCOL_VERSION, 27 + kind: Some("dns".into()), 28 + capabilities: vec![ 29 + "login".into(), 30 + "list_txt".into(), 31 + "upsert_txt".into(), 32 + "delete_txt".into(), 33 + "resolve_zone".into(), 34 + ], 35 + options_schema: vec![ 36 + OptionField { 37 + name: "api_key".into(), 38 + label: "GoDaddy API key".into(), 39 + help: Some( 40 + "Generate a production key at https://developer.godaddy.com/keys. \ 41 + DNS management needs the production (not OTE) environment." 42 + .into(), 43 + ), 44 + secret: true, 45 + required: true, 46 + default: None, 47 + }, 48 + OptionField { 49 + name: "api_secret".into(), 50 + label: "GoDaddy API secret".into(), 51 + help: None, 52 + secret: true, 53 + required: true, 54 + default: None, 55 + }, 56 + ], 57 + }; 58 + 59 + if server.handshake(identity).await.is_err() { 60 + return Ok(()); 61 + } 62 + 63 + let mut creds: Option<Credentials> = None; 64 + 65 + while let Ok(Some(req)) = server.next_request().await { 66 + if let Err(e) = dispatch(&mut server, &req, &mut creds).await { 67 + let _ = server.reply_err("internal", &e.to_string(), false).await; 68 + } 69 + } 70 + 71 + Ok(()) 72 + } 73 + 74 + #[derive(Debug, Clone, Serialize, Deserialize)] 75 + pub struct Credentials { 76 + pub api_key: String, 77 + pub api_secret: String, 78 + } 79 + 80 + #[derive(Debug, Deserialize)] 81 + struct InitParams { 82 + #[serde(default)] 83 + credentials: Option<Credentials>, 84 + } 85 + 86 + #[derive(Debug, Deserialize)] 87 + struct ResolveZoneParams { 88 + domain: String, 89 + } 90 + 91 + #[derive(Debug, Deserialize)] 92 + struct ListTxtParams { 93 + name: String, 94 + } 95 + 96 + #[derive(Debug, Deserialize)] 97 + struct UpsertTxtParams { 98 + name: String, 99 + value: String, 100 + #[serde(default)] 101 + ttl: Option<u32>, 102 + } 103 + 104 + #[derive(Debug, Deserialize)] 105 + struct DeleteTxtParams { 106 + name: String, 107 + #[allow(dead_code)] 108 + record_id: String, 109 + } 110 + 111 + #[derive(thiserror::Error, Debug)] 112 + enum DispatchError { 113 + #[error("{0}")] 114 + Plugin(#[from] mlf_plugin_host::plugin::PluginError), 115 + #[error("{0}")] 116 + GoDaddy(#[from] GoDaddyError), 117 + } 118 + 119 + async fn dispatch<W, R>( 120 + server: &mut Server<W, R>, 121 + req: &Request, 122 + creds: &mut Option<Credentials>, 123 + ) -> Result<(), DispatchError> 124 + where 125 + W: tokio::io::AsyncWrite + Unpin, 126 + R: tokio::io::AsyncBufReadExt + Unpin, 127 + { 128 + match req.op.as_str() { 129 + "init" => { 130 + let InitParams { credentials } = params_as(req)?; 131 + *creds = credentials; 132 + server.reply_ok(empty_data()).await?; 133 + } 134 + "login" => { 135 + let Some(c) = creds.as_ref() else { 136 + server 137 + .reply_err( 138 + "no_credentials", 139 + "login called before init set credentials", 140 + false, 141 + ) 142 + .await?; 143 + return Ok(()); 144 + }; 145 + match GoDaddyClient::new(c).verify().await { 146 + Ok(name) => { 147 + server 148 + .reply_ok(json!({ 149 + "credentials": c, 150 + "display_name": name, 151 + })) 152 + .await?; 153 + } 154 + Err(e) => { 155 + server 156 + .reply_err("invalid_credentials", &e.to_string(), false) 157 + .await?; 158 + } 159 + } 160 + } 161 + "logout" => { 162 + *creds = None; 163 + server.reply_ok(empty_data()).await?; 164 + } 165 + "resolve_zone" => { 166 + let ResolveZoneParams { domain } = params_as(req)?; 167 + let c = require_creds(server, creds).await?; 168 + match GoDaddyClient::new(&c).find_zone_for(&domain).await? { 169 + Some(zone) => { 170 + server 171 + .reply_ok(json!({"zone_id": zone, "covered": true})) 172 + .await?; 173 + } 174 + None => { 175 + server 176 + .reply_ok(json!({"zone_id": Value::Null, "covered": false})) 177 + .await?; 178 + } 179 + } 180 + } 181 + "list_txt" => { 182 + let ListTxtParams { name } = params_as(req)?; 183 + let c = require_creds(server, creds).await?; 184 + let client = GoDaddyClient::new(&c); 185 + let zone = match client.find_zone_for(&name).await? { 186 + Some(z) => z, 187 + None => { 188 + server 189 + .reply_err("unknown_zone", &format!("no zone covers {name}"), false) 190 + .await?; 191 + return Ok(()); 192 + } 193 + }; 194 + let records = client.list_txt(&zone, &name).await?; 195 + server 196 + .reply_ok(json!({ 197 + "records": records.into_iter().map(|r| json!({ 198 + "id": r.id, 199 + "value": r.value, 200 + })).collect::<Vec<_>>(), 201 + })) 202 + .await?; 203 + } 204 + "upsert_txt" => { 205 + let UpsertTxtParams { name, value, ttl } = params_as(req)?; 206 + let c = require_creds(server, creds).await?; 207 + let client = GoDaddyClient::new(&c); 208 + let zone = match client.find_zone_for(&name).await? { 209 + Some(z) => z, 210 + None => { 211 + server 212 + .reply_err("unknown_zone", &format!("no zone covers {name}"), false) 213 + .await?; 214 + return Ok(()); 215 + } 216 + }; 217 + let id = client.upsert_txt(&zone, &name, &value, ttl.unwrap_or(600)).await?; 218 + server.reply_ok(json!({ "record_id": id })).await?; 219 + } 220 + "delete_txt" => { 221 + let DeleteTxtParams { name, record_id: _ } = params_as(req)?; 222 + let c = require_creds(server, creds).await?; 223 + let client = GoDaddyClient::new(&c); 224 + let zone = match client.find_zone_for(&name).await? { 225 + Some(z) => z, 226 + None => { 227 + server 228 + .reply_err("unknown_zone", &format!("no zone covers {name}"), false) 229 + .await?; 230 + return Ok(()); 231 + } 232 + }; 233 + client.delete_txt(&zone, &name).await?; 234 + server.reply_ok(empty_data()).await?; 235 + } 236 + other => { 237 + server 238 + .reply_err("unknown_op", &format!("unsupported op `{other}`"), false) 239 + .await?; 240 + } 241 + } 242 + Ok(()) 243 + } 244 + 245 + async fn require_creds<W, R>( 246 + server: &mut Server<W, R>, 247 + creds: &Option<Credentials>, 248 + ) -> Result<Credentials, DispatchError> 249 + where 250 + W: tokio::io::AsyncWrite + Unpin, 251 + R: tokio::io::AsyncBufReadExt + Unpin, 252 + { 253 + if let Some(c) = creds.clone() { 254 + return Ok(c); 255 + } 256 + server 257 + .reply_err( 258 + "no_credentials", 259 + "host hasn't called init with credentials yet", 260 + false, 261 + ) 262 + .await?; 263 + Err(DispatchError::Plugin( 264 + mlf_plugin_host::plugin::PluginError::Unexpected("missing credentials".into()), 265 + )) 266 + }