bindy/bind9/zone_ops.rs
1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Zone HTTP API operations for BIND9 management.
5//!
6//! This module contains all zone management functions that interact with the bindcar HTTP API sidecar.
7
8use 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, DNS_CONTAINER_PORT};
19use crate::reconcilers::retry::{http_backoff, is_retryable_http_status};
20
21/// Append the operand's DNS container port to each transfer endpoint, in the
22/// compact `<ip>:<port>` form bindcar accepts (IPv6 addresses are bracketed:
23/// `[2001:db8::1]:5353`).
24///
25/// `named` binds the unprivileged [`DNS_CONTAINER_PORT`] (5353), not 53, so a
26/// secondary's `primaries` and a primary's `also-notify` endpoints must target
27/// that port. bindcar (`0.7.2`+) parses this form and renders BIND's
28/// `<ip> port <n>` syntax. `allow-transfer` is a port-agnostic ACL and must stay
29/// bare IPs, so it deliberately does **not** use this helper.
30fn with_transfer_port(ips: &[String]) -> Vec<String> {
31 ips.iter()
32 .map(|ip| {
33 if ip.parse::<std::net::Ipv6Addr>().is_ok() {
34 format!("[{ip}]:{DNS_CONTAINER_PORT}")
35 } else {
36 format!("{ip}:{DNS_CONTAINER_PORT}")
37 }
38 })
39 .collect()
40}
41
42/// HTTP error with status code for retry logic.
43///
44/// This error type preserves the HTTP status code so we can determine
45/// if the error is retryable (429, 5xx) without parsing error strings.
46#[derive(Debug)]
47struct HttpError {
48 status: StatusCode,
49 message: String,
50}
51
52impl std::fmt::Display for HttpError {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 write!(f, "HTTP {}: {}", self.status, self.message)
55 }
56}
57
58impl std::error::Error for HttpError {}
59
60/// Extract the HTTP status code from an error, if it originated from a
61/// bindcar [`HttpError`].
62///
63/// Works even when the error has been wrapped in additional `anyhow` context
64/// (e.g. by `zone_status`), because `anyhow::Error::downcast_ref` sees
65/// through context layers. Never string-match on `e.to_string()` for status
66/// codes: it only prints the outermost context.
67fn bindcar_http_status(err: &anyhow::Error) -> Option<StatusCode> {
68 err.downcast_ref::<HttpError>()
69 .map(|http_err| http_err.status)
70}
71
72/// Returns `true` if the error is a bindcar HTTP 404 Not Found response.
73pub(crate) fn is_http_not_found(err: &anyhow::Error) -> bool {
74 bindcar_http_status(err) == Some(StatusCode::NOT_FOUND)
75}
76
77/// Returns `true` if the error is a bindcar HTTP 409 Conflict response.
78pub(crate) fn is_http_conflict(err: &anyhow::Error) -> bool {
79 bindcar_http_status(err) == Some(StatusCode::CONFLICT)
80}
81
82/// Returns `true` if a bindcar/BIND9 message indicates the zone already exists.
83///
84/// BIND9 can return various messages for duplicate zones:
85/// - "already exists" (including the BIND9 `zone X/IN: already exists` form)
86/// - "already serves the given zone"
87/// - "duplicate zone"
88fn is_zone_already_exists_message(message: &str) -> bool {
89 let msg = message.to_lowercase();
90 msg.contains("already exists")
91 || msg.contains("already serves")
92 || msg.contains("duplicate zone")
93}
94
95/// Returns `true` if the error indicates the zone already exists — either an
96/// HTTP 409 Conflict or a BIND9 "already exists"-style message.
97fn is_zone_already_exists_error(err: &anyhow::Error) -> bool {
98 is_http_conflict(err) || is_zone_already_exists_message(&err.to_string())
99}
100
101/// Build the API base URL from a server address
102///
103/// Converts "service-name.namespace.svc.cluster.local:8080" or "service-name:8080"
104/// to `<http://service-name.namespace.svc.cluster.local:8080>` or `<http://service-name:8080>`
105pub(crate) fn build_api_url(server: &str) -> String {
106 if server.starts_with("http://") || server.starts_with("https://") {
107 server.trim_end_matches('/').to_string()
108 } else {
109 format!("http://{}", server.trim_end_matches('/'))
110 }
111}
112
113/// Execute a request to the bindcar API with automatic retry.
114///
115/// This is the main entry point for all bindcar HTTP API calls. It wraps the internal
116/// `bindcar_request_internal` with exponential backoff retry logic.
117///
118/// # Retry Behavior
119/// - Retries on HTTP 429, 500, 502, 503, 504
120/// - Fails immediately on other 4xx errors
121/// - Max 2 minutes total retry time
122/// - Initial retry after 50ms, exponentially growing to max 10 seconds
123///
124/// # Arguments
125/// * `client` - HTTP client
126/// * `token` - Optional authentication token (None if auth disabled)
127/// * `method` - HTTP method (GET, POST, DELETE)
128/// * `url` - Full URL to the bindcar API endpoint
129/// * `body` - Optional JSON body for POST requests
130///
131/// # Errors
132///
133/// Returns an error if the HTTP request fails after all retries or encounters a non-retryable error.
134pub(crate) async fn bindcar_request<T: Serialize + std::fmt::Debug>(
135 client: &HttpClient,
136 token: Option<&str>,
137 method: &str,
138 url: &str,
139 body: Option<&T>,
140) -> Result<String> {
141 let mut backoff = http_backoff();
142 let start_time = Instant::now();
143 let mut attempt = 0;
144
145 loop {
146 attempt += 1;
147
148 let result = bindcar_request_internal(client, token, method, url, body).await;
149
150 match result {
151 Ok(response) => {
152 if attempt > 1 {
153 debug!(
154 method = %method,
155 url = %url,
156 attempt = attempt,
157 elapsed = ?start_time.elapsed(),
158 "HTTP API call succeeded after retries"
159 );
160 }
161 return Ok(response);
162 }
163 Err(e) => {
164 // Determine if the error is retryable by checking the error type
165 let mut is_retryable = false;
166
167 // Check if this is an HttpError (which contains the actual status code)
168 if let Some(http_err) = e.downcast_ref::<HttpError>() {
169 is_retryable = is_retryable_http_status(http_err.status);
170 } else {
171 // For non-HTTP errors, check if it's a network error
172 let error_msg = e.to_string();
173 if error_msg.contains("Failed to send") || error_msg.contains("connection") {
174 is_retryable = true;
175 }
176 }
177
178 if !is_retryable {
179 error!(
180 method = %method,
181 url = %url,
182 error = %e,
183 "Non-retryable HTTP API error, failing immediately"
184 );
185 return Err(e);
186 }
187
188 // Check if we've exceeded max elapsed time
189 if let Some(max_elapsed) = backoff.max_elapsed_time {
190 if start_time.elapsed() >= max_elapsed {
191 error!(
192 method = %method,
193 url = %url,
194 attempt = attempt,
195 elapsed = ?start_time.elapsed(),
196 error = %e,
197 "Max retry time exceeded, giving up"
198 );
199 return Err(anyhow::anyhow!(
200 "Max retry time exceeded after {attempt} attempts: {e}"
201 ));
202 }
203 }
204
205 // Calculate next backoff interval
206 if let Some(duration) = backoff.next_backoff() {
207 warn!(
208 method = %method,
209 url = %url,
210 attempt = attempt,
211 retry_after = ?duration,
212 error = %e,
213 "Retryable HTTP API error, will retry"
214 );
215 tokio::time::sleep(duration).await;
216 } else {
217 error!(
218 method = %method,
219 url = %url,
220 attempt = attempt,
221 elapsed = ?start_time.elapsed(),
222 error = %e,
223 "Backoff exhausted, giving up"
224 );
225 return Err(anyhow::anyhow!(
226 "Backoff exhausted after {attempt} attempts: {e}"
227 ));
228 }
229 }
230 }
231 }
232}
233
234/// Internal implementation of bindcar API requests without retry logic.
235///
236/// This function handles the actual HTTP communication. It should not be called directly;
237/// use `bindcar_request` instead, which wraps this with retry logic.
238///
239/// # Arguments
240/// * `client` - HTTP client
241/// * `token` - Optional authentication token (None if auth disabled)
242/// * `method` - HTTP method (GET, POST, DELETE)
243/// * `url` - Full URL to the bindcar API endpoint
244/// * `body` - Optional JSON body for POST requests
245///
246/// # Errors
247///
248/// Returns an error if the HTTP request fails or the API returns an error.
249async fn bindcar_request_internal<T: Serialize + std::fmt::Debug>(
250 client: &HttpClient,
251 token: Option<&str>,
252 method: &str,
253 url: &str,
254 body: Option<&T>,
255) -> Result<String> {
256 // Log the HTTP request
257 info!(
258 method = %method,
259 url = %url,
260 body = ?body,
261 auth_enabled = token.is_some(),
262 "HTTP API request to bindcar"
263 );
264
265 // Build the HTTP request
266 let mut request = match method {
267 "GET" => client.get(url),
268 "POST" => {
269 let mut req = client.post(url);
270 if let Some(body_data) = body {
271 req = req.json(body_data);
272 }
273 req
274 }
275 "PATCH" => {
276 let mut req = client.patch(url);
277 if let Some(body_data) = body {
278 req = req.json(body_data);
279 }
280 req
281 }
282 "DELETE" => client.delete(url),
283 _ => anyhow::bail!("Unsupported HTTP method: {method}"),
284 };
285
286 // Add Authorization header only if token is provided (auth enabled)
287 if let Some(token_value) = token {
288 request = request.header("Authorization", format!("Bearer {token_value}"));
289 }
290
291 // Execute the request
292 let response = request
293 .send()
294 .await
295 .context(format!("Failed to send HTTP request to {url}"))?;
296
297 let status = response.status();
298
299 // Handle error responses
300 if !status.is_success() {
301 let error_text = response
302 .text()
303 .await
304 .unwrap_or_else(|_| "Unknown error".to_string());
305 error!(
306 method = %method,
307 url = %url,
308 status = %status,
309 error = %error_text,
310 "HTTP API request failed"
311 );
312 return Err(HttpError {
313 status,
314 message: error_text,
315 }
316 .into());
317 }
318
319 // Read response body
320 let text = response
321 .text()
322 .await
323 .context("Failed to read response body")?;
324
325 info!(
326 method = %method,
327 url = %url,
328 status = %status,
329 response_len = text.len(),
330 "HTTP API request successful"
331 );
332
333 Ok(text)
334}
335
336/// Reload a specific zone via HTTP API.
337///
338/// This operation is idempotent - if the zone doesn't exist, it returns an error
339/// with a clear message indicating the zone was not found.
340///
341/// # Arguments
342/// * `client` - HTTP client
343/// * `token` - Optional authentication token (None if auth disabled)
344/// * `zone_name` - Name of the zone to reload
345/// * `server` - API server address (e.g., "bind9-primary-api:8080")
346///
347/// # Errors
348///
349/// Returns an error if the HTTP request fails or the zone cannot be reloaded.
350pub async fn reload_zone(
351 client: &Arc<HttpClient>,
352 token: Option<&str>,
353 zone_name: &str,
354 server: &str,
355) -> Result<()> {
356 let base_url = build_api_url(server);
357 let url = format!("{base_url}/api/v1/zones/{zone_name}/reload");
358
359 let result = bindcar_request(client, token, "POST", &url, None::<&()>).await;
360
361 match result {
362 Ok(_) => Ok(()),
363 Err(e) => {
364 let err_msg = e.to_string();
365 if err_msg.contains("not found") || err_msg.contains("does not exist") {
366 Err(anyhow::anyhow!("Zone {zone_name} not found on {server}"))
367 } else {
368 Err(e).context("Failed to reload zone")
369 }
370 }
371 }
372}
373
374/// Reload all zones via HTTP API.
375///
376/// # Errors
377///
378/// Returns an error if the HTTP request fails.
379pub async fn reload_all_zones(
380 client: &Arc<HttpClient>,
381 token: Option<&str>,
382 server: &str,
383) -> Result<()> {
384 let base_url = build_api_url(server);
385 let url = format!("{base_url}/api/v1/server/reload");
386
387 bindcar_request(client, token, "POST", &url, None::<&()>)
388 .await
389 .context("Failed to reload all zones")?;
390
391 Ok(())
392}
393
394/// Trigger zone transfer via HTTP API.
395///
396/// # Errors
397///
398/// Returns an error if the HTTP request fails or the zone transfer cannot be initiated.
399pub async fn retransfer_zone(
400 client: &Arc<HttpClient>,
401 token: Option<&str>,
402 zone_name: &str,
403 server: &str,
404) -> Result<()> {
405 let base_url = build_api_url(server);
406 let url = format!("{base_url}/api/v1/zones/{zone_name}/retransfer");
407
408 bindcar_request(client, token, "POST", &url, None::<&()>)
409 .await
410 .context("Failed to retransfer zone")?;
411
412 Ok(())
413}
414
415/// Freeze a zone to prevent dynamic updates via HTTP API.
416///
417/// # Errors
418///
419/// Returns an error if the HTTP request fails or the zone cannot be frozen.
420pub async fn freeze_zone(
421 client: &Arc<HttpClient>,
422 token: Option<&str>,
423 zone_name: &str,
424 server: &str,
425) -> Result<()> {
426 let base_url = build_api_url(server);
427 let url = format!("{base_url}/api/v1/zones/{zone_name}/freeze");
428
429 bindcar_request(client, token, "POST", &url, None::<&()>)
430 .await
431 .context("Failed to freeze zone")?;
432
433 Ok(())
434}
435
436/// Thaw a frozen zone to allow dynamic updates via HTTP API.
437///
438/// # Errors
439///
440/// Returns an error if the HTTP request fails or the zone cannot be thawed.
441pub async fn thaw_zone(
442 client: &Arc<HttpClient>,
443 token: Option<&str>,
444 zone_name: &str,
445 server: &str,
446) -> Result<()> {
447 let base_url = build_api_url(server);
448 let url = format!("{base_url}/api/v1/zones/{zone_name}/thaw");
449
450 bindcar_request(client, token, "POST", &url, None::<&()>)
451 .await
452 .context("Failed to thaw zone")?;
453
454 Ok(())
455}
456
457/// Get zone status via HTTP API.
458///
459/// # Errors
460///
461/// Returns an error if the HTTP request fails or the zone status cannot be retrieved.
462pub async fn zone_status(
463 client: &Arc<HttpClient>,
464 token: Option<&str>,
465 zone_name: &str,
466 server: &str,
467) -> Result<String> {
468 let base_url = build_api_url(server);
469 let url = format!("{base_url}/api/v1/zones/{zone_name}/status");
470
471 let status = bindcar_request(client, token, "GET", &url, None::<&()>)
472 .await
473 .context("Failed to get zone status")?;
474
475 Ok(status)
476}
477
478/// Check if a zone exists by trying to get its status.
479///
480/// Returns `Ok(true)` if the zone exists and can be queried, `Ok(false)` if the zone
481/// definitely does not exist (404), or `Err` for transient errors (rate limiting, network
482/// errors, server errors, etc.) that should be retried.
483///
484/// # Errors
485///
486/// Returns an error if:
487/// - The server is rate limiting requests (429 Too Many Requests)
488/// - Network connectivity issues occur
489/// - The server returns a 5xx error
490/// - Any other non-404 error occurs
491pub async fn zone_exists(
492 client: &Arc<HttpClient>,
493 token: Option<&str>,
494 zone_name: &str,
495 server: &str,
496) -> Result<bool> {
497 match zone_status(client, token, zone_name, server).await {
498 Ok(_) => {
499 debug!("Zone {zone_name} exists on {server}");
500 Ok(true)
501 }
502 // 404 Not Found - zone definitely doesn't exist. Inspect the typed
503 // error in the chain: zone_status wraps the HttpError with anyhow
504 // context, so string-matching on e.to_string() would never see "404".
505 Err(e) if is_http_not_found(&e) => {
506 debug!("Zone {zone_name} does not exist on {server}");
507 Ok(false)
508 }
509 // Rate limiting - should retry
510 Err(e) if bindcar_http_status(&e) == Some(StatusCode::TOO_MANY_REQUESTS) => {
511 error!("Rate limited while checking if zone {zone_name} exists on {server}: {e}");
512 Err(e).context("Rate limited while checking zone existence")
513 }
514 // Any other error is a transient failure that should be retried
515 Err(e) => {
516 error!("Error checking if zone {zone_name} exists on {server}: {e}");
517 Err(e).context("Failed to check zone existence")
518 }
519 }
520}
521
522/// Get server status via HTTP API.
523///
524/// # Errors
525///
526/// Returns an error if the HTTP request fails or the server status cannot be retrieved.
527pub async fn server_status(
528 client: &Arc<HttpClient>,
529 token: Option<&str>,
530 server: &str,
531) -> Result<String> {
532 let base_url = build_api_url(server);
533 let url = format!("{base_url}/api/v1/server/status");
534
535 let status = bindcar_request(client, token, "GET", &url, None::<&()>)
536 .await
537 .context("Failed to get server status")?;
538
539 Ok(status)
540}
541
542/// Add a new primary zone via HTTP API.
543///
544/// This operation is idempotent - if the zone already exists, it returns success
545/// without attempting to re-add it.
546///
547/// The zone is created with `allow-update` enabled for the TSIG key used by the operator.
548/// This allows dynamic DNS updates (RFC 2136) to add/update/delete records in the zone.
549///
550/// **Note:** This method creates a zone without initial content. For creating zones with
551/// initial SOA/NS records, use `create_zone_http()` instead.
552///
553/// # Arguments
554/// * `client` - HTTP client
555/// * `token` - Authentication token
556/// * `zone_name` - Name of the zone (e.g., "example.com")
557/// * `server` - API endpoint (e.g., "bind9-primary-api:8080")
558/// * `key_data` - RNDC key data (used for allow-update configuration)
559/// * `soa_record` - SOA record data
560/// * `name_servers` - Optional list of ALL authoritative nameserver hostnames (including primary from SOA)
561/// * `name_server_ips` - Optional map of nameserver hostnames to IP addresses for glue records
562/// * `secondary_ips` - Optional list of secondary server IPs for also-notify and allow-transfer
563///
564/// # Returns
565///
566/// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
567///
568/// # Errors
569///
570/// Returns an error if the HTTP request fails or the zone cannot be added.
571#[allow(
572 clippy::cast_possible_truncation,
573 clippy::cast_sign_loss,
574 clippy::too_many_arguments
575)]
576#[allow(clippy::implicit_hasher)]
577pub async fn add_primary_zone(
578 client: &Arc<HttpClient>,
579 token: Option<&str>,
580 zone_name: &str,
581 server: &str,
582 key_data: &RndcKeyData,
583 soa_record: &crate::crd::SOARecord,
584 name_servers: Option<&[String]>,
585 name_server_ips: Option<&HashMap<String, String>>,
586 secondary_ips: Option<&[String]>,
587 dnssec_policy: Option<&str>,
588) -> Result<bool> {
589 use bindcar::ZONE_TYPE_PRIMARY;
590
591 // Use the HTTP API to create a minimal zone
592 // Idempotency is handled in the error path below (lines 434-446)
593 // The bindcar API will handle zone file generation and allow-update configuration
594 let base_url = build_api_url(server);
595 let url = format!("{base_url}/api/v1/zones");
596
597 // Build list of all authoritative nameservers
598 // Priority: use provided name_servers list if available, otherwise fall back to primary NS from SOA
599 let all_name_servers = if let Some(ns_list) = name_servers {
600 ns_list.to_vec()
601 } else {
602 // Fallback: only primary NS from SOA
603 vec![soa_record.primary_ns.clone()]
604 };
605
606 // Log DNSSEC configuration if provided
607 if let Some(policy) = dnssec_policy {
608 info!("DNSSEC policy '{policy}' will be applied to zone {zone_name} on {server}");
609 }
610
611 // Create zone configuration using SOA record from DNSZone spec
612 let zone_config = ZoneConfig {
613 ttl: DEFAULT_DNS_RECORD_TTL_SECS as u32,
614 soa: SoaRecord {
615 primary_ns: soa_record.primary_ns.clone(),
616 admin_email: soa_record.admin_email.clone(),
617 serial: soa_record.serial as u32,
618 refresh: soa_record.refresh as u32,
619 retry: soa_record.retry as u32,
620 expire: soa_record.expire as u32,
621 negative_ttl: soa_record.negative_ttl as u32,
622 },
623 name_servers: all_name_servers,
624 name_server_ips: name_server_ips.cloned().unwrap_or_default(),
625 records: vec![],
626 // Configure zone transfers to secondary servers. NOTIFY targets the
627 // secondaries' operand port (5353); allow-transfer is a bare-IP ACL.
628 also_notify: secondary_ips.map(with_transfer_port),
629 allow_transfer: secondary_ips.map(<[String]>::to_vec),
630 // Primary zones don't have primaries field (only secondary zones do)
631 primaries: None,
632 // DNSSEC configuration (bindcar 0.6.0+)
633 dnssec_policy: dnssec_policy.map(String::from),
634 inline_signing: dnssec_policy.map(|_| true),
635 };
636
637 let request = CreateZoneRequest {
638 zone_name: zone_name.to_string(),
639 zone_type: ZONE_TYPE_PRIMARY.to_string(),
640 zone_config,
641 update_key_name: Some(key_data.name.clone()),
642 };
643
644 match bindcar_request(client, token, "POST", &url, Some(&request)).await {
645 Ok(_) => {
646 if let Some(ips) = secondary_ips {
647 info!(
648 "Added zone {zone_name} on {server} with allow-update for key {} and zone transfers configured for {} secondary server(s): {:?}",
649 key_data.name, ips.len(), ips
650 );
651 } else {
652 info!(
653 "Added zone {zone_name} on {server} with allow-update for key {} (no secondary servers)",
654 key_data.name
655 );
656 }
657 Ok(true)
658 }
659 Err(e) => {
660 // Handle "zone already exists" errors (HTTP 409 Conflict or a
661 // BIND9 duplicate-zone message) as success (idempotent)
662 if is_zone_already_exists_error(&e) {
663 info!("Zone {zone_name} already exists on {server} (HTTP 409 Conflict), treating as success");
664
665 // Zone exists - check if we need to update its configuration with secondary IPs
666 if let Some(ips) = secondary_ips {
667 if !ips.is_empty() {
668 info!(
669 "Zone {zone_name} already exists on {server}, updating also-notify and allow-transfer with {} secondary server(s)",
670 ips.len()
671 );
672 // Update the zone's also-notify and allow-transfer configuration
673 // This is critical when secondary pods restart and get new IPs
674 let _updated =
675 update_primary_zone(client, token, zone_name, server, ips).await?;
676 // IMPORTANT: Return Ok(false) because the zone was NOT newly added, it already existed
677 // Returning true here would trigger status updates and cause a reconciliation loop
678 return Ok(false);
679 }
680 }
681
682 Ok(false)
683 } else {
684 Err(e).context("Failed to add zone")
685 }
686 }
687 }
688}
689
690/// Update an existing primary zone's configuration via HTTP API.
691///
692/// Updates a zone's `also-notify` and `allow-transfer` configuration without
693/// deleting and re-adding the zone. This is used when secondary pod IPs change
694/// (e.g., after pod restart) to keep zone transfer ACLs up to date.
695///
696/// **Implementation:** Uses bindcar's PATCH endpoint introduced in v0.4.0.
697///
698/// # Arguments
699/// * `client` - HTTP client
700/// * `token` - Authentication token
701/// * `zone_name` - Name of the zone (e.g., "example.com")
702/// * `server` - API endpoint (e.g., "bind9-primary-api:8080")
703/// * `secondary_ips` - Updated list of secondary server IPs for also-notify and allow-transfer
704///
705/// # Returns
706///
707/// Returns `Ok(true)` if the zone was updated, `Ok(false)` if no update was needed.
708///
709/// # Errors
710///
711/// Returns an error if the HTTP request fails or the zone cannot be updated.
712pub async fn update_primary_zone(
713 client: &Arc<HttpClient>,
714 token: Option<&str>,
715 zone_name: &str,
716 server: &str,
717 secondary_ips: &[String],
718) -> Result<bool> {
719 // Define the update request structure
720 // IMPORTANT: Must match bindcar's ModifyZoneRequest which uses camelCase
721 #[derive(Serialize, Debug)]
722 #[serde(rename_all = "camelCase")]
723 struct ZoneUpdateRequest {
724 also_notify: Option<Vec<String>>,
725 allow_transfer: Option<Vec<String>>,
726 }
727
728 let base_url = build_api_url(server);
729 let url = format!("{base_url}/api/v1/zones/{zone_name}");
730
731 let update_request = ZoneUpdateRequest {
732 // NOTIFY targets the secondaries' operand port (5353); allow-transfer is
733 // a bare-IP ACL.
734 also_notify: Some(with_transfer_port(secondary_ips)),
735 allow_transfer: Some(secondary_ips.to_vec()),
736 };
737
738 info!(
739 "Updating zone {zone_name} on {server} with {} secondary server(s): {:?}",
740 secondary_ips.len(),
741 secondary_ips
742 );
743
744 // Use PATCH to update only the specified fields
745 match bindcar_request(client, token, "PATCH", &url, Some(&update_request)).await {
746 Ok(_) => {
747 info!(
748 "Successfully updated zone {zone_name} on {server} with also-notify and allow-transfer for {} secondary server(s)",
749 secondary_ips.len()
750 );
751 Ok(true)
752 }
753 // If the zone doesn't exist, we can't update it
754 Err(e) if is_http_not_found(&e) => {
755 debug!("Zone {zone_name} not found on {server}, cannot update");
756 Ok(false)
757 }
758 Err(e) => Err(e).context("Failed to update zone configuration"),
759 }
760}
761
762/// Add a secondary zone via HTTP API.
763///
764/// Creates a secondary zone configured to transfer from the specified primary servers.
765/// This is idempotent - if the zone already exists, it returns success without re-adding.
766///
767/// # Arguments
768/// * `client` - HTTP client
769/// * `token` - Authentication token
770/// * `zone_name` - Name of the zone (e.g., "example.com")
771/// * `server` - API endpoint of the secondary server (e.g., "bind9-secondary-api:8080")
772/// * `key_data` - RNDC key data
773/// * `primary_ips` - List of primary server IP addresses to transfer from
774///
775/// # Returns
776///
777/// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
778///
779/// # Errors
780///
781/// Returns an error if the HTTP request fails or the zone cannot be added.
782pub async fn add_secondary_zone(
783 client: &Arc<HttpClient>,
784 token: Option<&str>,
785 zone_name: &str,
786 server: &str,
787 key_data: &RndcKeyData,
788 primary_ips: &[String],
789) -> Result<bool> {
790 use bindcar::ZONE_TYPE_SECONDARY;
791
792 // Use the HTTP API to create a minimal secondary zone
793 // Idempotency is handled in the error path below (lines 609-616)
794 let base_url = build_api_url(server);
795 let url = format!("{base_url}/api/v1/zones");
796
797 // Create zone configuration for secondary zone with primaries.
798 // Secondary zones don't need SOA/NS records as they are transferred from
799 // the primary. The operand `named` listens on the unprivileged
800 // DNS_CONTAINER_PORT (5353), so each primary endpoint is port-qualified
801 // (`<ip>:5353`); bindcar (0.7.2+) parses this and renders BIND's
802 // `<ip> port 5353` primaries syntax.
803 let primaries: Vec<String> = with_transfer_port(primary_ips);
804
805 let zone_config = ZoneConfig {
806 ttl: DEFAULT_DNS_RECORD_TTL_SECS as u32,
807 soa: SoaRecord {
808 primary_ns: "placeholder.example.com.".to_string(),
809 admin_email: "admin.example.com.".to_string(),
810 serial: 1,
811 refresh: 3600,
812 retry: 600,
813 expire: 604_800,
814 negative_ttl: 86400,
815 },
816 name_servers: vec![],
817 name_server_ips: std::collections::HashMap::new(),
818 records: vec![],
819 also_notify: None,
820 allow_transfer: None,
821 primaries: Some(primaries),
822 // Secondary zones don't need DNSSEC policy (they receive signed zones via transfer)
823 dnssec_policy: None,
824 inline_signing: None,
825 };
826
827 let request = CreateZoneRequest {
828 zone_name: zone_name.to_string(),
829 zone_type: ZONE_TYPE_SECONDARY.to_string(),
830 zone_config,
831 update_key_name: Some(key_data.name.clone()),
832 };
833
834 match bindcar_request(client, token, "POST", &url, Some(&request)).await {
835 Ok(_) => {
836 info!(
837 "Added secondary zone {zone_name} on {server} with primaries: {:?}",
838 request.zone_config.primaries
839 );
840 Ok(true)
841 }
842 // Handle "zone already exists" errors (HTTP 409 Conflict or a BIND9
843 // duplicate-zone message) as success (idempotent)
844 Err(e) if is_zone_already_exists_error(&e) => {
845 info!("Zone {zone_name} already exists on {server} (HTTP 409 Conflict), treating as success");
846 Ok(false)
847 }
848 Err(e) => Err(e).context("Failed to add secondary zone"),
849 }
850}
851
852/// Add a zone via HTTP API (primary or secondary).
853///
854/// This is the centralized zone addition function that dispatches to either
855/// `add_primary_zone` or `add_secondary_zone` based on the zone type.
856///
857/// This operation is idempotent - if the zone already exists, it returns success
858/// without attempting to re-add it.
859///
860/// # Arguments
861/// * `client` - HTTP client
862/// * `token` - Authentication token
863/// * `zone_name` - Name of the zone (e.g., "example.com")
864/// * `zone_type` - Zone type (use `ZONE_TYPE_PRIMARY` or `ZONE_TYPE_SECONDARY` constants)
865/// * `server` - API endpoint (e.g., "bind9-primary-api:8080" or "bind9-secondary-api:8080")
866/// * `key_data` - RNDC key data
867/// * `soa_record` - Optional SOA record data (required for primary zones, ignored for secondary)
868/// * `name_servers` - Optional list of ALL authoritative nameserver hostnames (for primary zones)
869/// * `name_server_ips` - Optional map of nameserver hostnames to IP addresses (for primary zones)
870/// * `secondary_ips` - Optional list of secondary server IPs for also-notify and allow-transfer (for primary zones)
871/// * `primary_ips` - Optional list of primary server IPs to transfer from (for secondary zones)
872///
873/// # Returns
874///
875/// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
876///
877/// # Errors
878///
879/// Returns an error if:
880/// - The HTTP request fails
881/// - The zone cannot be added
882/// - For primary zones: SOA record is None
883/// - For secondary zones: `primary_ips` is None or empty
884#[allow(clippy::too_many_arguments)]
885#[allow(clippy::implicit_hasher)]
886pub async fn add_zones(
887 client: &Arc<HttpClient>,
888 token: Option<&str>,
889 zone_name: &str,
890 zone_type: &str,
891 server: &str,
892 key_data: &RndcKeyData,
893 soa_record: Option<&crate::crd::SOARecord>,
894 name_servers: Option<&[String]>,
895 name_server_ips: Option<&HashMap<String, String>>,
896 secondary_ips: Option<&[String]>,
897 primary_ips: Option<&[String]>,
898 dnssec_policy: Option<&str>,
899) -> Result<bool> {
900 use bindcar::{ZONE_TYPE_PRIMARY, ZONE_TYPE_SECONDARY};
901
902 match zone_type {
903 ZONE_TYPE_PRIMARY => {
904 let soa = soa_record
905 .ok_or_else(|| anyhow::anyhow!("SOA record is required for primary zones"))?;
906
907 add_primary_zone(
908 client,
909 token,
910 zone_name,
911 server,
912 key_data,
913 soa,
914 name_servers,
915 name_server_ips,
916 secondary_ips,
917 dnssec_policy,
918 )
919 .await
920 }
921 ZONE_TYPE_SECONDARY => {
922 let primaries = primary_ips
923 .ok_or_else(|| anyhow::anyhow!("Primary IPs are required for secondary zones"))?;
924
925 if primaries.is_empty() {
926 anyhow::bail!("Primary IPs list cannot be empty for secondary zones");
927 }
928
929 add_secondary_zone(client, token, zone_name, server, key_data, primaries).await
930 }
931 _ => anyhow::bail!("Invalid zone type: {zone_type}. Must be 'primary' or 'secondary'"),
932 }
933}
934
935/// Create a zone via HTTP API with structured configuration.
936///
937/// This method sends a POST request to the API sidecar (via the shared
938/// `bindcar_request` retry path, so it gets the same retry/backoff and
939/// timeout behavior as all other zone operations) to create a zone using
940/// structured zone configuration from the bindcar library.
941///
942/// This operation is idempotent: an HTTP 409 Conflict (or a BIND9
943/// "already exists"-style message) is treated as success.
944///
945/// # Arguments
946/// * `client` - HTTP client
947/// * `token` - Authentication token
948/// * `zone_name` - Name of the zone (e.g., "example.com")
949/// * `zone_type` - Zone type (use `ZONE_TYPE_PRIMARY` or `ZONE_TYPE_SECONDARY` constants)
950/// * `zone_config` - Structured zone configuration (converted to zone file by bindcar)
951/// * `server` - API endpoint (e.g., "bind9-primary-api:8080")
952/// * `key_data` - RNDC authentication key (used as updateKeyName)
953///
954/// # Errors
955///
956/// Returns an error if the HTTP request fails or the zone cannot be created.
957#[allow(clippy::too_many_arguments)]
958pub async fn create_zone_http(
959 client: &Arc<HttpClient>,
960 token: Option<&str>,
961 zone_name: &str,
962 zone_type: &str,
963 zone_config: ZoneConfig,
964 server: &str,
965 key_data: &RndcKeyData,
966) -> Result<()> {
967 let base_url = build_api_url(server);
968 let url = format!("{base_url}/api/v1/zones");
969
970 let request = CreateZoneRequest {
971 zone_name: zone_name.to_string(),
972 zone_type: zone_type.to_string(),
973 zone_config,
974 update_key_name: Some(key_data.name.clone()),
975 };
976
977 debug!(
978 zone_name = %zone_name,
979 zone_type = %zone_type,
980 server = %server,
981 "Creating zone via HTTP API"
982 );
983
984 let body = match bindcar_request(client, token, "POST", &url, Some(&request)).await {
985 Ok(body) => body,
986 // HTTP 409 Conflict (or an "already exists" message) means the zone
987 // is already present - treat as success (idempotent)
988 Err(e) if is_zone_already_exists_error(&e) => {
989 info!("Zone {zone_name} already exists on {server}, treating as success");
990 return Ok(());
991 }
992 Err(e) => {
993 error!(
994 zone_name = %zone_name,
995 server = %server,
996 error = %e,
997 "Failed to create zone via HTTP API"
998 );
999 return Err(e)
1000 .with_context(|| format!("Failed to create zone '{zone_name}' via HTTP API"));
1001 }
1002 };
1003
1004 let result: ZoneResponse =
1005 serde_json::from_str(&body).context("Failed to parse API response")?;
1006
1007 if !result.success {
1008 // Check if the error message indicates zone already exists (idempotent)
1009 if is_zone_already_exists_message(&result.message) {
1010 info!("Zone {zone_name} already exists on {server} (detected via API response), treating as success");
1011 return Ok(());
1012 }
1013
1014 error!(
1015 zone_name = %zone_name,
1016 server = %server,
1017 message = %result.message,
1018 details = ?result.details,
1019 "API returned error when creating zone"
1020 );
1021 anyhow::bail!("Failed to create zone '{}': {}", zone_name, result.message);
1022 }
1023
1024 info!(
1025 zone_name = %zone_name,
1026 server = %server,
1027 message = %result.message,
1028 "Zone created successfully via HTTP API"
1029 );
1030
1031 Ok(())
1032}
1033
1034/// Delete a zone via HTTP API.
1035///
1036/// # Arguments
1037/// * `client` - HTTP client
1038/// * `token` - Authentication token
1039/// * `zone_name` - Name of the zone to delete
1040/// * `server` - API server address
1041/// * `freeze_before_delete` - Whether to freeze the zone before deletion (true for primary zones, false for secondary zones)
1042///
1043/// # Errors
1044///
1045/// Returns an error if the HTTP request fails or the zone cannot be deleted.
1046pub async fn delete_zone(
1047 client: &Arc<HttpClient>,
1048 token: Option<&str>,
1049 zone_name: &str,
1050 server: &str,
1051 freeze_before_delete: bool,
1052) -> Result<()> {
1053 // Freeze the zone before deletion if requested (only for primary zones)
1054 // Secondary zones should NOT be frozen as they are read-only
1055 if freeze_before_delete {
1056 if let Err(e) = freeze_zone(client, token, zone_name, server).await {
1057 debug!(
1058 "Failed to freeze zone {} before deletion (zone may not exist): {}",
1059 zone_name, e
1060 );
1061 }
1062 }
1063
1064 let base_url = build_api_url(server);
1065 let url = format!("{base_url}/api/v1/zones/{zone_name}");
1066
1067 // Attempt to delete the zone - treat "not found" as success (idempotent)
1068 match bindcar_request(client, token, "DELETE", &url, None::<&()>).await {
1069 Ok(_) => {
1070 info!("Deleted zone {zone_name} from {server}");
1071 Ok(())
1072 }
1073 // If the zone doesn't exist, consider it already deleted (idempotent)
1074 Err(e) if is_http_not_found(&e) => {
1075 debug!("Zone {zone_name} already deleted from {server}");
1076 Ok(())
1077 }
1078 Err(e) => Err(e).context("Failed to delete zone"),
1079 }
1080}
1081
1082/// Notify secondaries about zone changes via HTTP API.
1083///
1084/// # Errors
1085///
1086/// Returns an error if the HTTP request fails or the notification cannot be sent.
1087pub async fn notify_zone(
1088 client: &Arc<HttpClient>,
1089 token: Option<&str>,
1090 zone_name: &str,
1091 server: &str,
1092) -> Result<()> {
1093 let base_url = build_api_url(server);
1094 let url = format!("{base_url}/api/v1/zones/{zone_name}/notify");
1095
1096 bindcar_request(client, token, "POST", &url, None::<&()>)
1097 .await
1098 .context("Failed to notify zone")?;
1099
1100 info!("Notified secondaries for zone {zone_name} from {server}");
1101 Ok(())
1102}
1103
1104/// Verify that a zone is signed with DNSSEC by querying for DNSKEY records.
1105///
1106/// This function performs a DNS query to check if the zone has been signed
1107/// with DNSSEC. It queries for DNSKEY records, which are present in signed zones.
1108///
1109/// # Arguments
1110///
1111/// * `zone_name` - The DNS zone name to verify (e.g., "example.com")
1112/// * `server` - The DNS server address (e.g., "bind9-primary.bindy-system.svc.cluster.local:53")
1113///
1114/// # Returns
1115///
1116/// * `Ok(true)` - Zone is signed (DNSKEY records found)
1117/// * `Ok(false)` - Zone is not signed (no DNSKEY records)
1118/// * `Err(_)` - Query failed (network error, invalid zone name, etc.)
1119///
1120/// # Errors
1121///
1122/// Returns an error if:
1123/// - The DNS server address cannot be parsed
1124/// - The zone name is invalid
1125/// - The DNS query fails (network error, timeout, etc.)
1126///
1127/// # Example
1128///
1129/// ```no_run
1130/// use bindy::bind9::zone_ops::verify_zone_signed;
1131///
1132/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1133/// let signed = verify_zone_signed(
1134/// "example.com",
1135/// "10.0.0.1:53"
1136/// ).await?;
1137///
1138/// if signed {
1139/// println!("Zone is signed with DNSSEC");
1140/// } else {
1141/// println!("Zone is not signed");
1142/// }
1143/// # Ok(())
1144/// # }
1145/// ```
1146pub async fn verify_zone_signed(zone_name: &str, server: &str) -> Result<bool> {
1147 use hickory_net::client::{Client, ClientHandle};
1148 use hickory_net::runtime::TokioRuntimeProvider;
1149 use hickory_net::udp::UdpClientStream;
1150 use hickory_proto::rr::{DNSClass, Name, RecordType};
1151 use std::net::SocketAddr;
1152 use std::str::FromStr;
1153
1154 // Parse server address
1155 let server_addr: SocketAddr = server
1156 .parse()
1157 .with_context(|| format!("Invalid DNS server address: {server}"))?;
1158
1159 debug!(
1160 "Verifying DNSSEC signing for zone {} on {}",
1161 zone_name, server_addr
1162 );
1163
1164 // Create UDP client connection (unauthenticated read-only query).
1165 let stream = UdpClientStream::builder(server_addr, TokioRuntimeProvider::default()).build();
1166 let (mut client, bg) = Client::<TokioRuntimeProvider>::from_sender(stream);
1167
1168 // Spawn the background task that drives the connection.
1169 tokio::spawn(bg);
1170
1171 // Parse zone name
1172 let name =
1173 Name::from_str(zone_name).with_context(|| format!("Invalid zone name: {zone_name}"))?;
1174
1175 // Query for DNSKEY records
1176 let response = client
1177 .query(name.clone(), DNSClass::IN, RecordType::DNSKEY)
1178 .await
1179 .with_context(|| {
1180 format!("Failed to query DNSKEY records for zone {zone_name} on {server_addr}")
1181 })?;
1182
1183 // If we got DNSKEY records, the zone is signed
1184 let is_signed = !response.answers.is_empty();
1185
1186 if is_signed {
1187 debug!(
1188 "Zone {} is signed with DNSSEC (found {} DNSKEY record(s))",
1189 zone_name,
1190 response.answers.len()
1191 );
1192 } else {
1193 debug!(
1194 "Zone {} is not signed with DNSSEC (no DNSKEY records found)",
1195 zone_name
1196 );
1197 }
1198
1199 Ok(is_signed)
1200}
1201
1202#[cfg(test)]
1203#[path = "zone_ops_tests.rs"]
1204mod zone_ops_tests;