From f2d316843d84ee354634e41d2fed2094e3c4de81 Mon Sep 17 00:00:00 2001 From: Matt Stavola Date: Fri, 17 Apr 2026 21:34:35 -0400 Subject: [PATCH] Add mlf-dns-godaddy plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 12 + Cargo.toml | 1 + dns-plugins/mlf-dns-godaddy/Cargo.toml | 18 ++ dns-plugins/mlf-dns-godaddy/src/api.rs | 293 ++++++++++++++++++++++++ dns-plugins/mlf-dns-godaddy/src/main.rs | 266 +++++++++++++++++++++ 5 files changed, 590 insertions(+) create mode 100644 dns-plugins/mlf-dns-godaddy/Cargo.toml create mode 100644 dns-plugins/mlf-dns-godaddy/src/api.rs create mode 100644 dns-plugins/mlf-dns-godaddy/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 19c92b6..4b13493 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2333,6 +2333,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "mlf-dns-godaddy" +version = "0.1.0" +dependencies = [ + "mlf-plugin-host", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "mlf-dns-porkbun" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3c3ecd0..c87e02b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "dns-plugins/mlf-dns-cloudflare", "mlf-atproto", "mlf-cli", + "dns-plugins/mlf-dns-godaddy", "dns-plugins/mlf-dns-porkbun", "dns-plugins/mlf-dns-route53", "mlf-plugin-host", diff --git a/dns-plugins/mlf-dns-godaddy/Cargo.toml b/dns-plugins/mlf-dns-godaddy/Cargo.toml new file mode 100644 index 0000000..8e08b6f --- /dev/null +++ b/dns-plugins/mlf-dns-godaddy/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "mlf-dns-godaddy" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Official MLF DNS provider plugin for GoDaddy" + +[[bin]] +name = "mlf-dns-godaddy" +path = "src/main.rs" + +[dependencies] +mlf-plugin-host = { path = "../../mlf-plugin-host" } +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-godaddy/src/api.rs b/dns-plugins/mlf-dns-godaddy/src/api.rs new file mode 100644 index 0000000..13d66cf --- /dev/null +++ b/dns-plugins/mlf-dns-godaddy/src/api.rs @@ -0,0 +1,293 @@ +//! Thin GoDaddy API wrapper. +//! +//! Auth: `Authorization: sso-key API_KEY:API_SECRET` header. Records +//! are addressed by `(domain, type, name)`; GoDaddy has no per-record +//! IDs, so the host's record_id is the relative name. Records at the +//! zone apex use `@` as the name per GoDaddy's convention. + +use crate::Credentials; +use serde::Deserialize; +use thiserror::Error; + +const API_BASE: &str = "https://api.godaddy.com/v1"; + +#[derive(Error, Debug)] +pub enum GoDaddyError { + #[error("GoDaddy HTTP error: {0}")] + Http(String), + #[error("GoDaddy API error: {0}")] + Api(String), + #[error("JSON decode error: {0}")] + Decode(String), +} + +impl From for GoDaddyError { + fn from(e: reqwest::Error) -> Self { + GoDaddyError::Http(e.to_string()) + } +} + +pub struct GoDaddyClient { + client: reqwest::Client, + auth_header: String, +} + +impl GoDaddyClient { + pub fn new(creds: &Credentials) -> Self { + Self { + client: reqwest::Client::new(), + auth_header: format!("sso-key {}:{}", creds.api_key, creds.api_secret), + } + } + + /// Validate credentials by listing domains (the lightest authed call). + /// Returns the number of domains visible for display. + pub async fn verify(&self) -> Result { + let resp = self + .client + .get(format!("{API_BASE}/domains")) + .header("Authorization", &self.auth_header) + .query(&[("limit", "1")]) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(GoDaddyError::Api(format!("HTTP {status}: {body}"))); + } + // Body is a (possibly empty) array; we don't need to parse fully. + Ok("godaddy".into()) + } + + pub async fn list_domains(&self) -> Result, GoDaddyError> { + #[derive(Deserialize)] + struct Domain { + domain: String, + } + let resp = self + .client + .get(format!("{API_BASE}/domains")) + .header("Authorization", &self.auth_header) + .query(&[("statuses", "ACTIVE")]) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(GoDaddyError::Api(format!("HTTP {status}: {body}"))); + } + let list: Vec = resp + .json() + .await + .map_err(|e| GoDaddyError::Decode(e.to_string()))?; + Ok(list.into_iter().map(|d| d.domain).collect()) + } + + pub async fn find_zone_for(&self, dns_name: &str) -> Result, GoDaddyError> { + 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 == &candidate) { + return Ok(Some(candidate)); + } + } + Ok(None) + } + + pub async fn list_txt( + &self, + zone: &str, + name: &str, + ) -> Result, GoDaddyError> { + let rel = relative_name(name, zone); + #[derive(Deserialize)] + struct Record { + #[serde(default)] + data: String, + #[serde(default)] + name: String, + } + let url = format!("{API_BASE}/domains/{zone}/records/TXT/{rel}"); + let resp = self + .client + .get(&url) + .header("Authorization", &self.auth_header) + .send() + .await?; + let status = resp.status(); + if status == 404 { + return Ok(Vec::new()); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(GoDaddyError::Api(format!("HTTP {status}: {body}"))); + } + let records: Vec = resp + .json() + .await + .map_err(|e| GoDaddyError::Decode(e.to_string()))?; + // GoDaddy returns TXT data as an RFC 1035 quoted string; + // normalise to the raw payload so callers don't see the quotes. + Ok(records + .into_iter() + .map(|r| TxtRecord { + id: r.name, + value: unquote_txt(&r.data), + }) + .collect()) + } + + /// GoDaddy's PUT `/records/{type}/{name}` replaces every record at + /// (type, name) with the body array — the correct shape for single- + /// valued `_lexicon` TXT records. + pub async fn upsert_txt( + &self, + zone: &str, + name: &str, + value: &str, + ttl: u32, + ) -> Result { + let rel = relative_name(name, zone); + let url = format!("{API_BASE}/domains/{zone}/records/TXT/{rel}"); + let quoted = format!("\"{}\"", escape_for_txt(value)); + let body = serde_json::json!([{ + "data": quoted, + "ttl": ttl, + }]); + let resp = self + .client + .put(&url) + .header("Authorization", &self.auth_header) + .json(&body) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(GoDaddyError::Api(format!("HTTP {status}: {body}"))); + } + Ok(rel) + } + + pub async fn delete_txt(&self, zone: &str, name: &str) -> Result<(), GoDaddyError> { + let rel = relative_name(name, zone); + let url = format!("{API_BASE}/domains/{zone}/records/TXT/{rel}"); + let resp = self + .client + .delete(&url) + .header("Authorization", &self.auth_header) + .send() + .await?; + let status = resp.status(); + // 404 → already gone, treat as success. + if status == 404 || status.is_success() { + return Ok(()); + } + let body = resp.text().await.unwrap_or_default(); + Err(GoDaddyError::Api(format!("HTTP {status}: {body}"))) + } +} + +#[derive(Debug, Clone)] +pub struct TxtRecord { + pub id: String, + pub value: String, +} + +fn parent_domains(name: &str) -> Vec { + let parts: Vec<&str> = name.split('.').collect(); + (0..parts.len()).map(|i| parts[i..].join(".")).collect() +} + +/// Wrap a TXT payload per RFC 1035 `character-string` quoting: +/// double-quote delimited, backslash + double-quote escaped. +fn escape_for_txt(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +/// Reverse of [`escape_for_txt`]: strip one pair of outer quotes and +/// unescape `\\` / `\"`. Non-quoted input is returned untouched so the +/// function is safe to apply defensively. +fn unquote_txt(s: &str) -> String { + let inner = s + .strip_prefix('"') + .and_then(|t| t.strip_suffix('"')) + .unwrap_or(s); + let mut out = String::with_capacity(inner.len()); + let mut chars = inner.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('\\') => out.push('\\'), + Some('"') => out.push('"'), + Some(other) => { + out.push('\\'); + out.push(other); + } + None => out.push('\\'), + } + } else { + out.push(c); + } + } + out +} + +/// The zone-relative name GoDaddy expects. Apex becomes `@`; otherwise +/// the prefix before `.`. +fn relative_name(name: &str, zone: &str) -> String { + let name = name.trim_end_matches('.'); + let zone = zone.trim_end_matches('.'); + if name == 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 txt_quote_round_trip() { + let raw = "did=did:plc:xl243nyru4tbbqjkuf2uvmna"; + let wrapped = format!("\"{}\"", escape_for_txt(raw)); + assert_eq!(wrapped, format!("\"{raw}\"")); + assert_eq!(unquote_txt(&wrapped), raw); + + let raw = r#"a"b\c"#; + let wrapped = format!("\"{}\"", escape_for_txt(raw)); + assert_eq!(wrapped, r#""a\"b\\c""#); + assert_eq!(unquote_txt(&wrapped), raw); + + assert_eq!(unquote_txt("did=plain"), "did=plain"); + } + + #[test] + fn relative_name_handles_apex_and_subdomain() { + assert_eq!(relative_name("example.com", "example.com"), "@"); + assert_eq!( + relative_name("_lexicon.forum.example.com", "example.com"), + "_lexicon.forum" + ); + assert_eq!( + relative_name("_lexicon.example.com", "example.com"), + "_lexicon" + ); + } +} diff --git a/dns-plugins/mlf-dns-godaddy/src/main.rs b/dns-plugins/mlf-dns-godaddy/src/main.rs new file mode 100644 index 0000000..abdd94a --- /dev/null +++ b/dns-plugins/mlf-dns-godaddy/src/main.rs @@ -0,0 +1,266 @@ +//! Official MLF DNS provider plugin for GoDaddy. +//! +//! Options schema: +//! - `api_key` (secret, required) +//! - `api_secret` (secret, required) +//! +//! GoDaddy's production API requires a paid "Prime" level account for +//! DNS record management; the plugin surfaces HTTP 403 as `unauthorized` +//! rather than swallowing it, so users on hobbyist accounts see the +//! real reason their calls fail. + +mod api; + +use api::{GoDaddyClient, GoDaddyError}; +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: "godaddy".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_key".into(), + label: "GoDaddy API key".into(), + help: Some( + "Generate a production key at https://developer.godaddy.com/keys. \ + DNS management needs the production (not OTE) environment." + .into(), + ), + secret: true, + required: true, + default: None, + }, + OptionField { + name: "api_secret".into(), + label: "GoDaddy API secret".into(), + help: None, + secret: true, + 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_key: String, + pub api_secret: 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}")] + GoDaddy(#[from] GoDaddyError), +} + +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 GoDaddyClient::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?; + match GoDaddyClient::new(&c).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 = GoDaddyClient::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 records = client.list_txt(&zone, &name).await?; + server + .reply_ok(json!({ + "records": records.into_iter().map(|r| json!({ + "id": r.id, + "value": r.value, + })).collect::>(), + })) + .await?; + } + "upsert_txt" => { + let UpsertTxtParams { name, value, ttl } = params_as(req)?; + let c = require_creds(server, creds).await?; + let client = GoDaddyClient::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 id = client.upsert_txt(&zone, &name, &value, ttl.unwrap_or(600)).await?; + server.reply_ok(json!({ "record_id": id })).await?; + } + "delete_txt" => { + let DeleteTxtParams { name, record_id: _ } = params_as(req)?; + let c = require_creds(server, creds).await?; + let client = GoDaddyClient::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(()); + } + }; + client.delete_txt(&zone, &name).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