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-google plugin (Google Cloud DNS)

Auth via a service-account JSON key (content, not a file path) or
Application Default Credentials when the plugin runs on GCE/Cloud Run
with attached identity. Uses gcp_auth to fetch bearer tokens per op.

Cloud DNS addresses records by (managedZone, name, type); the atomic
Changes endpoint takes additions+deletions in one request, so upsert
sends delete-old + add-new together. Zone resolution walks parent
labels against /projects/{id}/managedZones and matches against the
zone's dnsName (trailing-dot-aware).

Options schema: project_id (non-secret, required) and
service_account_json (secret, optional, falls back to ADC when
omitted).

authored by stavola.xyz and committed by

Tangled b92d8371 1db25cb8

+716
+50
Cargo.lock
··· 1366 1366 ] 1367 1367 1368 1368 [[package]] 1369 + name = "gcp_auth" 1370 + version = "0.12.6" 1371 + source = "registry+https://github.com/rust-lang/crates.io-index" 1372 + checksum = "c2b3d0b409a042a380111af38136310839af8ac1a0917fb6e84515ed1e4bf3ee" 1373 + dependencies = [ 1374 + "async-trait", 1375 + "base64", 1376 + "bytes", 1377 + "chrono", 1378 + "http 1.3.1", 1379 + "http-body-util", 1380 + "hyper 1.7.0", 1381 + "hyper-rustls 0.27.7", 1382 + "hyper-util", 1383 + "ring", 1384 + "rustls-pki-types", 1385 + "serde", 1386 + "serde_json", 1387 + "thiserror 2.0.17", 1388 + "tokio", 1389 + "tracing", 1390 + "tracing-futures", 1391 + "url", 1392 + ] 1393 + 1394 + [[package]] 1369 1395 name = "generic-array" 1370 1396 version = "0.14.7" 1371 1397 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2346 2372 ] 2347 2373 2348 2374 [[package]] 2375 + name = "mlf-dns-google" 2376 + version = "0.1.0" 2377 + dependencies = [ 2378 + "gcp_auth", 2379 + "mlf-plugin-host", 2380 + "reqwest", 2381 + "serde", 2382 + "serde_json", 2383 + "thiserror 2.0.17", 2384 + "tokio", 2385 + ] 2386 + 2387 + [[package]] 2349 2388 name = "mlf-dns-namecheap" 2350 2389 version = "0.1.0" 2351 2390 dependencies = [ ··· 3114 3153 dependencies = [ 3115 3154 "aws-lc-rs", 3116 3155 "once_cell", 3156 + "ring", 3117 3157 "rustls-pki-types", 3118 3158 "rustls-webpki 0.103.7", 3119 3159 "subtle", ··· 4003 4043 dependencies = [ 4004 4044 "once_cell", 4005 4045 "valuable", 4046 + ] 4047 + 4048 + [[package]] 4049 + name = "tracing-futures" 4050 + version = "0.2.5" 4051 + source = "registry+https://github.com/rust-lang/crates.io-index" 4052 + checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" 4053 + dependencies = [ 4054 + "pin-project", 4055 + "tracing", 4006 4056 ] 4007 4057 4008 4058 [[package]]
+1
Cargo.toml
··· 8 8 "mlf-atproto", 9 9 "mlf-cli", 10 10 "dns-plugins/mlf-dns-godaddy", 11 + "dns-plugins/mlf-dns-google", 11 12 "dns-plugins/mlf-dns-namecheap", 12 13 "dns-plugins/mlf-dns-porkbun", 13 14 "dns-plugins/mlf-dns-route53",
+19
dns-plugins/mlf-dns-google/Cargo.toml
··· 1 + [package] 2 + name = "mlf-dns-google" 3 + version = "0.1.0" 4 + edition = "2024" 5 + license = "MIT" 6 + description = "Official MLF DNS provider plugin for Google Cloud DNS" 7 + 8 + [[bin]] 9 + name = "mlf-dns-google" 10 + path = "src/main.rs" 11 + 12 + [dependencies] 13 + mlf-plugin-host = { path = "../../mlf-plugin-host" } 14 + gcp_auth = "0.12" 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"] }
+368
dns-plugins/mlf-dns-google/src/api.rs
··· 1 + //! Thin Google Cloud DNS wrapper. 2 + //! 3 + //! Auth: a service-account JSON key (the content, not a path) or 4 + //! Application Default Credentials via `gcp_auth`. Every API call 5 + //! sends a freshly-fetched bearer token. 6 + //! 7 + //! Cloud DNS addresses records by `(managedZone, name, type)`. 8 + //! Resource-record-sets are atomically modified via the `changes` 9 + //! endpoint, which takes `additions` and `deletions` lists in one 10 + //! request — handy for upsert (delete old + add new together). 11 + 12 + use crate::Credentials; 13 + use gcp_auth::{CustomServiceAccount, TokenProvider}; 14 + use serde::{Deserialize, Serialize}; 15 + use std::sync::Arc; 16 + use thiserror::Error; 17 + 18 + const API_BASE: &str = "https://dns.googleapis.com/dns/v1"; 19 + const SCOPE: &[&str] = &["https://www.googleapis.com/auth/ndev.clouddns.readwrite"]; 20 + 21 + #[derive(Error, Debug)] 22 + pub enum GoogleDnsError { 23 + #[error("Google DNS HTTP error: {0}")] 24 + Http(String), 25 + #[error("Google DNS API error: {0}")] 26 + Api(String), 27 + #[error("Auth error: {0}")] 28 + Auth(String), 29 + #[error("JSON decode error: {0}")] 30 + Decode(String), 31 + } 32 + 33 + impl From<reqwest::Error> for GoogleDnsError { 34 + fn from(e: reqwest::Error) -> Self { 35 + GoogleDnsError::Http(e.to_string()) 36 + } 37 + } 38 + 39 + pub struct GoogleDnsClient { 40 + client: reqwest::Client, 41 + provider: Arc<dyn TokenProvider>, 42 + project: String, 43 + } 44 + 45 + impl GoogleDnsClient { 46 + pub async fn new(creds: &Credentials) -> Result<Self, GoogleDnsError> { 47 + let provider: Arc<dyn TokenProvider> = 48 + if let Some(json) = creds.service_account_json.as_deref() { 49 + let sa = CustomServiceAccount::from_json(json) 50 + .map_err(|e| GoogleDnsError::Auth(e.to_string()))?; 51 + Arc::new(sa) 52 + } else { 53 + gcp_auth::provider() 54 + .await 55 + .map_err(|e| GoogleDnsError::Auth(e.to_string()))? 56 + }; 57 + Ok(Self { 58 + client: reqwest::Client::new(), 59 + provider, 60 + project: creds.project_id.clone(), 61 + }) 62 + } 63 + 64 + async fn token(&self) -> Result<String, GoogleDnsError> { 65 + let token = self 66 + .provider 67 + .token(SCOPE) 68 + .await 69 + .map_err(|e| GoogleDnsError::Auth(e.to_string()))?; 70 + Ok(token.as_str().to_string()) 71 + } 72 + 73 + /// Validate auth by listing the zones in the project. 74 + pub async fn verify(&self) -> Result<String, GoogleDnsError> { 75 + let zones = self.list_zones().await?; 76 + Ok(format!( 77 + "google ({} — {} zone(s))", 78 + self.project, 79 + zones.len() 80 + )) 81 + } 82 + 83 + /// All managed zones under the project. 84 + pub async fn list_zones(&self) -> Result<Vec<ManagedZone>, GoogleDnsError> { 85 + #[derive(Deserialize)] 86 + struct Resp { 87 + #[serde(default)] 88 + #[serde(rename = "managedZones")] 89 + managed_zones: Vec<ManagedZone>, 90 + } 91 + let token = self.token().await?; 92 + let resp = self 93 + .client 94 + .get(format!("{API_BASE}/projects/{}/managedZones", self.project)) 95 + .bearer_auth(token) 96 + .send() 97 + .await?; 98 + if !resp.status().is_success() { 99 + let status = resp.status(); 100 + let body = resp.text().await.unwrap_or_default(); 101 + return Err(GoogleDnsError::Api(format!("HTTP {status}: {body}"))); 102 + } 103 + let parsed: Resp = resp 104 + .json() 105 + .await 106 + .map_err(|e| GoogleDnsError::Decode(e.to_string()))?; 107 + Ok(parsed.managed_zones) 108 + } 109 + 110 + pub async fn find_zone_for( 111 + &self, 112 + dns_name: &str, 113 + ) -> Result<Option<ManagedZone>, GoogleDnsError> { 114 + let stripped = dns_name.strip_prefix("_lexicon.").unwrap_or(dns_name); 115 + let zones = self.list_zones().await?; 116 + // Zones in Cloud DNS end with a dot; compare without. 117 + for candidate in parent_domains(stripped) { 118 + if let Some(zone) = zones 119 + .iter() 120 + .find(|z| z.dns_name.trim_end_matches('.') == candidate) 121 + { 122 + return Ok(Some(zone.clone())); 123 + } 124 + } 125 + Ok(None) 126 + } 127 + 128 + pub async fn list_txt( 129 + &self, 130 + zone_name: &str, 131 + dns_name: &str, 132 + ) -> Result<Vec<TxtRecord>, GoogleDnsError> { 133 + let name = with_trailing_dot(dns_name); 134 + let token = self.token().await?; 135 + #[derive(Deserialize)] 136 + struct Resp { 137 + #[serde(default)] 138 + rrsets: Vec<Rrset>, 139 + } 140 + let resp = self 141 + .client 142 + .get(format!( 143 + "{API_BASE}/projects/{}/managedZones/{}/rrsets", 144 + self.project, zone_name 145 + )) 146 + .bearer_auth(token) 147 + .query(&[("name", name.as_str()), ("type", "TXT")]) 148 + .send() 149 + .await?; 150 + if !resp.status().is_success() { 151 + let status = resp.status(); 152 + let body = resp.text().await.unwrap_or_default(); 153 + return Err(GoogleDnsError::Api(format!("HTTP {status}: {body}"))); 154 + } 155 + let parsed: Resp = resp 156 + .json() 157 + .await 158 + .map_err(|e| GoogleDnsError::Decode(e.to_string()))?; 159 + let mut out = Vec::new(); 160 + for rrset in parsed.rrsets { 161 + if rrset.r#type != "TXT" { 162 + continue; 163 + } 164 + for value in rrset.rrdatas { 165 + out.push(TxtRecord { 166 + id: format!("{}/TXT", rrset.name.trim_end_matches('.')), 167 + value: unquote(&value), 168 + }); 169 + } 170 + } 171 + Ok(out) 172 + } 173 + 174 + /// Atomically replace the TXT rrset at `dns_name` with a single value. 175 + pub async fn upsert_txt( 176 + &self, 177 + zone_name: &str, 178 + dns_name: &str, 179 + value: &str, 180 + ttl: u32, 181 + ) -> Result<String, GoogleDnsError> { 182 + let name = with_trailing_dot(dns_name); 183 + let quoted = format!("\"{}\"", escape_for_txt(value)); 184 + let existing = self.get_rrset(zone_name, &name, "TXT").await?; 185 + let change = Change { 186 + additions: vec![Rrset { 187 + name: name.clone(), 188 + r#type: "TXT".into(), 189 + ttl: ttl as i64, 190 + rrdatas: vec![quoted], 191 + }], 192 + deletions: existing.into_iter().collect(), 193 + }; 194 + self.submit_change(zone_name, &change).await?; 195 + Ok(name.trim_end_matches('.').to_string()) 196 + } 197 + 198 + pub async fn delete_txt(&self, zone_name: &str, dns_name: &str) -> Result<(), GoogleDnsError> { 199 + let name = with_trailing_dot(dns_name); 200 + let existing = self.get_rrset(zone_name, &name, "TXT").await?; 201 + if existing.is_empty() { 202 + return Ok(()); 203 + } 204 + let change = Change { 205 + additions: vec![], 206 + deletions: existing, 207 + }; 208 + self.submit_change(zone_name, &change).await 209 + } 210 + 211 + async fn get_rrset( 212 + &self, 213 + zone_name: &str, 214 + dns_name: &str, 215 + record_type: &str, 216 + ) -> Result<Vec<Rrset>, GoogleDnsError> { 217 + let token = self.token().await?; 218 + #[derive(Deserialize)] 219 + struct Resp { 220 + #[serde(default)] 221 + rrsets: Vec<Rrset>, 222 + } 223 + let resp = self 224 + .client 225 + .get(format!( 226 + "{API_BASE}/projects/{}/managedZones/{}/rrsets", 227 + self.project, zone_name 228 + )) 229 + .bearer_auth(token) 230 + .query(&[("name", dns_name), ("type", record_type)]) 231 + .send() 232 + .await?; 233 + if !resp.status().is_success() { 234 + let status = resp.status(); 235 + let body = resp.text().await.unwrap_or_default(); 236 + return Err(GoogleDnsError::Api(format!("HTTP {status}: {body}"))); 237 + } 238 + let parsed: Resp = resp 239 + .json() 240 + .await 241 + .map_err(|e| GoogleDnsError::Decode(e.to_string()))?; 242 + Ok(parsed.rrsets) 243 + } 244 + 245 + async fn submit_change(&self, zone_name: &str, change: &Change) -> Result<(), GoogleDnsError> { 246 + let token = self.token().await?; 247 + let resp = self 248 + .client 249 + .post(format!( 250 + "{API_BASE}/projects/{}/managedZones/{}/changes", 251 + self.project, zone_name 252 + )) 253 + .bearer_auth(token) 254 + .json(change) 255 + .send() 256 + .await?; 257 + if !resp.status().is_success() { 258 + let status = resp.status(); 259 + let body = resp.text().await.unwrap_or_default(); 260 + return Err(GoogleDnsError::Api(format!("HTTP {status}: {body}"))); 261 + } 262 + Ok(()) 263 + } 264 + } 265 + 266 + #[derive(Debug, Clone, Deserialize)] 267 + pub struct ManagedZone { 268 + pub name: String, 269 + #[serde(rename = "dnsName")] 270 + pub dns_name: String, 271 + } 272 + 273 + #[derive(Debug, Clone, Serialize, Deserialize)] 274 + struct Rrset { 275 + name: String, 276 + r#type: String, 277 + ttl: i64, 278 + #[serde(default)] 279 + rrdatas: Vec<String>, 280 + } 281 + 282 + #[derive(Debug, Clone, Serialize)] 283 + struct Change { 284 + additions: Vec<Rrset>, 285 + deletions: Vec<Rrset>, 286 + } 287 + 288 + #[derive(Debug, Clone)] 289 + pub struct TxtRecord { 290 + pub id: String, 291 + pub value: String, 292 + } 293 + 294 + fn parent_domains(name: &str) -> Vec<String> { 295 + let parts: Vec<&str> = name.split('.').collect(); 296 + (0..parts.len()).map(|i| parts[i..].join(".")).collect() 297 + } 298 + 299 + fn with_trailing_dot(s: &str) -> String { 300 + if s.ends_with('.') { 301 + s.to_string() 302 + } else { 303 + format!("{s}.") 304 + } 305 + } 306 + 307 + fn escape_for_txt(s: &str) -> String { 308 + s.replace('\\', "\\\\").replace('"', "\\\"") 309 + } 310 + 311 + /// Cloud DNS returns rrdatas surrounded with double quotes + backslash 312 + /// escapes. Strip one set of outer quotes and unescape `\\` / `\"`. 313 + fn unquote(s: &str) -> String { 314 + let inner = s 315 + .strip_prefix('"') 316 + .and_then(|t| t.strip_suffix('"')) 317 + .unwrap_or(s); 318 + let mut out = String::with_capacity(inner.len()); 319 + let mut chars = inner.chars(); 320 + while let Some(c) = chars.next() { 321 + if c == '\\' { 322 + match chars.next() { 323 + Some('\\') => out.push('\\'), 324 + Some('"') => out.push('"'), 325 + Some(other) => { 326 + out.push('\\'); 327 + out.push(other); 328 + } 329 + None => out.push('\\'), 330 + } 331 + } else { 332 + out.push(c); 333 + } 334 + } 335 + out 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 with_trailing_dot_is_idempotent() { 357 + assert_eq!(with_trailing_dot("example.com"), "example.com."); 358 + assert_eq!(with_trailing_dot("example.com."), "example.com."); 359 + } 360 + 361 + #[test] 362 + fn txt_escape_round_trip() { 363 + let raw = r#"did=did:plc:"hello""#; 364 + let escaped = escape_for_txt(raw); 365 + let wrapped = format!("\"{escaped}\""); 366 + assert_eq!(unquote(&wrapped), raw); 367 + } 368 + }
+278
dns-plugins/mlf-dns-google/src/main.rs
··· 1 + //! Official MLF DNS provider plugin for Google Cloud DNS. 2 + //! 3 + //! Options schema: 4 + //! - `project_id` (non-secret, required) — GCP project that owns the zones 5 + //! - `service_account_json` (secret, optional) — JSON key content. If 6 + //! omitted, the plugin falls back to Application Default Credentials 7 + //! (useful on GCE / Cloud Run where the runtime provides them). 8 + //! 9 + //! The plugin holds a `gcp_auth::TokenProvider` alive for the session 10 + //! so repeated ops share token caching. 11 + 12 + mod api; 13 + 14 + use api::{GoogleDnsClient, GoogleDnsError}; 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: "google".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: "project_id".into(), 38 + label: "GCP project ID".into(), 39 + help: Some( 40 + "The project that owns the Cloud DNS managed zones you'll publish under." 41 + .into(), 42 + ), 43 + secret: false, 44 + required: true, 45 + default: None, 46 + }, 47 + OptionField { 48 + name: "service_account_json".into(), 49 + label: "Service account JSON key (content, not path)".into(), 50 + help: Some( 51 + "Paste the full JSON body of a service-account key with \ 52 + roles/dns.admin. If omitted, the plugin uses Application \ 53 + Default Credentials (gcloud auth, metadata server, etc.)." 54 + .into(), 55 + ), 56 + secret: true, 57 + required: false, 58 + default: None, 59 + }, 60 + ], 61 + }; 62 + 63 + if server.handshake(identity).await.is_err() { 64 + return Ok(()); 65 + } 66 + 67 + let mut creds: Option<Credentials> = None; 68 + 69 + while let Ok(Some(req)) = server.next_request().await { 70 + if let Err(e) = dispatch(&mut server, &req, &mut creds).await { 71 + let _ = server.reply_err("internal", &e.to_string(), false).await; 72 + } 73 + } 74 + 75 + Ok(()) 76 + } 77 + 78 + #[derive(Debug, Clone, Serialize, Deserialize)] 79 + pub struct Credentials { 80 + pub project_id: String, 81 + #[serde(default)] 82 + pub service_account_json: Option<String>, 83 + } 84 + 85 + #[derive(Debug, Deserialize)] 86 + struct InitParams { 87 + #[serde(default)] 88 + credentials: Option<Credentials>, 89 + } 90 + 91 + #[derive(Debug, Deserialize)] 92 + struct ResolveZoneParams { 93 + domain: String, 94 + } 95 + 96 + #[derive(Debug, Deserialize)] 97 + struct ListTxtParams { 98 + name: String, 99 + } 100 + 101 + #[derive(Debug, Deserialize)] 102 + struct UpsertTxtParams { 103 + name: String, 104 + value: String, 105 + #[serde(default)] 106 + ttl: Option<u32>, 107 + } 108 + 109 + #[derive(Debug, Deserialize)] 110 + struct DeleteTxtParams { 111 + name: String, 112 + #[allow(dead_code)] 113 + record_id: String, 114 + } 115 + 116 + #[derive(thiserror::Error, Debug)] 117 + enum DispatchError { 118 + #[error("{0}")] 119 + Plugin(#[from] mlf_plugin_host::plugin::PluginError), 120 + #[error("{0}")] 121 + Google(#[from] GoogleDnsError), 122 + } 123 + 124 + async fn dispatch<W, R>( 125 + server: &mut Server<W, R>, 126 + req: &Request, 127 + creds: &mut Option<Credentials>, 128 + ) -> Result<(), DispatchError> 129 + where 130 + W: tokio::io::AsyncWrite + Unpin, 131 + R: tokio::io::AsyncBufReadExt + Unpin, 132 + { 133 + match req.op.as_str() { 134 + "init" => { 135 + let InitParams { credentials } = params_as(req)?; 136 + *creds = credentials; 137 + server.reply_ok(empty_data()).await?; 138 + } 139 + "login" => { 140 + let Some(c) = creds.as_ref() else { 141 + server 142 + .reply_err( 143 + "no_credentials", 144 + "login called before init set credentials", 145 + false, 146 + ) 147 + .await?; 148 + return Ok(()); 149 + }; 150 + match GoogleDnsClient::new(c).await { 151 + Ok(client) => match client.verify().await { 152 + Ok(name) => { 153 + server 154 + .reply_ok(json!({"credentials": c, "display_name": name})) 155 + .await?; 156 + } 157 + Err(e) => { 158 + server 159 + .reply_err("invalid_credentials", &e.to_string(), false) 160 + .await?; 161 + } 162 + }, 163 + Err(e) => { 164 + server 165 + .reply_err("invalid_credentials", &e.to_string(), false) 166 + .await?; 167 + } 168 + } 169 + } 170 + "logout" => { 171 + *creds = None; 172 + server.reply_ok(empty_data()).await?; 173 + } 174 + "resolve_zone" => { 175 + let ResolveZoneParams { domain } = params_as(req)?; 176 + let c = require_creds(server, creds).await?; 177 + let client = GoogleDnsClient::new(&c).await?; 178 + match client.find_zone_for(&domain).await? { 179 + Some(zone) => { 180 + server 181 + .reply_ok(json!({"zone_id": zone.name, "covered": true})) 182 + .await?; 183 + } 184 + None => { 185 + server 186 + .reply_ok(json!({"zone_id": Value::Null, "covered": false})) 187 + .await?; 188 + } 189 + } 190 + } 191 + "list_txt" => { 192 + let ListTxtParams { name } = params_as(req)?; 193 + let c = require_creds(server, creds).await?; 194 + let client = GoogleDnsClient::new(&c).await?; 195 + let zone = match client.find_zone_for(&name).await? { 196 + Some(z) => z, 197 + None => { 198 + server 199 + .reply_err("unknown_zone", &format!("no zone covers {name}"), false) 200 + .await?; 201 + return Ok(()); 202 + } 203 + }; 204 + let records = client.list_txt(&zone.name, &name).await?; 205 + server 206 + .reply_ok(json!({ 207 + "records": records.into_iter().map(|r| json!({ 208 + "id": r.id, 209 + "value": r.value, 210 + })).collect::<Vec<_>>(), 211 + })) 212 + .await?; 213 + } 214 + "upsert_txt" => { 215 + let UpsertTxtParams { name, value, ttl } = params_as(req)?; 216 + let c = require_creds(server, creds).await?; 217 + let client = GoogleDnsClient::new(&c).await?; 218 + let zone = match client.find_zone_for(&name).await? { 219 + Some(z) => z, 220 + None => { 221 + server 222 + .reply_err("unknown_zone", &format!("no zone covers {name}"), false) 223 + .await?; 224 + return Ok(()); 225 + } 226 + }; 227 + let id = client 228 + .upsert_txt(&zone.name, &name, &value, ttl.unwrap_or(300)) 229 + .await?; 230 + server.reply_ok(json!({ "record_id": id })).await?; 231 + } 232 + "delete_txt" => { 233 + let DeleteTxtParams { name, record_id: _ } = params_as(req)?; 234 + let c = require_creds(server, creds).await?; 235 + let client = GoogleDnsClient::new(&c).await?; 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 + client.delete_txt(&zone.name, &name).await?; 246 + server.reply_ok(empty_data()).await?; 247 + } 248 + other => { 249 + server 250 + .reply_err("unknown_op", &format!("unsupported op `{other}`"), false) 251 + .await?; 252 + } 253 + } 254 + Ok(()) 255 + } 256 + 257 + async fn require_creds<W, R>( 258 + server: &mut Server<W, R>, 259 + creds: &Option<Credentials>, 260 + ) -> Result<Credentials, DispatchError> 261 + where 262 + W: tokio::io::AsyncWrite + Unpin, 263 + R: tokio::io::AsyncBufReadExt + Unpin, 264 + { 265 + if let Some(c) = creds.clone() { 266 + return Ok(c); 267 + } 268 + server 269 + .reply_err( 270 + "no_credentials", 271 + "host hasn't called init with credentials yet", 272 + false, 273 + ) 274 + .await?; 275 + Err(DispatchError::Plugin( 276 + mlf_plugin_host::plugin::PluginError::Unexpected("missing credentials".into()), 277 + )) 278 + }