1use super::types::RndcKeyData;
9use anyhow::{Context, Result};
10use bindcar::{CreateZoneRequest, SoaRecord, ZoneConfig, ZoneResponse};
11use reqwest::{Client as HttpClient, StatusCode};
12use serde::Serialize;
13use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::Instant;
16use tracing::{debug, error, info, warn};
17
18use crate::constants::DEFAULT_DNS_RECORD_TTL_SECS;
19use crate::reconcilers::retry::{http_backoff, is_retryable_http_status};
20
21#[derive(Debug)]
26struct HttpError {
27 status: StatusCode,
28 message: String,
29}
30
31impl std::fmt::Display for HttpError {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 write!(f, "HTTP {}: {}", self.status, self.message)
34 }
35}
36
37impl std::error::Error for HttpError {}
38
39pub(crate) fn build_api_url(server: &str) -> String {
44 if server.starts_with("http://") || server.starts_with("https://") {
45 server.trim_end_matches('/').to_string()
46 } else {
47 format!("http://{}", server.trim_end_matches('/'))
48 }
49}
50
51pub(crate) async fn bindcar_request<T: Serialize + std::fmt::Debug>(
73 client: &HttpClient,
74 token: Option<&str>,
75 method: &str,
76 url: &str,
77 body: Option<&T>,
78) -> Result<String> {
79 let mut backoff = http_backoff();
80 let start_time = Instant::now();
81 let mut attempt = 0;
82
83 loop {
84 attempt += 1;
85
86 let result = bindcar_request_internal(client, token, method, url, body).await;
87
88 match result {
89 Ok(response) => {
90 if attempt > 1 {
91 debug!(
92 method = %method,
93 url = %url,
94 attempt = attempt,
95 elapsed = ?start_time.elapsed(),
96 "HTTP API call succeeded after retries"
97 );
98 }
99 return Ok(response);
100 }
101 Err(e) => {
102 let mut is_retryable = false;
104
105 if let Some(http_err) = e.downcast_ref::<HttpError>() {
107 is_retryable = is_retryable_http_status(http_err.status);
108 } else {
109 let error_msg = e.to_string();
111 if error_msg.contains("Failed to send") || error_msg.contains("connection") {
112 is_retryable = true;
113 }
114 }
115
116 if !is_retryable {
117 error!(
118 method = %method,
119 url = %url,
120 error = %e,
121 "Non-retryable HTTP API error, failing immediately"
122 );
123 return Err(e);
124 }
125
126 if let Some(max_elapsed) = backoff.max_elapsed_time {
128 if start_time.elapsed() >= max_elapsed {
129 error!(
130 method = %method,
131 url = %url,
132 attempt = attempt,
133 elapsed = ?start_time.elapsed(),
134 error = %e,
135 "Max retry time exceeded, giving up"
136 );
137 return Err(anyhow::anyhow!(
138 "Max retry time exceeded after {attempt} attempts: {e}"
139 ));
140 }
141 }
142
143 if let Some(duration) = backoff.next_backoff() {
145 warn!(
146 method = %method,
147 url = %url,
148 attempt = attempt,
149 retry_after = ?duration,
150 error = %e,
151 "Retryable HTTP API error, will retry"
152 );
153 tokio::time::sleep(duration).await;
154 } else {
155 error!(
156 method = %method,
157 url = %url,
158 attempt = attempt,
159 elapsed = ?start_time.elapsed(),
160 error = %e,
161 "Backoff exhausted, giving up"
162 );
163 return Err(anyhow::anyhow!(
164 "Backoff exhausted after {attempt} attempts: {e}"
165 ));
166 }
167 }
168 }
169 }
170}
171
172async fn bindcar_request_internal<T: Serialize + std::fmt::Debug>(
188 client: &HttpClient,
189 token: Option<&str>,
190 method: &str,
191 url: &str,
192 body: Option<&T>,
193) -> Result<String> {
194 info!(
196 method = %method,
197 url = %url,
198 body = ?body,
199 auth_enabled = token.is_some(),
200 "HTTP API request to bindcar"
201 );
202
203 let mut request = match method {
205 "GET" => client.get(url),
206 "POST" => {
207 let mut req = client.post(url);
208 if let Some(body_data) = body {
209 req = req.json(body_data);
210 }
211 req
212 }
213 "PATCH" => {
214 let mut req = client.patch(url);
215 if let Some(body_data) = body {
216 req = req.json(body_data);
217 }
218 req
219 }
220 "DELETE" => client.delete(url),
221 _ => anyhow::bail!("Unsupported HTTP method: {method}"),
222 };
223
224 if let Some(token_value) = token {
226 request = request.header("Authorization", format!("Bearer {token_value}"));
227 }
228
229 let response = request
231 .send()
232 .await
233 .context(format!("Failed to send HTTP request to {url}"))?;
234
235 let status = response.status();
236
237 if !status.is_success() {
239 let error_text = response
240 .text()
241 .await
242 .unwrap_or_else(|_| "Unknown error".to_string());
243 error!(
244 method = %method,
245 url = %url,
246 status = %status,
247 error = %error_text,
248 "HTTP API request failed"
249 );
250 return Err(HttpError {
251 status,
252 message: error_text,
253 }
254 .into());
255 }
256
257 let text = response
259 .text()
260 .await
261 .context("Failed to read response body")?;
262
263 info!(
264 method = %method,
265 url = %url,
266 status = %status,
267 response_len = text.len(),
268 "HTTP API request successful"
269 );
270
271 Ok(text)
272}
273
274pub async fn reload_zone(
289 client: &Arc<HttpClient>,
290 token: Option<&str>,
291 zone_name: &str,
292 server: &str,
293) -> Result<()> {
294 let base_url = build_api_url(server);
295 let url = format!("{base_url}/api/v1/zones/{zone_name}/reload");
296
297 let result = bindcar_request(client, token, "POST", &url, None::<&()>).await;
298
299 match result {
300 Ok(_) => Ok(()),
301 Err(e) => {
302 let err_msg = e.to_string();
303 if err_msg.contains("not found") || err_msg.contains("does not exist") {
304 Err(anyhow::anyhow!("Zone {zone_name} not found on {server}"))
305 } else {
306 Err(e).context("Failed to reload zone")
307 }
308 }
309 }
310}
311
312pub async fn reload_all_zones(
318 client: &Arc<HttpClient>,
319 token: Option<&str>,
320 server: &str,
321) -> Result<()> {
322 let base_url = build_api_url(server);
323 let url = format!("{base_url}/api/v1/server/reload");
324
325 bindcar_request(client, token, "POST", &url, None::<&()>)
326 .await
327 .context("Failed to reload all zones")?;
328
329 Ok(())
330}
331
332pub async fn retransfer_zone(
338 client: &Arc<HttpClient>,
339 token: Option<&str>,
340 zone_name: &str,
341 server: &str,
342) -> Result<()> {
343 let base_url = build_api_url(server);
344 let url = format!("{base_url}/api/v1/zones/{zone_name}/retransfer");
345
346 bindcar_request(client, token, "POST", &url, None::<&()>)
347 .await
348 .context("Failed to retransfer zone")?;
349
350 Ok(())
351}
352
353pub async fn freeze_zone(
359 client: &Arc<HttpClient>,
360 token: Option<&str>,
361 zone_name: &str,
362 server: &str,
363) -> Result<()> {
364 let base_url = build_api_url(server);
365 let url = format!("{base_url}/api/v1/zones/{zone_name}/freeze");
366
367 bindcar_request(client, token, "POST", &url, None::<&()>)
368 .await
369 .context("Failed to freeze zone")?;
370
371 Ok(())
372}
373
374pub async fn thaw_zone(
380 client: &Arc<HttpClient>,
381 token: Option<&str>,
382 zone_name: &str,
383 server: &str,
384) -> Result<()> {
385 let base_url = build_api_url(server);
386 let url = format!("{base_url}/api/v1/zones/{zone_name}/thaw");
387
388 bindcar_request(client, token, "POST", &url, None::<&()>)
389 .await
390 .context("Failed to thaw zone")?;
391
392 Ok(())
393}
394
395pub async fn zone_status(
401 client: &Arc<HttpClient>,
402 token: Option<&str>,
403 zone_name: &str,
404 server: &str,
405) -> Result<String> {
406 let base_url = build_api_url(server);
407 let url = format!("{base_url}/api/v1/zones/{zone_name}/status");
408
409 let status = bindcar_request(client, token, "GET", &url, None::<&()>)
410 .await
411 .context("Failed to get zone status")?;
412
413 Ok(status)
414}
415
416pub async fn zone_exists(
430 client: &Arc<HttpClient>,
431 token: Option<&str>,
432 zone_name: &str,
433 server: &str,
434) -> Result<bool> {
435 match zone_status(client, token, zone_name, server).await {
436 Ok(_) => {
437 debug!("Zone {zone_name} exists on {server}");
438 Ok(true)
439 }
440 Err(e) => {
441 let err_msg = e.to_string();
442
443 if err_msg.contains("404") || err_msg.contains("not found") {
445 debug!("Zone {zone_name} does not exist on {server}");
446 return Ok(false);
447 }
448
449 if err_msg.contains("429") || err_msg.contains("Too Many Requests") {
451 error!("Rate limited while checking if zone {zone_name} exists on {server}: {e}");
452 return Err(e).context("Rate limited while checking zone existence");
453 }
454
455 error!("Error checking if zone {zone_name} exists on {server}: {e}");
457 Err(e).context("Failed to check zone existence")
458 }
459 }
460}
461
462pub async fn server_status(
468 client: &Arc<HttpClient>,
469 token: Option<&str>,
470 server: &str,
471) -> Result<String> {
472 let base_url = build_api_url(server);
473 let url = format!("{base_url}/api/v1/server/status");
474
475 let status = bindcar_request(client, token, "GET", &url, None::<&()>)
476 .await
477 .context("Failed to get server status")?;
478
479 Ok(status)
480}
481
482#[allow(
512 clippy::cast_possible_truncation,
513 clippy::cast_sign_loss,
514 clippy::too_many_arguments
515)]
516#[allow(clippy::implicit_hasher)]
517pub async fn add_primary_zone(
518 client: &Arc<HttpClient>,
519 token: Option<&str>,
520 zone_name: &str,
521 server: &str,
522 key_data: &RndcKeyData,
523 soa_record: &crate::crd::SOARecord,
524 name_servers: Option<&[String]>,
525 name_server_ips: Option<&HashMap<String, String>>,
526 secondary_ips: Option<&[String]>,
527 dnssec_policy: Option<&str>,
528) -> Result<bool> {
529 use bindcar::ZONE_TYPE_PRIMARY;
530
531 let base_url = build_api_url(server);
535 let url = format!("{base_url}/api/v1/zones");
536
537 let all_name_servers = if let Some(ns_list) = name_servers {
540 ns_list.to_vec()
541 } else {
542 vec![soa_record.primary_ns.clone()]
544 };
545
546 if let Some(policy) = dnssec_policy {
548 info!("DNSSEC policy '{policy}' will be applied to zone {zone_name} on {server}");
549 }
550
551 let zone_config = ZoneConfig {
553 ttl: DEFAULT_DNS_RECORD_TTL_SECS as u32,
554 soa: SoaRecord {
555 primary_ns: soa_record.primary_ns.clone(),
556 admin_email: soa_record.admin_email.clone(),
557 serial: soa_record.serial as u32,
558 refresh: soa_record.refresh as u32,
559 retry: soa_record.retry as u32,
560 expire: soa_record.expire as u32,
561 negative_ttl: soa_record.negative_ttl as u32,
562 },
563 name_servers: all_name_servers,
564 name_server_ips: name_server_ips.cloned().unwrap_or_default(),
565 records: vec![],
566 also_notify: secondary_ips.map(<[String]>::to_vec),
568 allow_transfer: secondary_ips.map(<[String]>::to_vec),
569 primaries: None,
571 dnssec_policy: dnssec_policy.map(String::from),
573 inline_signing: dnssec_policy.map(|_| true),
574 };
575
576 let request = CreateZoneRequest {
577 zone_name: zone_name.to_string(),
578 zone_type: ZONE_TYPE_PRIMARY.to_string(),
579 zone_config,
580 update_key_name: Some(key_data.name.clone()),
581 };
582
583 match bindcar_request(client, token, "POST", &url, Some(&request)).await {
584 Ok(_) => {
585 if let Some(ips) = secondary_ips {
586 info!(
587 "Added zone {zone_name} on {server} with allow-update for key {} and zone transfers configured for {} secondary server(s): {:?}",
588 key_data.name, ips.len(), ips
589 );
590 } else {
591 info!(
592 "Added zone {zone_name} on {server} with allow-update for key {} (no secondary servers)",
593 key_data.name
594 );
595 }
596 Ok(true)
597 }
598 Err(e) => {
599 let is_conflict = e
602 .downcast_ref::<HttpError>()
603 .is_some_and(|http_err| http_err.status == StatusCode::CONFLICT);
604
605 let err_msg = e.to_string().to_lowercase();
606 if is_conflict
613 || err_msg.contains("already exists")
614 || err_msg.contains("already serves")
615 || err_msg.contains("duplicate zone")
616 {
617 info!("Zone {zone_name} already exists on {server} (HTTP 409 Conflict), treating as success");
618
619 if let Some(ips) = secondary_ips {
621 if !ips.is_empty() {
622 info!(
623 "Zone {zone_name} already exists on {server}, updating also-notify and allow-transfer with {} secondary server(s)",
624 ips.len()
625 );
626 let _updated =
629 update_primary_zone(client, token, zone_name, server, ips).await?;
630 return Ok(false);
633 }
634 }
635
636 Ok(false)
637 } else {
638 Err(e).context("Failed to add zone")
639 }
640 }
641 }
642}
643
644pub async fn update_primary_zone(
667 client: &Arc<HttpClient>,
668 token: Option<&str>,
669 zone_name: &str,
670 server: &str,
671 secondary_ips: &[String],
672) -> Result<bool> {
673 #[derive(Serialize, Debug)]
676 #[serde(rename_all = "camelCase")]
677 struct ZoneUpdateRequest {
678 also_notify: Option<Vec<String>>,
679 allow_transfer: Option<Vec<String>>,
680 }
681
682 let base_url = build_api_url(server);
683 let url = format!("{base_url}/api/v1/zones/{zone_name}");
684
685 let update_request = ZoneUpdateRequest {
686 also_notify: Some(secondary_ips.to_vec()),
687 allow_transfer: Some(secondary_ips.to_vec()),
688 };
689
690 info!(
691 "Updating zone {zone_name} on {server} with {} secondary server(s): {:?}",
692 secondary_ips.len(),
693 secondary_ips
694 );
695
696 match bindcar_request(client, token, "PATCH", &url, Some(&update_request)).await {
698 Ok(_) => {
699 info!(
700 "Successfully updated zone {zone_name} on {server} with also-notify and allow-transfer for {} secondary server(s)",
701 secondary_ips.len()
702 );
703 Ok(true)
704 }
705 Err(e) => {
706 let error_msg = e.to_string();
707 if error_msg.contains("not found") || error_msg.contains("404") {
709 debug!("Zone {zone_name} not found on {server}, cannot update");
710 Ok(false)
711 } else {
712 Err(e).context("Failed to update zone configuration")
713 }
714 }
715 }
716}
717
718pub async fn add_secondary_zone(
739 client: &Arc<HttpClient>,
740 token: Option<&str>,
741 zone_name: &str,
742 server: &str,
743 key_data: &RndcKeyData,
744 primary_ips: &[String],
745) -> Result<bool> {
746 use bindcar::ZONE_TYPE_SECONDARY;
747
748 let base_url = build_api_url(server);
751 let url = format!("{base_url}/api/v1/zones");
752
753 let primaries_with_port: Vec<String> = primary_ips
759 .iter()
760 .map(|ip| format!("{} port {}", ip, crate::constants::DNS_CONTAINER_PORT))
761 .collect();
762
763 let zone_config = ZoneConfig {
764 ttl: DEFAULT_DNS_RECORD_TTL_SECS as u32,
765 soa: SoaRecord {
766 primary_ns: "placeholder.example.com.".to_string(),
767 admin_email: "admin.example.com.".to_string(),
768 serial: 1,
769 refresh: 3600,
770 retry: 600,
771 expire: 604_800,
772 negative_ttl: 86400,
773 },
774 name_servers: vec![],
775 name_server_ips: std::collections::HashMap::new(),
776 records: vec![],
777 also_notify: None,
778 allow_transfer: None,
779 primaries: Some(primaries_with_port),
780 dnssec_policy: None,
782 inline_signing: None,
783 };
784
785 let request = CreateZoneRequest {
786 zone_name: zone_name.to_string(),
787 zone_type: ZONE_TYPE_SECONDARY.to_string(),
788 zone_config,
789 update_key_name: Some(key_data.name.clone()),
790 };
791
792 match bindcar_request(client, token, "POST", &url, Some(&request)).await {
793 Ok(_) => {
794 info!(
795 "Added secondary zone {zone_name} on {server} with primaries: {:?}",
796 request.zone_config.primaries
797 );
798 Ok(true)
799 }
800 Err(e) => {
801 let is_conflict = e
804 .downcast_ref::<HttpError>()
805 .is_some_and(|http_err| http_err.status == StatusCode::CONFLICT);
806
807 let err_msg = e.to_string().to_lowercase();
808 if is_conflict
815 || err_msg.contains("already exists")
816 || err_msg.contains("already serves")
817 || err_msg.contains("duplicate zone")
818 {
819 info!("Zone {zone_name} already exists on {server} (HTTP 409 Conflict), treating as success");
820 Ok(false)
821 } else {
822 Err(e).context("Failed to add secondary zone")
823 }
824 }
825 }
826}
827
828#[allow(clippy::too_many_arguments)]
861#[allow(clippy::implicit_hasher)]
862pub async fn add_zones(
863 client: &Arc<HttpClient>,
864 token: Option<&str>,
865 zone_name: &str,
866 zone_type: &str,
867 server: &str,
868 key_data: &RndcKeyData,
869 soa_record: Option<&crate::crd::SOARecord>,
870 name_servers: Option<&[String]>,
871 name_server_ips: Option<&HashMap<String, String>>,
872 secondary_ips: Option<&[String]>,
873 primary_ips: Option<&[String]>,
874 dnssec_policy: Option<&str>,
875) -> Result<bool> {
876 use bindcar::{ZONE_TYPE_PRIMARY, ZONE_TYPE_SECONDARY};
877
878 match zone_type {
879 ZONE_TYPE_PRIMARY => {
880 let soa = soa_record
881 .ok_or_else(|| anyhow::anyhow!("SOA record is required for primary zones"))?;
882
883 add_primary_zone(
884 client,
885 token,
886 zone_name,
887 server,
888 key_data,
889 soa,
890 name_servers,
891 name_server_ips,
892 secondary_ips,
893 dnssec_policy,
894 )
895 .await
896 }
897 ZONE_TYPE_SECONDARY => {
898 let primaries = primary_ips
899 .ok_or_else(|| anyhow::anyhow!("Primary IPs are required for secondary zones"))?;
900
901 if primaries.is_empty() {
902 anyhow::bail!("Primary IPs list cannot be empty for secondary zones");
903 }
904
905 add_secondary_zone(client, token, zone_name, server, key_data, primaries).await
906 }
907 _ => anyhow::bail!("Invalid zone type: {zone_type}. Must be 'primary' or 'secondary'"),
908 }
909}
910
911#[allow(clippy::too_many_arguments)]
929#[allow(clippy::too_many_lines)]
930pub async fn create_zone_http(
931 client: &Arc<HttpClient>,
932 token: Option<&str>,
933 zone_name: &str,
934 zone_type: &str,
935 zone_config: ZoneConfig,
936 server: &str,
937 key_data: &RndcKeyData,
938) -> Result<()> {
939 match zone_exists(client, token, zone_name, server).await {
941 Ok(true) => {
942 info!("Zone {zone_name} already exists on {server}, skipping creation");
943 return Ok(());
944 }
945 Ok(false) => {
946 }
948 Err(e) => {
949 return Err(e).context("Failed to check if zone exists before creation");
952 }
953 }
954
955 let base_url = build_api_url(server);
956 let url = format!("{base_url}/api/v1/zones");
957
958 let request = CreateZoneRequest {
959 zone_name: zone_name.to_string(),
960 zone_type: zone_type.to_string(),
961 zone_config,
962 update_key_name: Some(key_data.name.clone()),
963 };
964
965 debug!(
966 zone_name = %zone_name,
967 zone_type = %zone_type,
968 server = %server,
969 "Creating zone via HTTP API"
970 );
971
972 let mut post_request = client
973 .post(&url)
974 .header("Content-Type", "application/json")
975 .json(&request);
976
977 if let Some(token_value) = token {
979 post_request = post_request.header("Authorization", format!("Bearer {token_value}"));
980 }
981
982 let response = post_request
983 .send()
984 .await
985 .context(format!("Failed to send HTTP request to {url}"))?;
986
987 let status = response.status();
988
989 if !status.is_success() {
990 let error_text = response
991 .text()
992 .await
993 .unwrap_or_else(|_| "Unknown error".to_string());
994
995 let error_lower = error_text.to_lowercase();
997 let zone_check_result = zone_exists(client, token, zone_name, server).await;
998 if error_lower.contains("already exists")
999 || error_lower.contains("already serves")
1000 || error_lower.contains("duplicate zone")
1001 || status.as_u16() == 409
1002 || matches!(zone_check_result, Ok(true))
1003 {
1004 info!("Zone {zone_name} already exists on {server} (detected via API error or existence check), treating as success");
1005 return Ok(());
1006 }
1007
1008 if let Err(zone_err) = zone_check_result {
1010 return Err(zone_err).context("Failed to verify zone existence after creation error");
1011 }
1012
1013 error!(
1014 zone_name = %zone_name,
1015 server = %server,
1016 status = %status,
1017 error = %error_text,
1018 "Failed to create zone via HTTP API"
1019 );
1020 anyhow::bail!("Failed to create zone '{zone_name}' via HTTP API: {status} - {error_text}");
1021 }
1022
1023 let result: ZoneResponse = response
1024 .json()
1025 .await
1026 .context("Failed to parse API response")?;
1027
1028 if !result.success {
1029 let msg_lower = result.message.to_lowercase();
1031 let zone_check_result = zone_exists(client, token, zone_name, server).await;
1032 if msg_lower.contains("already exists")
1033 || msg_lower.contains("already serves")
1034 || msg_lower.contains("duplicate zone")
1035 || matches!(zone_check_result, Ok(true))
1036 {
1037 info!("Zone {zone_name} already exists on {server} (detected via API response), treating as success");
1038 return Ok(());
1039 }
1040
1041 if let Err(zone_err) = zone_check_result {
1043 return Err(zone_err)
1044 .context("Failed to verify zone existence after API returned error");
1045 }
1046
1047 error!(
1048 zone_name = %zone_name,
1049 server = %server,
1050 message = %result.message,
1051 details = ?result.details,
1052 "API returned error when creating zone"
1053 );
1054 anyhow::bail!("Failed to create zone '{}': {}", zone_name, result.message);
1055 }
1056
1057 info!(
1058 zone_name = %zone_name,
1059 server = %server,
1060 message = %result.message,
1061 "Zone created successfully via HTTP API"
1062 );
1063
1064 Ok(())
1065}
1066
1067pub async fn delete_zone(
1080 client: &Arc<HttpClient>,
1081 token: Option<&str>,
1082 zone_name: &str,
1083 server: &str,
1084 freeze_before_delete: bool,
1085) -> Result<()> {
1086 if freeze_before_delete {
1089 if let Err(e) = freeze_zone(client, token, zone_name, server).await {
1090 debug!(
1091 "Failed to freeze zone {} before deletion (zone may not exist): {}",
1092 zone_name, e
1093 );
1094 }
1095 }
1096
1097 let base_url = build_api_url(server);
1098 let url = format!("{base_url}/api/v1/zones/{zone_name}");
1099
1100 match bindcar_request(client, token, "DELETE", &url, None::<&()>).await {
1102 Ok(_) => {
1103 info!("Deleted zone {zone_name} from {server}");
1104 Ok(())
1105 }
1106 Err(e) => {
1107 let error_msg = e.to_string();
1108 if error_msg.contains("not found") || error_msg.contains("404") {
1110 debug!("Zone {zone_name} already deleted from {server}");
1111 Ok(())
1112 } else {
1113 Err(e).context("Failed to delete zone")
1114 }
1115 }
1116 }
1117}
1118
1119pub async fn notify_zone(
1125 client: &Arc<HttpClient>,
1126 token: Option<&str>,
1127 zone_name: &str,
1128 server: &str,
1129) -> Result<()> {
1130 let base_url = build_api_url(server);
1131 let url = format!("{base_url}/api/v1/zones/{zone_name}/notify");
1132
1133 bindcar_request(client, token, "POST", &url, None::<&()>)
1134 .await
1135 .context("Failed to notify zone")?;
1136
1137 info!("Notified secondaries for zone {zone_name} from {server}");
1138 Ok(())
1139}
1140
1141pub async fn verify_zone_signed(zone_name: &str, server: &str) -> Result<bool> {
1184 use hickory_client::client::{AsyncClient, ClientHandle};
1185 use hickory_client::rr::{DNSClass, Name, RecordType};
1186 use hickory_client::udp::UdpClientStream;
1187 use std::net::SocketAddr;
1188 use std::str::FromStr;
1189
1190 let server_addr: SocketAddr = server
1192 .parse()
1193 .with_context(|| format!("Invalid DNS server address: {server}"))?;
1194
1195 debug!(
1196 "Verifying DNSSEC signing for zone {} on {}",
1197 zone_name, server_addr
1198 );
1199
1200 let stream = UdpClientStream::<tokio::net::UdpSocket>::new(server_addr);
1202 let (mut client, bg) = AsyncClient::connect(stream).await?;
1203
1204 tokio::spawn(bg);
1206
1207 let name =
1209 Name::from_str(zone_name).with_context(|| format!("Invalid zone name: {zone_name}"))?;
1210
1211 let response = client
1213 .query(name.clone(), DNSClass::IN, RecordType::DNSKEY)
1214 .await
1215 .with_context(|| {
1216 format!("Failed to query DNSKEY records for zone {zone_name} on {server_addr}")
1217 })?;
1218
1219 let is_signed = !response.answers().is_empty();
1221
1222 if is_signed {
1223 debug!(
1224 "Zone {} is signed with DNSSEC (found {} DNSKEY record(s))",
1225 zone_name,
1226 response.answers().len()
1227 );
1228 } else {
1229 debug!(
1230 "Zone {} is not signed with DNSSEC (no DNSKEY records found)",
1231 zone_name
1232 );
1233 }
1234
1235 Ok(is_signed)
1236}
1237
1238#[cfg(test)]
1239#[path = "zone_ops_tests.rs"]
1240mod zone_ops_tests;