1use std::{
13 net::{Ipv4Addr, Ipv6Addr},
14 time::Duration,
15};
16
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20use crate::{http::HttpClientBuilder, DnsRecord, Error, IntoFqdn};
21
22#[derive(Clone)]
23pub struct CloudflareProvider {
24 client: HttpClientBuilder,
25}
26
27#[derive(Deserialize, Debug)]
28pub struct IdMap {
29 pub id: String,
30 pub name: String,
31}
32
33#[derive(Serialize, Debug)]
34pub struct Query {
35 name: String,
36}
37
38#[derive(Serialize, Clone, Debug)]
39pub struct CreateDnsRecordParams<'a> {
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub ttl: Option<u32>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub priority: Option<u16>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub proxied: Option<bool>,
46 pub name: &'a str,
47 #[serde(flatten)]
48 pub content: DnsContent,
49}
50
51#[derive(Serialize, Clone, Debug)]
52pub struct UpdateDnsRecordParams<'a> {
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub ttl: Option<u32>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub proxied: Option<bool>,
57 pub name: &'a str,
58 #[serde(flatten)]
59 pub content: DnsContent,
60}
61
62#[derive(Deserialize, Serialize, Clone, Debug)]
63#[serde(tag = "type")]
64#[allow(clippy::upper_case_acronyms)]
65pub enum DnsContent {
66 A { content: Ipv4Addr },
67 AAAA { content: Ipv6Addr },
68 CNAME { content: String },
69 NS { content: String },
70 MX { content: String, priority: u16 },
71 TXT { content: String },
72 SRV { content: String },
73}
74
75#[derive(Deserialize, Serialize, Debug)]
76struct ApiResult<T> {
77 errors: Vec<ApiError>,
78 success: bool,
79 result: T,
80}
81
82#[derive(Deserialize, Serialize, Debug)]
83pub struct ApiError {
84 pub code: u16,
85 pub message: String,
86}
87
88impl CloudflareProvider {
89 pub(crate) fn new(
90 secret: impl AsRef<str>,
91 email: Option<impl AsRef<str>>,
92 timeout: Option<Duration>,
93 ) -> crate::Result<Self> {
94 let client = if let Some(email) = email {
95 HttpClientBuilder::default()
96 .with_header("X-Auth-Email", email.as_ref())
97 .with_header("X-Auth-Key", secret.as_ref())
98 } else {
99 HttpClientBuilder::default()
100 .with_header("Authorization", format!("Bearer {}", secret.as_ref()))
101 }
102 .with_timeout(timeout);
103
104 Ok(Self { client })
105 }
106
107 async fn obtain_zone_id(&self, origin: impl IntoFqdn<'_>) -> crate::Result<String> {
108 let origin = origin.into_name();
109 self.client
110 .get(format!(
111 "https://api.cloudflare.com/client/v4/zones?{}",
112 Query::name(origin.as_ref()).serialize()
113 ))
114 .send_with_retry::<ApiResult<Vec<IdMap>>>(3)
115 .await
116 .and_then(|r| r.unwrap_response("list zones"))
117 .and_then(|result| {
118 result
119 .into_iter()
120 .find(|zone| zone.name == origin.as_ref())
121 .map(|zone| zone.id)
122 .ok_or_else(|| Error::Api(format!("Zone {} not found", origin.as_ref())))
123 })
124 }
125
126 async fn obtain_record_id(
127 &self,
128 zone_id: &str,
129 name: impl IntoFqdn<'_>,
130 ) -> crate::Result<String> {
131 let name = name.into_name();
132 self.client
133 .get(format!(
134 "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?{}",
135 Query::name(name.as_ref()).serialize()
136 ))
137 .send_with_retry::<ApiResult<Vec<IdMap>>>(3)
138 .await
139 .and_then(|r| r.unwrap_response("list DNS records"))
140 .and_then(|result| {
141 result
142 .into_iter()
143 .find(|record| record.name == name.as_ref())
144 .map(|record| record.id)
145 .ok_or_else(|| Error::Api(format!("DNS Record {} not found", name.as_ref())))
146 })
147 }
148
149 pub(crate) async fn create(
150 &self,
151 name: impl IntoFqdn<'_>,
152 record: DnsRecord,
153 ttl: u32,
154 origin: impl IntoFqdn<'_>,
155 ) -> crate::Result<()> {
156 self.client
157 .post(format!(
158 "https://api.cloudflare.com/client/v4/zones/{}/dns_records",
159 self.obtain_zone_id(origin).await?
160 ))
161 .with_body(CreateDnsRecordParams {
162 ttl: ttl.into(),
163 priority: record.priority(),
164 proxied: false.into(),
165 name: name.into_name().as_ref(),
166 content: record.into(),
167 })?
168 .send_with_retry::<ApiResult<Value>>(3)
169 .await
170 .map(|_| ())
171 }
172
173 pub(crate) async fn update(
174 &self,
175 name: impl IntoFqdn<'_>,
176 record: DnsRecord,
177 ttl: u32,
178 origin: impl IntoFqdn<'_>,
179 ) -> crate::Result<()> {
180 let name = name.into_name();
181 self.client
182 .patch(format!(
183 "https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}",
184 self.obtain_zone_id(origin).await?,
185 name.as_ref()
186 ))
187 .with_body(UpdateDnsRecordParams {
188 ttl: ttl.into(),
189 proxied: None,
190 name: name.as_ref(),
191 content: record.into(),
192 })?
193 .send_with_retry::<ApiResult<Value>>(3)
194 .await
195 .map(|_| ())
196 }
197
198 pub(crate) async fn delete(
199 &self,
200 name: impl IntoFqdn<'_>,
201 origin: impl IntoFqdn<'_>,
202 ) -> crate::Result<()> {
203 let zone_id = self.obtain_zone_id(origin).await?;
204 let record_id = self.obtain_record_id(&zone_id, name).await?;
205
206 self.client
207 .delete(format!(
208 "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}",
209 ))
210 .send_with_retry::<ApiResult<Value>>(3)
211 .await
212 .map(|_| ())
213 }
214}
215
216impl<T> ApiResult<T> {
217 fn unwrap_response(self, action_name: &str) -> crate::Result<T> {
218 if self.success {
219 Ok(self.result)
220 } else {
221 Err(Error::Api(format!(
222 "Failed to {action_name}: {:?}",
223 self.errors
224 )))
225 }
226 }
227}
228
229impl Query {
230 pub fn name(name: impl Into<String>) -> Self {
231 Self { name: name.into() }
232 }
233
234 pub fn serialize(&self) -> String {
235 serde_urlencoded::to_string(self).unwrap()
236 }
237}
238
239impl From<DnsRecord> for DnsContent {
240 fn from(record: DnsRecord) -> Self {
241 match record {
242 DnsRecord::A { content } => DnsContent::A { content },
243 DnsRecord::AAAA { content } => DnsContent::AAAA { content },
244 DnsRecord::CNAME { content } => DnsContent::CNAME { content },
245 DnsRecord::NS { content } => DnsContent::NS { content },
246 DnsRecord::MX { content, priority } => DnsContent::MX { content, priority },
247 DnsRecord::TXT { content } => DnsContent::TXT { content },
248 DnsRecord::SRV { content, .. } => DnsContent::SRV { content },
249 }
250 }
251}