1use crate::utils::build_caa;
13use crate::utils::strip_trailing_dot;
14use crate::utils::{
15 decode_hex, tlsa_cert_usage_from_u8, tlsa_matching_from_u8, tlsa_selector_from_u8,
16};
17use crate::{
18 DnsRecord, DnsRecordType, Error, IntoFqdn, MXRecord, SRVRecord, TLSARecord,
19 http::{HttpClient, HttpClientBuilder},
20};
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23use std::{borrow::Cow, time::Duration};
24
25const DEFAULT_API_ENDPOINT: &str = "https://api.gcore.com/dns";
26
27#[derive(Clone)]
28pub struct GcoreProvider {
29 client: HttpClient,
30 endpoint: Cow<'static, str>,
31}
32
33#[derive(Deserialize, Debug)]
34struct Zone {
35 name: String,
36}
37
38#[derive(Serialize, Debug)]
39struct RrSet {
40 ttl: u32,
41 resource_records: Vec<ResourceRecord>,
42}
43
44#[derive(Serialize, Debug)]
45struct ResourceRecord {
46 content: Vec<Value>,
47}
48
49#[derive(Deserialize, Debug)]
50struct RrSetResponse {
51 #[serde(default)]
52 ttl: u32,
53 #[serde(default)]
54 resource_records: Vec<ResourceRecordResponse>,
55}
56
57#[derive(Deserialize, Debug)]
58struct ResourceRecordResponse {
59 content: Vec<Value>,
60}
61
62impl GcoreProvider {
63 pub(crate) fn new(api_token: impl AsRef<str>, timeout: Option<Duration>) -> Self {
64 let client = HttpClientBuilder::default()
65 .with_header("Authorization", format!("APIKey {}", api_token.as_ref()))
66 .with_timeout(timeout)
67 .build();
68 Self {
69 client,
70 endpoint: Cow::Borrowed(DEFAULT_API_ENDPOINT),
71 }
72 }
73
74 #[cfg(test)]
75 pub(crate) fn with_endpoint(self, endpoint: impl Into<Cow<'static, str>>) -> Self {
76 Self {
77 endpoint: endpoint.into(),
78 ..self
79 }
80 }
81
82 pub(crate) async fn set_rrset(
83 &self,
84 name: impl IntoFqdn<'_>,
85 record_type: DnsRecordType,
86 ttl: u32,
87 records: Vec<DnsRecord>,
88 origin: impl IntoFqdn<'_>,
89 ) -> crate::Result<()> {
90 check_record_types(record_type, &records)?;
91 let zone = self.obtain_zone(&origin.into_name()).await?;
92 let fqdn = name.into_name();
93 let url = self.rrset_url(&zone, &fqdn, record_type);
94
95 if records.is_empty() {
96 return self
97 .client
98 .delete(url)
99 .send_raw()
100 .await
101 .map(|_| ())
102 .or_else(|err| match err {
103 Error::NotFound => Ok(()),
104 err => Err(err),
105 });
106 }
107
108 let body = build_rrset_from_many(records, ttl)?;
109 self.put_or_post(url, body).await
110 }
111
112 pub(crate) async fn add_to_rrset(
113 &self,
114 name: impl IntoFqdn<'_>,
115 record_type: DnsRecordType,
116 ttl: u32,
117 records: Vec<DnsRecord>,
118 origin: impl IntoFqdn<'_>,
119 ) -> crate::Result<()> {
120 if records.is_empty() {
121 return Ok(());
122 }
123 check_record_types(record_type, &records)?;
124 let zone = self.obtain_zone(&origin.into_name()).await?;
125 let fqdn = name.into_name();
126 let url = self.rrset_url(&zone, &fqdn, record_type);
127
128 let to_add: Vec<Vec<Value>> = records.into_iter().map(record_to_content).collect();
129
130 let (mut current, existed, effective_ttl) = match self.fetch_rrset(&url).await? {
131 Some(existing) => {
132 let existing_ttl = existing.ttl;
133 let contents = existing
134 .resource_records
135 .into_iter()
136 .map(|r| r.content)
137 .collect::<Vec<_>>();
138 (contents, true, existing_ttl)
139 }
140 None => (Vec::new(), false, ttl),
141 };
142
143 let before = current.len();
144 for content in to_add {
145 if !current.iter().any(|c| contents_equal(c, &content)) {
146 current.push(content);
147 }
148 }
149
150 if existed && current.len() == before {
151 return Ok(());
152 }
153
154 let body = RrSet {
155 ttl: effective_ttl,
156 resource_records: current
157 .into_iter()
158 .map(|content| ResourceRecord { content })
159 .collect(),
160 };
161 self.put_or_post(url, body).await
162 }
163
164 pub(crate) async fn remove_from_rrset(
165 &self,
166 name: impl IntoFqdn<'_>,
167 record_type: DnsRecordType,
168 records: Vec<DnsRecord>,
169 origin: impl IntoFqdn<'_>,
170 ) -> crate::Result<()> {
171 if records.is_empty() {
172 return Ok(());
173 }
174 check_record_types(record_type, &records)?;
175 let zone = self.obtain_zone(&origin.into_name()).await?;
176 let fqdn = name.into_name();
177 let url = self.rrset_url(&zone, &fqdn, record_type);
178
179 let existing = match self.fetch_rrset(&url).await? {
180 Some(existing) => existing,
181 None => return Ok(()),
182 };
183
184 let to_remove: Vec<Vec<Value>> = records.into_iter().map(record_to_content).collect();
185 let original_len = existing.resource_records.len();
186 let filtered: Vec<Vec<Value>> = existing
187 .resource_records
188 .into_iter()
189 .map(|r| r.content)
190 .filter(|content| !to_remove.iter().any(|r| contents_equal(r, content)))
191 .collect();
192
193 if filtered.len() == original_len {
194 return Ok(());
195 }
196
197 if filtered.is_empty() {
198 return self
199 .client
200 .delete(url)
201 .send_raw()
202 .await
203 .map(|_| ())
204 .or_else(|err| match err {
205 Error::NotFound => Ok(()),
206 err => Err(err),
207 });
208 }
209
210 let body = RrSet {
211 ttl: existing.ttl,
212 resource_records: filtered
213 .into_iter()
214 .map(|content| ResourceRecord { content })
215 .collect(),
216 };
217 self.client
218 .put(url)
219 .with_body(body)?
220 .send_raw()
221 .await
222 .map(|_| ())
223 }
224
225 pub(crate) async fn list_rrset(
226 &self,
227 name: impl IntoFqdn<'_>,
228 record_type: DnsRecordType,
229 origin: impl IntoFqdn<'_>,
230 ) -> crate::Result<Vec<DnsRecord>> {
231 let zone = self.obtain_zone(&origin.into_name()).await?;
232 let fqdn = name.into_name();
233 let url = self.rrset_url(&zone, &fqdn, record_type);
234
235 let existing = match self.fetch_rrset(&url).await? {
236 Some(existing) => existing,
237 None => return Ok(Vec::new()),
238 };
239
240 existing
241 .resource_records
242 .into_iter()
243 .map(|r| parse_content(record_type, &r.content))
244 .collect()
245 }
246
247 fn rrset_url(&self, zone: &str, fqdn: &str, record_type: DnsRecordType) -> String {
248 format!(
249 "{}/v2/zones/{}/{}/{}",
250 self.endpoint,
251 zone,
252 fqdn,
253 record_type.as_str()
254 )
255 }
256
257 async fn fetch_rrset(&self, url: &str) -> crate::Result<Option<RrSetResponse>> {
258 match self
259 .client
260 .get(url.to_string())
261 .send_with_retry::<RrSetResponse>(3)
262 .await
263 {
264 Ok(rrset) => Ok(Some(rrset)),
265 Err(Error::NotFound) => Ok(None),
266 Err(err) => Err(err),
267 }
268 }
269
270 async fn put_or_post(&self, url: String, body: RrSet) -> crate::Result<()> {
271 match self
272 .client
273 .put(url.clone())
274 .with_body(&body)?
275 .send_raw()
276 .await
277 {
278 Ok(_) => Ok(()),
279 Err(Error::NotFound) => self
280 .client
281 .post(url)
282 .with_body(body)?
283 .send_raw()
284 .await
285 .map(|_| ()),
286 Err(err) => Err(err),
287 }
288 }
289
290 async fn obtain_zone(&self, origin: &str) -> crate::Result<String> {
291 let mut candidate: &str = origin;
292 loop {
293 let result = self
294 .client
295 .get(format!("{}/v2/zones/{}", self.endpoint, candidate))
296 .send_with_retry::<Zone>(3)
297 .await;
298 match result {
299 Ok(zone) => return Ok(zone.name),
300 Err(Error::NotFound) => {}
301 Err(err) => return Err(err),
302 }
303 match candidate.split_once('.') {
304 Some((_, rest)) if rest.contains('.') => candidate = rest,
305 _ => {
306 return Err(Error::Api(format!("No Gcore zone found for {origin}")));
307 }
308 }
309 }
310 }
311}
312
313fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
314 for record in records {
315 if record.as_type() != expected {
316 return Err(Error::Api(format!(
317 "RRSet record type mismatch: expected {}, got {}",
318 expected.as_str(),
319 record.as_type().as_str(),
320 )));
321 }
322 }
323 Ok(())
324}
325
326fn build_rrset_from_many(records: Vec<DnsRecord>, ttl: u32) -> crate::Result<RrSet> {
327 Ok(RrSet {
328 ttl,
329 resource_records: records
330 .into_iter()
331 .map(|r| ResourceRecord {
332 content: record_to_content(r),
333 })
334 .collect(),
335 })
336}
337
338fn record_to_content(record: DnsRecord) -> Vec<Value> {
339 match record {
340 DnsRecord::A(addr) => vec![Value::String(addr.to_string())],
341 DnsRecord::AAAA(addr) => vec![Value::String(addr.to_string())],
342 DnsRecord::CNAME(content) => vec![Value::String(content)],
343 DnsRecord::NS(content) => vec![Value::String(content)],
344 DnsRecord::MX(mx) => vec![
345 Value::Number(mx.priority.into()),
346 Value::String(mx.exchange),
347 ],
348 DnsRecord::TXT(content) => vec![Value::String(content)],
349 DnsRecord::SRV(srv) => vec![
350 Value::Number(srv.priority.into()),
351 Value::Number(srv.weight.into()),
352 Value::Number(srv.port.into()),
353 Value::String(srv.target),
354 ],
355 DnsRecord::TLSA(tlsa) => vec![Value::String(tlsa.to_string())],
356 DnsRecord::CAA(caa) => {
357 let (flags, tag, value) = caa.decompose();
358 vec![
359 Value::Number(flags.into()),
360 Value::String(tag),
361 Value::String(value),
362 ]
363 }
364 }
365}
366
367fn contents_equal(a: &[Value], b: &[Value]) -> bool {
368 if a.len() != b.len() {
369 return false;
370 }
371 a.iter().zip(b.iter()).all(|(x, y)| values_equal(x, y))
372}
373
374fn values_equal(a: &Value, b: &Value) -> bool {
375 match (a, b) {
376 (Value::String(x), Value::String(y)) => x == y,
377 (Value::Number(x), Value::Number(y)) => match (x.as_i64(), y.as_i64()) {
378 (Some(xi), Some(yi)) => xi == yi,
379 _ => match (x.as_u64(), y.as_u64()) {
380 (Some(xu), Some(yu)) => xu == yu,
381 _ => match (x.as_f64(), y.as_f64()) {
382 (Some(xf), Some(yf)) => xf == yf,
383 _ => false,
384 },
385 },
386 },
387 (Value::Bool(x), Value::Bool(y)) => x == y,
388 (Value::Null, Value::Null) => true,
389 _ => a == b,
390 }
391}
392
393fn parse_content(record_type: DnsRecordType, content: &[Value]) -> crate::Result<DnsRecord> {
394 match record_type {
395 DnsRecordType::A => {
396 let s = expect_string(content, 0, "A")?;
397 s.parse()
398 .map(DnsRecord::A)
399 .map_err(|e| Error::Parse(format!("invalid A record: {e}")))
400 }
401 DnsRecordType::AAAA => {
402 let s = expect_string(content, 0, "AAAA")?;
403 s.parse()
404 .map(DnsRecord::AAAA)
405 .map_err(|e| Error::Parse(format!("invalid AAAA record: {e}")))
406 }
407 DnsRecordType::CNAME => {
408 let s = expect_string(content, 0, "CNAME")?;
409 Ok(DnsRecord::CNAME(strip_trailing_dot(s).to_string()))
410 }
411 DnsRecordType::NS => {
412 let s = expect_string(content, 0, "NS")?;
413 Ok(DnsRecord::NS(strip_trailing_dot(s).to_string()))
414 }
415 DnsRecordType::MX => {
416 if content.len() < 2 {
417 return Err(Error::Parse(format!(
418 "invalid MX content array length: {}",
419 content.len()
420 )));
421 }
422 let priority = expect_u16(content, 0, "MX")?;
423 let exchange = expect_string(content, 1, "MX")?;
424 Ok(DnsRecord::MX(MXRecord {
425 priority,
426 exchange: strip_trailing_dot(exchange).to_string(),
427 }))
428 }
429 DnsRecordType::TXT => {
430 let s = expect_string(content, 0, "TXT")?;
431 Ok(DnsRecord::TXT(s.to_string()))
432 }
433 DnsRecordType::SRV => {
434 if content.len() < 4 {
435 return Err(Error::Parse(format!(
436 "invalid SRV content array length: {}",
437 content.len()
438 )));
439 }
440 let priority = expect_u16(content, 0, "SRV")?;
441 let weight = expect_u16(content, 1, "SRV")?;
442 let port = expect_u16(content, 2, "SRV")?;
443 let target = expect_string(content, 3, "SRV")?;
444 Ok(DnsRecord::SRV(SRVRecord {
445 priority,
446 weight,
447 port,
448 target: strip_trailing_dot(target).to_string(),
449 }))
450 }
451 DnsRecordType::TLSA => {
452 let s = expect_string(content, 0, "TLSA")?;
453 parse_tlsa_text(s)
454 }
455 DnsRecordType::CAA => {
456 if content.len() < 3 {
457 return Err(Error::Parse(format!(
458 "invalid CAA content array length: {}",
459 content.len()
460 )));
461 }
462 let flags = expect_u8(content, 0, "CAA")?;
463 let tag = expect_string(content, 1, "CAA")?.to_string();
464 let value = expect_string(content, 2, "CAA")?.to_string();
465 build_caa(flags, &tag, &value).map(DnsRecord::CAA)
466 }
467 }
468}
469
470fn expect_string<'a>(content: &'a [Value], idx: usize, rtype: &str) -> crate::Result<&'a str> {
471 match content.get(idx) {
472 Some(Value::String(s)) => Ok(s.as_str()),
473 Some(other) => Err(Error::Parse(format!(
474 "expected string at position {idx} for {rtype}, got: {other}"
475 ))),
476 None => Err(Error::Parse(format!(
477 "missing element at position {idx} for {rtype}"
478 ))),
479 }
480}
481
482fn expect_u16(content: &[Value], idx: usize, rtype: &str) -> crate::Result<u16> {
483 match content.get(idx) {
484 Some(Value::Number(n)) => n
485 .as_u64()
486 .and_then(|v| u16::try_from(v).ok())
487 .ok_or_else(|| Error::Parse(format!("invalid u16 at position {idx} for {rtype}: {n}"))),
488 Some(other) => Err(Error::Parse(format!(
489 "expected number at position {idx} for {rtype}, got: {other}"
490 ))),
491 None => Err(Error::Parse(format!(
492 "missing element at position {idx} for {rtype}"
493 ))),
494 }
495}
496
497fn expect_u8(content: &[Value], idx: usize, rtype: &str) -> crate::Result<u8> {
498 match content.get(idx) {
499 Some(Value::Number(n)) => n
500 .as_u64()
501 .and_then(|v| u8::try_from(v).ok())
502 .ok_or_else(|| Error::Parse(format!("invalid u8 at position {idx} for {rtype}: {n}"))),
503 Some(other) => Err(Error::Parse(format!(
504 "expected number at position {idx} for {rtype}, got: {other}"
505 ))),
506 None => Err(Error::Parse(format!(
507 "missing element at position {idx} for {rtype}"
508 ))),
509 }
510}
511
512fn parse_tlsa_text(text: &str) -> crate::Result<DnsRecord> {
513 let mut parts = text.split_whitespace();
514 let usage: u8 = parts
515 .next()
516 .ok_or_else(|| Error::Parse(format!("invalid TLSA record: {text}")))?
517 .parse()
518 .map_err(|e| Error::Parse(format!("invalid TLSA usage: {e}")))?;
519 let selector: u8 = parts
520 .next()
521 .ok_or_else(|| Error::Parse(format!("invalid TLSA record: {text}")))?
522 .parse()
523 .map_err(|e| Error::Parse(format!("invalid TLSA selector: {e}")))?;
524 let matching: u8 = parts
525 .next()
526 .ok_or_else(|| Error::Parse(format!("invalid TLSA record: {text}")))?
527 .parse()
528 .map_err(|e| Error::Parse(format!("invalid TLSA matching: {e}")))?;
529 let hex: String = parts.collect::<Vec<_>>().join("");
530 Ok(DnsRecord::TLSA(TLSARecord {
531 cert_usage: tlsa_cert_usage_from_u8(usage)?,
532 selector: tlsa_selector_from_u8(selector)?,
533 matching: tlsa_matching_from_u8(matching)?,
534 cert_data: decode_hex(&hex)?,
535 }))
536}