From eed5b07210a9cd6718227d56fd32267954713fc4 Mon Sep 17 00:00:00 2001 From: Matt Stavola Date: Fri, 17 Apr 2026 21:32:43 -0400 Subject: [PATCH] Add mlf-dns-porkbun plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Porkbun's API is POST-only with apikey + secretapikey carried in the request body. Zone lookup walks parent labels against /domain/listAll since Porkbun has no explicit "which zone covers this name" endpoint. TXT ops use the retrieveByNameType / editByNameType / deleteByNameType variants — editByNameType replaces every record at (subdomain, TXT) in one call, which matches the single-value _lexicon shape exactly. login pings /ping to verify the credentials and report the caller's whitelisted IP. --- Cargo.lock | 12 + Cargo.toml | 1 + dns-plugins/mlf-dns-porkbun/Cargo.toml | 18 ++ dns-plugins/mlf-dns-porkbun/src/api.rs | 385 ++++++++++++++++++++++++ dns-plugins/mlf-dns-porkbun/src/main.rs | 266 ++++++++++++++++ 5 files changed, 682 insertions(+) create mode 100644 dns-plugins/mlf-dns-porkbun/Cargo.toml create mode 100644 dns-plugins/mlf-dns-porkbun/src/api.rs create mode 100644 dns-plugins/mlf-dns-porkbun/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 104e5bc..19c92b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2333,6 +2333,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "mlf-dns-porkbun" +version = "0.1.0" +dependencies = [ + "mlf-plugin-host", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "mlf-dns-route53" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5d2265f..3c3ecd0 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-porkbun", "dns-plugins/mlf-dns-route53", "mlf-plugin-host", "mlf-publish", diff --git a/dns-plugins/mlf-dns-porkbun/Cargo.toml b/dns-plugins/mlf-dns-porkbun/Cargo.toml new file mode 100644 index 0000000..7fa1bff --- /dev/null +++ b/dns-plugins/mlf-dns-porkbun/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "mlf-dns-porkbun" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Official MLF DNS provider plugin for Porkbun" + +[[bin]] +name = "mlf-dns-porkbun" +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-porkbun/src/api.rs b/dns-plugins/mlf-dns-porkbun/src/api.rs new file mode 100644 index 0000000..143473e --- /dev/null +++ b/dns-plugins/mlf-dns-porkbun/src/api.rs @@ -0,0 +1,385 @@ +//! Thin Porkbun API wrapper. +//! +//! Porkbun's API is POST-only; `apikey` + `secretapikey` ride along in +//! every request body. Records are addressed by a numeric ID +//! per-subdomain+type, so we use the "by name + type" endpoints for +//! edit/delete and the record list for reading. + +use crate::Credentials; +use serde::Deserialize; +use serde_json::{Value, json}; +use thiserror::Error; + +const API_BASE: &str = "https://api.porkbun.com/api/json/v3"; + +#[derive(Error, Debug)] +pub enum PorkbunError { + #[error("Porkbun HTTP error: {0}")] + Http(String), + #[error("Porkbun API error: {0}")] + Api(String), + #[error("JSON decode error: {0}")] + Decode(String), +} + +impl From for PorkbunError { + fn from(e: reqwest::Error) -> Self { + PorkbunError::Http(e.to_string()) + } +} + +pub struct PorkbunClient { + client: reqwest::Client, + apikey: String, + secretapikey: String, +} + +impl PorkbunClient { + pub fn new(creds: &Credentials) -> Self { + Self { + client: reqwest::Client::new(), + apikey: creds.api_key.clone(), + secretapikey: creds.secret_key.clone(), + } + } + + fn auth_body(&self) -> Value { + json!({ + "apikey": self.apikey, + "secretapikey": self.secretapikey, + }) + } + + /// Validate credentials via `/ping`. Returns the caller's IPv4/6 + /// address (Porkbun's whitelist check), or an error if auth fails. + pub async fn ping(&self) -> Result { + #[derive(Deserialize)] + struct Resp { + status: String, + #[serde(default)] + message: Option, + #[serde(default, rename = "yourIp")] + your_ip: Option, + } + let resp: Resp = self + .client + .post(format!("{API_BASE}/ping")) + .json(&self.auth_body()) + .send() + .await? + .json() + .await + .map_err(|e| PorkbunError::Decode(e.to_string()))?; + if resp.status != "SUCCESS" { + return Err(PorkbunError::Api( + resp.message.unwrap_or_else(|| "auth failed".into()), + )); + } + Ok(resp.your_ip.unwrap_or_else(|| "porkbun".into())) + } + + /// Return the registered domains on the account. Used as the set of + /// candidate zones when resolving which zone covers a DNS name. + pub async fn list_domains(&self) -> Result, PorkbunError> { + #[derive(Deserialize)] + struct Resp { + status: String, + #[serde(default)] + message: Option, + #[serde(default)] + domains: Vec, + } + #[derive(Deserialize)] + struct Domain { + domain: String, + } + let resp: Resp = self + .client + .post(format!("{API_BASE}/domain/listAll")) + .json(&self.auth_body()) + .send() + .await? + .json() + .await + .map_err(|e| PorkbunError::Decode(e.to_string()))?; + if resp.status != "SUCCESS" { + return Err(PorkbunError::Api( + resp.message.unwrap_or_else(|| "listAll failed".into()), + )); + } + Ok(resp.domains.into_iter().map(|d| d.domain).collect()) + } + + /// Find the domain under the account that covers `dns_name` by walking + /// parent labels. Returns the domain (zone) name. + pub async fn find_zone_for(&self, dns_name: &str) -> Result, PorkbunError> { + 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) + } + + /// List TXT records at `name`. Porkbun's `retrieveByNameType` wants + /// the *subdomain* relative to the zone — i.e. everything before + /// the zone's label, empty for the zone root. + pub async fn list_txt( + &self, + zone: &str, + name: &str, + ) -> Result, PorkbunError> { + let subdomain = subdomain_for(name, zone); + #[derive(Deserialize)] + struct Resp { + status: String, + #[serde(default)] + message: Option, + #[serde(default)] + records: Vec, + } + #[derive(Deserialize)] + struct Record { + id: String, + content: String, + } + let url = format!( + "{API_BASE}/dns/retrieveByNameType/{zone}/TXT/{subdomain}", + subdomain = subdomain + ); + let resp: Resp = self + .client + .post(&url) + .json(&self.auth_body()) + .send() + .await? + .json() + .await + .map_err(|e| PorkbunError::Decode(e.to_string()))?; + if resp.status != "SUCCESS" { + return Err(PorkbunError::Api( + resp.message.unwrap_or_else(|| "retrieveByNameType failed".into()), + )); + } + // Porkbun returns TXT content as an RFC 1035 quoted string + // (e.g. `"did=..."`); normalise to the raw payload. + Ok(resp + .records + .into_iter() + .map(|r| TxtRecord { + id: r.id, + content: unquote_txt(&r.content), + }) + .collect()) + } + + /// Upsert TXT via `editByNameType` (replace the single-record slot) + /// if it exists, else `create`. Porkbun's "edit by name+type" + /// replaces every record at (subdomain, type) with one value — + /// exactly what we want for the single `_lexicon` TXT case. + pub async fn upsert_txt( + &self, + zone: &str, + name: &str, + value: &str, + ttl: u32, + ) -> Result { + let subdomain = subdomain_for(name, zone); + let quoted = format!("\"{}\"", escape_for_txt(value)); + let existing = self.list_txt(zone, name).await?; + if existing.is_empty() { + let create_body = json!({ + "apikey": self.apikey, + "secretapikey": self.secretapikey, + "name": subdomain, + "type": "TXT", + "content": quoted, + "ttl": ttl.to_string(), + }); + let url = format!("{API_BASE}/dns/create/{zone}"); + let resp = self + .client + .post(&url) + .json(&create_body) + .send() + .await? + .json::() + .await + .map_err(|e| PorkbunError::Decode(e.to_string()))?; + if resp["status"] != "SUCCESS" { + return Err(PorkbunError::Api( + resp["message"] + .as_str() + .unwrap_or("create failed") + .to_string(), + )); + } + Ok(resp["id"].to_string()) + } else { + // editByNameType replaces every TXT at (subdomain, type) with + // one value — exactly the shape we want for `_lexicon` TXT. + let url = format!("{API_BASE}/dns/editByNameType/{zone}/TXT/{subdomain}"); + let edit_body = json!({ + "apikey": self.apikey, + "secretapikey": self.secretapikey, + "content": quoted, + "ttl": ttl.to_string(), + }); + let resp = self + .client + .post(&url) + .json(&edit_body) + .send() + .await? + .json::() + .await + .map_err(|e| PorkbunError::Decode(e.to_string()))?; + if resp["status"] != "SUCCESS" { + return Err(PorkbunError::Api( + resp["message"] + .as_str() + .unwrap_or("edit failed") + .to_string(), + )); + } + // editByNameType doesn't return an id; reuse the first existing one. + Ok(existing[0].id.clone()) + } + } + + pub async fn delete_txt(&self, zone: &str, name: &str) -> Result<(), PorkbunError> { + let subdomain = subdomain_for(name, zone); + let url = format!( + "{API_BASE}/dns/deleteByNameType/{zone}/TXT/{subdomain}", + subdomain = subdomain + ); + let resp = self + .client + .post(&url) + .json(&self.auth_body()) + .send() + .await? + .json::() + .await + .map_err(|e| PorkbunError::Decode(e.to_string()))?; + if resp["status"] != "SUCCESS" { + return Err(PorkbunError::Api( + resp["message"] + .as_str() + .unwrap_or("delete failed") + .to_string(), + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct TxtRecord { + pub id: String, + pub content: 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 +} + +/// Subdomain label for a fully-qualified `name` relative to a `zone`. +/// For `_lexicon.forum.example.com` + `example.com` → `_lexicon.forum`. +/// For `example.com` + `example.com` → empty string (zone root). +fn subdomain_for(name: &str, zone: &str) -> String { + let name = name.trim_end_matches('.'); + let zone = zone.trim_end_matches('.'); + if name == zone { + return String::new(); + } + 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 subdomain_for_strips_zone() { + assert_eq!( + subdomain_for("_lexicon.forum.example.com", "example.com"), + "_lexicon.forum" + ); + assert_eq!( + subdomain_for("example.com", "example.com"), + "" + ); + assert_eq!( + subdomain_for("_lexicon.example.com", "example.com"), + "_lexicon" + ); + } +} diff --git a/dns-plugins/mlf-dns-porkbun/src/main.rs b/dns-plugins/mlf-dns-porkbun/src/main.rs new file mode 100644 index 0000000..e2c7746 --- /dev/null +++ b/dns-plugins/mlf-dns-porkbun/src/main.rs @@ -0,0 +1,266 @@ +//! Official MLF DNS provider plugin for Porkbun. +//! +//! Options schema: +//! - `api_key` (secret, required) — Porkbun API key +//! - `secret_key` (secret, required) — Porkbun API secret +//! +//! Porkbun requires API access to be toggled on per-domain in the +//! dashboard; the plugin surfaces a clear error if a call hits a +//! domain without API access enabled. + +mod api; + +use api::{PorkbunClient, PorkbunError}; +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: "porkbun".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: "Porkbun API key".into(), + help: Some( + "Generate at https://porkbun.com/account/api. The domain must \ + have API access enabled under its settings tab." + .into(), + ), + secret: true, + required: true, + default: None, + }, + OptionField { + name: "secret_key".into(), + label: "Porkbun 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 secret_key: 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}")] + Porkbun(#[from] PorkbunError), +} + +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 PorkbunClient::new(c).ping().await { + Ok(ip) => { + server + .reply_ok(json!({ + "credentials": c, + "display_name": format!("porkbun ({ip})"), + })) + .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 = PorkbunClient::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 = PorkbunClient::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.content, + })).collect::>(), + })) + .await?; + } + "upsert_txt" => { + let UpsertTxtParams { name, value, ttl } = params_as(req)?; + let c = require_creds(server, creds).await?; + let client = PorkbunClient::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 = PorkbunClient::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