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

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.

authored by stavola.xyz and committed by

Tangled eed5b072 a2fcbfd2

+682
+12
Cargo.lock
··· 2334 2334 ] 2335 2335 2336 2336 [[package]] 2337 + name = "mlf-dns-porkbun" 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-route53" 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-porkbun", 10 11 "dns-plugins/mlf-dns-route53", 11 12 "mlf-plugin-host", 12 13 "mlf-publish",
+18
dns-plugins/mlf-dns-porkbun/Cargo.toml
··· 1 + [package] 2 + name = "mlf-dns-porkbun" 3 + version = "0.1.0" 4 + edition = "2024" 5 + license = "MIT" 6 + description = "Official MLF DNS provider plugin for Porkbun" 7 + 8 + [[bin]] 9 + name = "mlf-dns-porkbun" 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"] }
+385
dns-plugins/mlf-dns-porkbun/src/api.rs
··· 1 + //! Thin Porkbun API wrapper. 2 + //! 3 + //! Porkbun's API is POST-only; `apikey` + `secretapikey` ride along in 4 + //! every request body. Records are addressed by a numeric ID 5 + //! per-subdomain+type, so we use the "by name + type" endpoints for 6 + //! edit/delete and the record list for reading. 7 + 8 + use crate::Credentials; 9 + use serde::Deserialize; 10 + use serde_json::{Value, json}; 11 + use thiserror::Error; 12 + 13 + const API_BASE: &str = "https://api.porkbun.com/api/json/v3"; 14 + 15 + #[derive(Error, Debug)] 16 + pub enum PorkbunError { 17 + #[error("Porkbun HTTP error: {0}")] 18 + Http(String), 19 + #[error("Porkbun API error: {0}")] 20 + Api(String), 21 + #[error("JSON decode error: {0}")] 22 + Decode(String), 23 + } 24 + 25 + impl From<reqwest::Error> for PorkbunError { 26 + fn from(e: reqwest::Error) -> Self { 27 + PorkbunError::Http(e.to_string()) 28 + } 29 + } 30 + 31 + pub struct PorkbunClient { 32 + client: reqwest::Client, 33 + apikey: String, 34 + secretapikey: String, 35 + } 36 + 37 + impl PorkbunClient { 38 + pub fn new(creds: &Credentials) -> Self { 39 + Self { 40 + client: reqwest::Client::new(), 41 + apikey: creds.api_key.clone(), 42 + secretapikey: creds.secret_key.clone(), 43 + } 44 + } 45 + 46 + fn auth_body(&self) -> Value { 47 + json!({ 48 + "apikey": self.apikey, 49 + "secretapikey": self.secretapikey, 50 + }) 51 + } 52 + 53 + /// Validate credentials via `/ping`. Returns the caller's IPv4/6 54 + /// address (Porkbun's whitelist check), or an error if auth fails. 55 + pub async fn ping(&self) -> Result<String, PorkbunError> { 56 + #[derive(Deserialize)] 57 + struct Resp { 58 + status: String, 59 + #[serde(default)] 60 + message: Option<String>, 61 + #[serde(default, rename = "yourIp")] 62 + your_ip: Option<String>, 63 + } 64 + let resp: Resp = self 65 + .client 66 + .post(format!("{API_BASE}/ping")) 67 + .json(&self.auth_body()) 68 + .send() 69 + .await? 70 + .json() 71 + .await 72 + .map_err(|e| PorkbunError::Decode(e.to_string()))?; 73 + if resp.status != "SUCCESS" { 74 + return Err(PorkbunError::Api( 75 + resp.message.unwrap_or_else(|| "auth failed".into()), 76 + )); 77 + } 78 + Ok(resp.your_ip.unwrap_or_else(|| "porkbun".into())) 79 + } 80 + 81 + /// Return the registered domains on the account. Used as the set of 82 + /// candidate zones when resolving which zone covers a DNS name. 83 + pub async fn list_domains(&self) -> Result<Vec<String>, PorkbunError> { 84 + #[derive(Deserialize)] 85 + struct Resp { 86 + status: String, 87 + #[serde(default)] 88 + message: Option<String>, 89 + #[serde(default)] 90 + domains: Vec<Domain>, 91 + } 92 + #[derive(Deserialize)] 93 + struct Domain { 94 + domain: String, 95 + } 96 + let resp: Resp = self 97 + .client 98 + .post(format!("{API_BASE}/domain/listAll")) 99 + .json(&self.auth_body()) 100 + .send() 101 + .await? 102 + .json() 103 + .await 104 + .map_err(|e| PorkbunError::Decode(e.to_string()))?; 105 + if resp.status != "SUCCESS" { 106 + return Err(PorkbunError::Api( 107 + resp.message.unwrap_or_else(|| "listAll failed".into()), 108 + )); 109 + } 110 + Ok(resp.domains.into_iter().map(|d| d.domain).collect()) 111 + } 112 + 113 + /// Find the domain under the account that covers `dns_name` by walking 114 + /// parent labels. Returns the domain (zone) name. 115 + pub async fn find_zone_for(&self, dns_name: &str) -> Result<Option<String>, PorkbunError> { 116 + let stripped = dns_name.strip_prefix("_lexicon.").unwrap_or(dns_name); 117 + let domains = self.list_domains().await?; 118 + for candidate in parent_domains(stripped) { 119 + if domains.iter().any(|d| d == &candidate) { 120 + return Ok(Some(candidate)); 121 + } 122 + } 123 + Ok(None) 124 + } 125 + 126 + /// List TXT records at `name`. Porkbun's `retrieveByNameType` wants 127 + /// the *subdomain* relative to the zone — i.e. everything before 128 + /// the zone's label, empty for the zone root. 129 + pub async fn list_txt( 130 + &self, 131 + zone: &str, 132 + name: &str, 133 + ) -> Result<Vec<TxtRecord>, PorkbunError> { 134 + let subdomain = subdomain_for(name, zone); 135 + #[derive(Deserialize)] 136 + struct Resp { 137 + status: String, 138 + #[serde(default)] 139 + message: Option<String>, 140 + #[serde(default)] 141 + records: Vec<Record>, 142 + } 143 + #[derive(Deserialize)] 144 + struct Record { 145 + id: String, 146 + content: String, 147 + } 148 + let url = format!( 149 + "{API_BASE}/dns/retrieveByNameType/{zone}/TXT/{subdomain}", 150 + subdomain = subdomain 151 + ); 152 + let resp: Resp = self 153 + .client 154 + .post(&url) 155 + .json(&self.auth_body()) 156 + .send() 157 + .await? 158 + .json() 159 + .await 160 + .map_err(|e| PorkbunError::Decode(e.to_string()))?; 161 + if resp.status != "SUCCESS" { 162 + return Err(PorkbunError::Api( 163 + resp.message.unwrap_or_else(|| "retrieveByNameType failed".into()), 164 + )); 165 + } 166 + // Porkbun returns TXT content as an RFC 1035 quoted string 167 + // (e.g. `"did=..."`); normalise to the raw payload. 168 + Ok(resp 169 + .records 170 + .into_iter() 171 + .map(|r| TxtRecord { 172 + id: r.id, 173 + content: unquote_txt(&r.content), 174 + }) 175 + .collect()) 176 + } 177 + 178 + /// Upsert TXT via `editByNameType` (replace the single-record slot) 179 + /// if it exists, else `create`. Porkbun's "edit by name+type" 180 + /// replaces every record at (subdomain, type) with one value — 181 + /// exactly what we want for the single `_lexicon` TXT case. 182 + pub async fn upsert_txt( 183 + &self, 184 + zone: &str, 185 + name: &str, 186 + value: &str, 187 + ttl: u32, 188 + ) -> Result<String, PorkbunError> { 189 + let subdomain = subdomain_for(name, zone); 190 + let quoted = format!("\"{}\"", escape_for_txt(value)); 191 + let existing = self.list_txt(zone, name).await?; 192 + if existing.is_empty() { 193 + let create_body = json!({ 194 + "apikey": self.apikey, 195 + "secretapikey": self.secretapikey, 196 + "name": subdomain, 197 + "type": "TXT", 198 + "content": quoted, 199 + "ttl": ttl.to_string(), 200 + }); 201 + let url = format!("{API_BASE}/dns/create/{zone}"); 202 + let resp = self 203 + .client 204 + .post(&url) 205 + .json(&create_body) 206 + .send() 207 + .await? 208 + .json::<Value>() 209 + .await 210 + .map_err(|e| PorkbunError::Decode(e.to_string()))?; 211 + if resp["status"] != "SUCCESS" { 212 + return Err(PorkbunError::Api( 213 + resp["message"] 214 + .as_str() 215 + .unwrap_or("create failed") 216 + .to_string(), 217 + )); 218 + } 219 + Ok(resp["id"].to_string()) 220 + } else { 221 + // editByNameType replaces every TXT at (subdomain, type) with 222 + // one value — exactly the shape we want for `_lexicon` TXT. 223 + let url = format!("{API_BASE}/dns/editByNameType/{zone}/TXT/{subdomain}"); 224 + let edit_body = json!({ 225 + "apikey": self.apikey, 226 + "secretapikey": self.secretapikey, 227 + "content": quoted, 228 + "ttl": ttl.to_string(), 229 + }); 230 + let resp = self 231 + .client 232 + .post(&url) 233 + .json(&edit_body) 234 + .send() 235 + .await? 236 + .json::<Value>() 237 + .await 238 + .map_err(|e| PorkbunError::Decode(e.to_string()))?; 239 + if resp["status"] != "SUCCESS" { 240 + return Err(PorkbunError::Api( 241 + resp["message"] 242 + .as_str() 243 + .unwrap_or("edit failed") 244 + .to_string(), 245 + )); 246 + } 247 + // editByNameType doesn't return an id; reuse the first existing one. 248 + Ok(existing[0].id.clone()) 249 + } 250 + } 251 + 252 + pub async fn delete_txt(&self, zone: &str, name: &str) -> Result<(), PorkbunError> { 253 + let subdomain = subdomain_for(name, zone); 254 + let url = format!( 255 + "{API_BASE}/dns/deleteByNameType/{zone}/TXT/{subdomain}", 256 + subdomain = subdomain 257 + ); 258 + let resp = self 259 + .client 260 + .post(&url) 261 + .json(&self.auth_body()) 262 + .send() 263 + .await? 264 + .json::<Value>() 265 + .await 266 + .map_err(|e| PorkbunError::Decode(e.to_string()))?; 267 + if resp["status"] != "SUCCESS" { 268 + return Err(PorkbunError::Api( 269 + resp["message"] 270 + .as_str() 271 + .unwrap_or("delete failed") 272 + .to_string(), 273 + )); 274 + } 275 + Ok(()) 276 + } 277 + } 278 + 279 + #[derive(Debug, Clone)] 280 + pub struct TxtRecord { 281 + pub id: String, 282 + pub content: String, 283 + } 284 + 285 + fn parent_domains(name: &str) -> Vec<String> { 286 + let parts: Vec<&str> = name.split('.').collect(); 287 + (0..parts.len()).map(|i| parts[i..].join(".")).collect() 288 + } 289 + 290 + /// Wrap a TXT payload per RFC 1035 `character-string` quoting: 291 + /// double-quote delimited, backslash + double-quote escaped. 292 + fn escape_for_txt(s: &str) -> String { 293 + s.replace('\\', "\\\\").replace('"', "\\\"") 294 + } 295 + 296 + /// Reverse of [`escape_for_txt`]: strip one pair of outer quotes and 297 + /// unescape `\\` / `\"`. Non-quoted input is returned untouched so the 298 + /// function is safe to apply defensively. 299 + fn unquote_txt(s: &str) -> String { 300 + let inner = s 301 + .strip_prefix('"') 302 + .and_then(|t| t.strip_suffix('"')) 303 + .unwrap_or(s); 304 + let mut out = String::with_capacity(inner.len()); 305 + let mut chars = inner.chars(); 306 + while let Some(c) = chars.next() { 307 + if c == '\\' { 308 + match chars.next() { 309 + Some('\\') => out.push('\\'), 310 + Some('"') => out.push('"'), 311 + Some(other) => { 312 + out.push('\\'); 313 + out.push(other); 314 + } 315 + None => out.push('\\'), 316 + } 317 + } else { 318 + out.push(c); 319 + } 320 + } 321 + out 322 + } 323 + 324 + /// Subdomain label for a fully-qualified `name` relative to a `zone`. 325 + /// For `_lexicon.forum.example.com` + `example.com` → `_lexicon.forum`. 326 + /// For `example.com` + `example.com` → empty string (zone root). 327 + fn subdomain_for(name: &str, zone: &str) -> String { 328 + let name = name.trim_end_matches('.'); 329 + let zone = zone.trim_end_matches('.'); 330 + if name == zone { 331 + return String::new(); 332 + } 333 + name.strip_suffix(&format!(".{zone}")) 334 + .map(|s| s.to_string()) 335 + .unwrap_or_else(|| name.to_string()) 336 + } 337 + 338 + #[cfg(test)] 339 + mod tests { 340 + use super::*; 341 + 342 + #[test] 343 + fn parent_domains_walks_up() { 344 + assert_eq!( 345 + parent_domains("_lexicon.forum.example.com"), 346 + vec![ 347 + "_lexicon.forum.example.com", 348 + "forum.example.com", 349 + "example.com", 350 + "com", 351 + ] 352 + ); 353 + } 354 + 355 + #[test] 356 + fn txt_quote_round_trip() { 357 + let raw = "did=did:plc:xl243nyru4tbbqjkuf2uvmna"; 358 + let wrapped = format!("\"{}\"", escape_for_txt(raw)); 359 + assert_eq!(wrapped, format!("\"{raw}\"")); 360 + assert_eq!(unquote_txt(&wrapped), raw); 361 + 362 + let raw = r#"a"b\c"#; 363 + let wrapped = format!("\"{}\"", escape_for_txt(raw)); 364 + assert_eq!(wrapped, r#""a\"b\\c""#); 365 + assert_eq!(unquote_txt(&wrapped), raw); 366 + 367 + assert_eq!(unquote_txt("did=plain"), "did=plain"); 368 + } 369 + 370 + #[test] 371 + fn subdomain_for_strips_zone() { 372 + assert_eq!( 373 + subdomain_for("_lexicon.forum.example.com", "example.com"), 374 + "_lexicon.forum" 375 + ); 376 + assert_eq!( 377 + subdomain_for("example.com", "example.com"), 378 + "" 379 + ); 380 + assert_eq!( 381 + subdomain_for("_lexicon.example.com", "example.com"), 382 + "_lexicon" 383 + ); 384 + } 385 + }
+266
dns-plugins/mlf-dns-porkbun/src/main.rs
··· 1 + //! Official MLF DNS provider plugin for Porkbun. 2 + //! 3 + //! Options schema: 4 + //! - `api_key` (secret, required) — Porkbun API key 5 + //! - `secret_key` (secret, required) — Porkbun API secret 6 + //! 7 + //! Porkbun requires API access to be toggled on per-domain in the 8 + //! dashboard; the plugin surfaces a clear error if a call hits a 9 + //! domain without API access enabled. 10 + 11 + mod api; 12 + 13 + use api::{PorkbunClient, PorkbunError}; 14 + use mlf_plugin_host::plugin::{Server, empty_data, params_as}; 15 + use mlf_plugin_host::protocol::{HelloData, OptionField, PROTOCOL_VERSION, Request}; 16 + use serde::{Deserialize, Serialize}; 17 + use serde_json::{Value, json}; 18 + 19 + #[tokio::main(flavor = "current_thread")] 20 + async fn main() -> std::io::Result<()> { 21 + let mut server = Server::stdio(); 22 + 23 + let identity = HelloData { 24 + name: "porkbun".into(), 25 + protocol_version: PROTOCOL_VERSION, 26 + kind: Some("dns".into()), 27 + capabilities: vec![ 28 + "login".into(), 29 + "list_txt".into(), 30 + "upsert_txt".into(), 31 + "delete_txt".into(), 32 + "resolve_zone".into(), 33 + ], 34 + options_schema: vec![ 35 + OptionField { 36 + name: "api_key".into(), 37 + label: "Porkbun API key".into(), 38 + help: Some( 39 + "Generate at https://porkbun.com/account/api. The domain must \ 40 + have API access enabled under its settings tab." 41 + .into(), 42 + ), 43 + secret: true, 44 + required: true, 45 + default: None, 46 + }, 47 + OptionField { 48 + name: "secret_key".into(), 49 + label: "Porkbun API secret".into(), 50 + help: None, 51 + secret: true, 52 + required: true, 53 + default: None, 54 + }, 55 + ], 56 + }; 57 + 58 + if server.handshake(identity).await.is_err() { 59 + return Ok(()); 60 + } 61 + 62 + let mut creds: Option<Credentials> = None; 63 + 64 + while let Ok(Some(req)) = server.next_request().await { 65 + if let Err(e) = dispatch(&mut server, &req, &mut creds).await { 66 + let _ = server.reply_err("internal", &e.to_string(), false).await; 67 + } 68 + } 69 + 70 + Ok(()) 71 + } 72 + 73 + #[derive(Debug, Clone, Serialize, Deserialize)] 74 + pub struct Credentials { 75 + pub api_key: String, 76 + pub secret_key: String, 77 + } 78 + 79 + #[derive(Debug, Deserialize)] 80 + struct InitParams { 81 + #[serde(default)] 82 + credentials: Option<Credentials>, 83 + } 84 + 85 + #[derive(Debug, Deserialize)] 86 + struct ResolveZoneParams { 87 + domain: String, 88 + } 89 + 90 + #[derive(Debug, Deserialize)] 91 + struct ListTxtParams { 92 + name: String, 93 + } 94 + 95 + #[derive(Debug, Deserialize)] 96 + struct UpsertTxtParams { 97 + name: String, 98 + value: String, 99 + #[serde(default)] 100 + ttl: Option<u32>, 101 + } 102 + 103 + #[derive(Debug, Deserialize)] 104 + struct DeleteTxtParams { 105 + name: String, 106 + #[allow(dead_code)] 107 + record_id: String, 108 + } 109 + 110 + #[derive(thiserror::Error, Debug)] 111 + enum DispatchError { 112 + #[error("{0}")] 113 + Plugin(#[from] mlf_plugin_host::plugin::PluginError), 114 + #[error("{0}")] 115 + Porkbun(#[from] PorkbunError), 116 + } 117 + 118 + async fn dispatch<W, R>( 119 + server: &mut Server<W, R>, 120 + req: &Request, 121 + creds: &mut Option<Credentials>, 122 + ) -> Result<(), DispatchError> 123 + where 124 + W: tokio::io::AsyncWrite + Unpin, 125 + R: tokio::io::AsyncBufReadExt + Unpin, 126 + { 127 + match req.op.as_str() { 128 + "init" => { 129 + let InitParams { credentials } = params_as(req)?; 130 + *creds = credentials; 131 + server.reply_ok(empty_data()).await?; 132 + } 133 + "login" => { 134 + let Some(c) = creds.as_ref() else { 135 + server 136 + .reply_err( 137 + "no_credentials", 138 + "login called before init set credentials", 139 + false, 140 + ) 141 + .await?; 142 + return Ok(()); 143 + }; 144 + match PorkbunClient::new(c).ping().await { 145 + Ok(ip) => { 146 + server 147 + .reply_ok(json!({ 148 + "credentials": c, 149 + "display_name": format!("porkbun ({ip})"), 150 + })) 151 + .await?; 152 + } 153 + Err(e) => { 154 + server 155 + .reply_err("invalid_credentials", &e.to_string(), false) 156 + .await?; 157 + } 158 + } 159 + } 160 + "logout" => { 161 + *creds = None; 162 + server.reply_ok(empty_data()).await?; 163 + } 164 + "resolve_zone" => { 165 + let ResolveZoneParams { domain } = params_as(req)?; 166 + let c = require_creds(server, creds).await?; 167 + let client = PorkbunClient::new(&c); 168 + match client.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 = PorkbunClient::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.content, 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 = PorkbunClient::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 = PorkbunClient::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 + }