1use super::util;
2use crate::resources::RecordType;
3use chrono::prelude::*;
4use eyre::{
5 bail,
6 Context as _,
7 Result,
8};
9use reqwest::Method;
10use serde::{
11 de::DeserializeOwned,
12 Deserialize,
13 Serialize,
14};
15use serde_json::Value;
16
17#[derive(Debug, Serialize, Deserialize)]
22struct ApiResult<T> {
23 errors: Value,
24 messages: Value,
25 result: T,
26 result_info: Option<ApiResultInfo>,
27 success: bool,
28}
29
30#[derive(Debug, Serialize, Deserialize)]
31struct ApiResultInfo {
32 count: usize,
33 page: usize,
34 per_page: usize,
35 total_count: usize,
36 total_pages: usize,
37}
38
39#[derive(Debug, Serialize, Deserialize)]
44pub struct AccountInfo {
45 account: Account,
46 id: String,
47 name: String,
48 activated_on: DateTime<Utc>,
49 created_on: DateTime<Utc>,
50 modified_on: Option<DateTime<Utc>>,
51 development_mode: i64,
52 meta: Value,
53 name_servers: Vec<String>,
54 original_dnshost: Option<Value>,
55 original_name_servers: Option<Value>,
56 original_registrar: Option<Value>,
57 owner: Owner,
58 paused: bool,
59 permissions: Vec<String>,
60 plan: Plan,
61 status: String,
62 tenant: Value,
63 tenant_unit: Value,
64 r#type: String,
65}
66
67#[derive(Debug, Serialize, Deserialize)]
68pub struct Account {
69 id: String,
70 name: String,
71}
72
73#[derive(Debug, Serialize, Deserialize)]
74pub struct Owner {
75 email: Option<String>,
76 id: Option<String>,
77 r#type: Option<String>,
78}
79
80#[derive(Debug, Serialize, Deserialize)]
81pub struct Plan {
82 can_subscribe: bool,
83 currency: String,
84 externally_managed: bool,
85 frequency: String,
86 id: String,
87 is_subscribed: bool,
88 legacy_discount: bool,
89 legacy_id: String,
90 name: String,
91 price: i64,
92}
93
94#[derive(Debug, Serialize, Deserialize)]
100pub struct DnsRecordInfo {
101 pub comment: Option<String>,
102 pub content: String,
103 pub created_on: DateTime<Utc>,
104 pub id: String,
105 pub meta: DnsRecordMeta,
106 pub modified_on: DateTime<Utc>,
107 pub name: String,
108 pub proxiable: bool,
109 pub proxied: bool,
110 #[serde(default)]
111 pub tags: Vec<String>,
112 pub ttl: i64,
113 #[serde(rename = "type")]
114 pub record_type: String,
115}
116
117#[derive(Debug, Serialize, Deserialize)]
118pub struct DnsRecordMeta {
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub auto_added: Option<bool>,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub managed_by_apps: Option<bool>,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 pub managed_by_argo_tunnel: Option<bool>,
125}
126
127#[derive(Debug, Serialize, Deserialize)]
131pub struct DnsRecordModification {
132 pub id: String,
134 pub name: String,
135 #[serde(rename = "type")]
136 pub record_type: RecordType,
137 pub content: String,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub ttl: Option<i64>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub proxied: Option<bool>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub comment: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub tags: Option<Vec<String>>,
146}
147
148#[derive(Clone, Debug)]
150pub enum Zone {
151 Identifier(String),
152 Name(String),
153}
154
155impl Zone {
156 pub fn id(id: impl ToString) -> Self {
157 Zone::Identifier(id.to_string())
158 }
159
160 pub fn name(name: impl ToString) -> Self {
161 Zone::Name(name.to_string())
162 }
163
164 pub async fn resolve(self, api_token: &str) -> Result<Option<Self>> {
165 self.lookup_id(api_token).await.map(|id| id.map(Zone::Identifier))
166 }
167
168 pub async fn lookup_id(self, api_token: &str) -> Result<Option<String>> {
169 match self {
170 Zone::Identifier(id) => Ok(Some(id)),
171 Zone::Name(name) => {
172 debug!(?name, "looking up zone by name");
173 let accounts = list_zones(api_token).await?;
174 Ok(accounts.into_iter().find(|it| it.name == name).map(|it| it.id))
175 }
176 }
177 }
178}
179
180pub struct CreateRecordArgs {
182 pub api_token: String,
183 pub zone: Zone,
184 pub name: String,
185 pub record_type: RecordType,
186 pub content: String,
187 pub comment: Option<String>,
188 pub ttl: Option<i64>,
189}
190
191pub async fn list_zones(api_token: &str) -> Result<Vec<AccountInfo>, eyre::Error> {
193 let url = "https://api.cloudflare.com/client/v4/zones";
194 cloudflare_api_request::<Vec<AccountInfo>, ()>(url, None, Method::GET, api_token).await
195}
196
197pub async fn create_dns_record(args: CreateRecordArgs) -> Result<DnsRecordInfo, eyre::Error> {
199 let CreateRecordArgs {
200 api_token,
201 zone,
202 name,
203 record_type,
204 content,
205 comment,
206 ttl,
207 } = args;
208
209 let zone_identifier = zone
210 .lookup_id(&api_token)
211 .await?
212 .ok_or_else(|| eyre::eyre!("zone not found"))?;
213
214 let url = format!("https://api.cloudflare.com/client/v4/zones/{zone_identifier}/dns_records");
215 let id = util::id();
216
217 info!(?id, ?name, r#type = ?record_type, "creating dns record");
218
219 cloudflare_api_request::<DnsRecordInfo, _>(
220 &url,
221 Some(DnsRecordModification {
222 id,
223 name,
224 record_type,
225 content,
226 ttl,
227 proxied: None,
228 comment,
229 tags: None,
230 }),
231 Method::POST,
232 api_token,
233 )
234 .await
235}
236
237pub async fn update_dns_record_and_wait(args: CreateRecordArgs) -> Result<DnsRecordInfo, eyre::Error> {
241 let Some(zone_id) = args.zone.clone().lookup_id(&args.api_token).await? else {
242 bail!("zone not found");
243 };
244 let api_token = args.api_token.clone();
245 let domain = args.name.clone();
246
247 let dns_records = list_dns_records(&zone_id, &api_token).await?;
248 if let Some(existing) = dns_records.into_iter().find(|record| record.name == domain) {
249 if existing.content == args.content {
250 info!("DNS record for {domain:?} already exists with {:?}", args.content);
251 return Ok(existing);
252 }
253
254 warn!(
255 "Found existing DNS record for web domain {domain:?} with ip {:?}. Deleting.",
256 existing.content
257 );
258 delete_dns_record(&zone_id, &existing.id, &api_token)
259 .await
260 .context("Failed to delete existing DNS record")?;
261 }
262
263 info!("Creating new DNS record for {domain:?} with {:?}", args.content);
264
265 let record = create_dns_record(args).await?;
266
267 debug!("Registered record for {domain:?} with {:?}", record.content);
268
269 Ok(record)
270}
271
272#[allow(dead_code)]
274pub async fn delete_dns_record_by_name(
275 name: impl AsRef<str>,
276 zone_identifier: impl AsRef<str>,
277 api_token: impl AsRef<str>,
278) -> Result<(), eyre::Error> {
279 let name = name.as_ref();
280 let zone_identifier = zone_identifier.as_ref();
281
282 info!(?name, "deleting dns record by name");
283
284 let record = list_dns_records(&zone_identifier, api_token.as_ref())
285 .await?
286 .into_iter()
287 .find(|it| it.name == name);
288
289 let Some(record) = record else {
290 bail!("no record found with name: {name}");
291 };
292
293 delete_dns_record(zone_identifier, record.id, api_token).await?;
294
295 Ok(())
296}
297
298pub async fn delete_dns_record(
300 zone_identifier: impl AsRef<str>,
301 id: impl AsRef<str>,
302 api_token: impl AsRef<str>,
303) -> Result<()> {
304 let zone_identifier = zone_identifier.as_ref();
305 let id = id.as_ref();
306 let url = format!("https://api.cloudflare.com/client/v4/zones/{zone_identifier}/dns_records/{id}");
307
308 cloudflare_api_request::<Value, ()>(&url, None, Method::DELETE, api_token).await?;
309
310 Ok(())
311}
312
313pub async fn list_dns_records(
315 zone_identifier: impl AsRef<str>,
316 api_token: impl AsRef<str>,
317) -> Result<Vec<DnsRecordInfo>> {
318 let zone_identifier = zone_identifier.as_ref();
319 let url = format!("https://api.cloudflare.com/client/v4/zones/{zone_identifier}/dns_records");
320 cloudflare_api_request::<Vec<DnsRecordInfo>, ()>(&url, None, Method::GET, api_token).await
321}
322
323pub async fn cloudflare_api_request<R, B>(
324 url: &str,
325 body: Option<B>,
326 method: Method,
327 api_token: impl AsRef<str>,
328) -> Result<R>
329where
330 B: Serialize,
331 R: DeserializeOwned,
332{
333 let req = reqwest::Client::new()
334 .request(method, url)
335 .bearer_auth(api_token.as_ref())
336 .header("Content-Type", "application/json");
337
338 let req = if let Some(body) = body { req.json(&body) } else { req };
339
340 let res = req.send().await?;
341
342 if !res.status().is_success() {
343 bail!(
344 "cloudflare api error: status={:?}, body={:?}",
345 res.status(),
346 res.text().await?
347 );
348 }
349
350 #[cfg(debug_assertions)]
351 let body: ApiResult<_> = {
352 let body: Value = res.json().await?;
353 match serde_json::from_value(body.clone()) {
354 Err(err) => bail!(
355 "failed to parse api response: {err:?}: {}",
356 serde_json::to_string_pretty(&body).expect("pretty json")
357 ),
358 Ok(it) => it,
359 }
360 };
361
362 #[cfg(not(debug_assertions))]
363 let body: ApiResult<_> = res.json().await?;
364
365 Ok(body.result)
366}