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;
19use crate::reconcilers::retry::{http_backoff, is_retryable_http_status};
20
21/// HTTP error with status code for retry logic.
22///
23/// This error type preserves the HTTP status code so we can determine
24/// if the error is retryable (429, 5xx) without parsing error strings.
25#[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
39/// Build the API base URL from a server address
40///
41/// Converts "service-name.namespace.svc.cluster.local:8080" or "service-name:8080"
42/// to `<http://service-name.namespace.svc.cluster.local:8080>` or `<http://service-name:8080>`
43pub(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
51/// Execute a request to the bindcar API with automatic retry.
52///
53/// This is the main entry point for all bindcar HTTP API calls. It wraps the internal
54/// `bindcar_request_internal` with exponential backoff retry logic.
55///
56/// # Retry Behavior
57/// - Retries on HTTP 429, 500, 502, 503, 504
58/// - Fails immediately on other 4xx errors
59/// - Max 2 minutes total retry time
60/// - Initial retry after 50ms, exponentially growing to max 10 seconds
61///
62/// # Arguments
63/// * `client` - HTTP client
64/// * `token` - Optional authentication token (None if auth disabled)
65/// * `method` - HTTP method (GET, POST, DELETE)
66/// * `url` - Full URL to the bindcar API endpoint
67/// * `body` - Optional JSON body for POST requests
68///
69/// # Errors
70///
71/// Returns an error if the HTTP request fails after all retries or encounters a non-retryable error.
72pub(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                // Determine if the error is retryable by checking the error type
103                let mut is_retryable = false;
104
105                // Check if this is an HttpError (which contains the actual status code)
106                if let Some(http_err) = e.downcast_ref::<HttpError>() {
107                    is_retryable = is_retryable_http_status(http_err.status);
108                } else {
109                    // For non-HTTP errors, check if it's a network error
110                    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                // Check if we've exceeded max elapsed time
127                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                // Calculate next backoff interval
144                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
172/// Internal implementation of bindcar API requests without retry logic.
173///
174/// This function handles the actual HTTP communication. It should not be called directly;
175/// use `bindcar_request` instead, which wraps this with retry logic.
176///
177/// # Arguments
178/// * `client` - HTTP client
179/// * `token` - Optional authentication token (None if auth disabled)
180/// * `method` - HTTP method (GET, POST, DELETE)
181/// * `url` - Full URL to the bindcar API endpoint
182/// * `body` - Optional JSON body for POST requests
183///
184/// # Errors
185///
186/// Returns an error if the HTTP request fails or the API returns an error.
187async 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    // Log the HTTP request
195    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    // Build the HTTP request
204    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    // Add Authorization header only if token is provided (auth enabled)
225    if let Some(token_value) = token {
226        request = request.header("Authorization", format!("Bearer {token_value}"));
227    }
228
229    // Execute the request
230    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    // Handle error responses
238    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    // Read response body
258    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
274/// Reload a specific zone via HTTP API.
275///
276/// This operation is idempotent - if the zone doesn't exist, it returns an error
277/// with a clear message indicating the zone was not found.
278///
279/// # Arguments
280/// * `client` - HTTP client
281/// * `token` - Optional authentication token (None if auth disabled)
282/// * `zone_name` - Name of the zone to reload
283/// * `server` - API server address (e.g., "bind9-primary-api:8080")
284///
285/// # Errors
286///
287/// Returns an error if the HTTP request fails or the zone cannot be reloaded.
288pub 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
312/// Reload all zones via HTTP API.
313///
314/// # Errors
315///
316/// Returns an error if the HTTP request fails.
317pub 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
332/// Trigger zone transfer via HTTP API.
333///
334/// # Errors
335///
336/// Returns an error if the HTTP request fails or the zone transfer cannot be initiated.
337pub 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
353/// Freeze a zone to prevent dynamic updates via HTTP API.
354///
355/// # Errors
356///
357/// Returns an error if the HTTP request fails or the zone cannot be frozen.
358pub 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
374/// Thaw a frozen zone to allow dynamic updates via HTTP API.
375///
376/// # Errors
377///
378/// Returns an error if the HTTP request fails or the zone cannot be thawed.
379pub 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
395/// Get zone status via HTTP API.
396///
397/// # Errors
398///
399/// Returns an error if the HTTP request fails or the zone status cannot be retrieved.
400pub 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
416/// Check if a zone exists by trying to get its status.
417///
418/// Returns `Ok(true)` if the zone exists and can be queried, `Ok(false)` if the zone
419/// definitely does not exist (404), or `Err` for transient errors (rate limiting, network
420/// errors, server errors, etc.) that should be retried.
421///
422/// # Errors
423///
424/// Returns an error if:
425/// - The server is rate limiting requests (429 Too Many Requests)
426/// - Network connectivity issues occur
427/// - The server returns a 5xx error
428/// - Any other non-404 error occurs
429pub 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            // 404 Not Found - zone definitely doesn't exist
444            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            // Rate limiting - should retry
450            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            // Any other error is a transient failure that should be retried
456            error!("Error checking if zone {zone_name} exists on {server}: {e}");
457            Err(e).context("Failed to check zone existence")
458        }
459    }
460}
461
462/// Get server status via HTTP API.
463///
464/// # Errors
465///
466/// Returns an error if the HTTP request fails or the server status cannot be retrieved.
467pub 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/// Add a new primary zone via HTTP API.
483///
484/// This operation is idempotent - if the zone already exists, it returns success
485/// without attempting to re-add it.
486///
487/// The zone is created with `allow-update` enabled for the TSIG key used by the operator.
488/// This allows dynamic DNS updates (RFC 2136) to add/update/delete records in the zone.
489///
490/// **Note:** This method creates a zone without initial content. For creating zones with
491/// initial SOA/NS records, use `create_zone_http()` instead.
492///
493/// # Arguments
494/// * `client` - HTTP client
495/// * `token` - Authentication token
496/// * `zone_name` - Name of the zone (e.g., "example.com")
497/// * `server` - API endpoint (e.g., "bind9-primary-api:8080")
498/// * `key_data` - RNDC key data (used for allow-update configuration)
499/// * `soa_record` - SOA record data
500/// * `name_servers` - Optional list of ALL authoritative nameserver hostnames (including primary from SOA)
501/// * `name_server_ips` - Optional map of nameserver hostnames to IP addresses for glue records
502/// * `secondary_ips` - Optional list of secondary server IPs for also-notify and allow-transfer
503///
504/// # Returns
505///
506/// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
507///
508/// # Errors
509///
510/// Returns an error if the HTTP request fails or the zone cannot be added.
511#[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    // Use the HTTP API to create a minimal zone
532    // Idempotency is handled in the error path below (lines 434-446)
533    // The bindcar API will handle zone file generation and allow-update configuration
534    let base_url = build_api_url(server);
535    let url = format!("{base_url}/api/v1/zones");
536
537    // Build list of all authoritative nameservers
538    // Priority: use provided name_servers list if available, otherwise fall back to primary NS from SOA
539    let all_name_servers = if let Some(ns_list) = name_servers {
540        ns_list.to_vec()
541    } else {
542        // Fallback: only primary NS from SOA
543        vec![soa_record.primary_ns.clone()]
544    };
545
546    // Log DNSSEC configuration if provided
547    if let Some(policy) = dnssec_policy {
548        info!("DNSSEC policy '{policy}' will be applied to zone {zone_name} on {server}");
549    }
550
551    // Create zone configuration using SOA record from DNSZone spec
552    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        // Configure zone transfers to secondary servers
567        also_notify: secondary_ips.map(<[String]>::to_vec),
568        allow_transfer: secondary_ips.map(<[String]>::to_vec),
569        // Primary zones don't have primaries field (only secondary zones do)
570        primaries: None,
571        // DNSSEC configuration (bindcar 0.6.0+)
572        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            // Handle "zone already exists" errors as success (idempotent)
600            // Check if this is an HttpError with 409 Conflict status code
601            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            // BIND9 can return various messages for duplicate zones:
607            // - "already exists"
608            // - "already serves the given zone"
609            // - "duplicate zone"
610            // - "zone X/IN: already exists" (BIND9 format)
611            // HTTP 409 Conflict means the zone already exists
612            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                // Zone exists - check if we need to update its configuration with secondary IPs
620                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                        // Update the zone's also-notify and allow-transfer configuration
627                        // This is critical when secondary pods restart and get new IPs
628                        let _updated =
629                            update_primary_zone(client, token, zone_name, server, ips).await?;
630                        // IMPORTANT: Return Ok(false) because the zone was NOT newly added, it already existed
631                        // Returning true here would trigger status updates and cause a reconciliation loop
632                        return Ok(false);
633                    }
634                }
635
636                Ok(false)
637            } else {
638                Err(e).context("Failed to add zone")
639            }
640        }
641    }
642}
643
644/// Update an existing primary zone's configuration via HTTP API.
645///
646/// Updates a zone's `also-notify` and `allow-transfer` configuration without
647/// deleting and re-adding the zone. This is used when secondary pod IPs change
648/// (e.g., after pod restart) to keep zone transfer ACLs up to date.
649///
650/// **Implementation:** Uses bindcar's PATCH endpoint introduced in v0.4.0.
651///
652/// # Arguments
653/// * `client` - HTTP client
654/// * `token` - Authentication token
655/// * `zone_name` - Name of the zone (e.g., "example.com")
656/// * `server` - API endpoint (e.g., "bind9-primary-api:8080")
657/// * `secondary_ips` - Updated list of secondary server IPs for also-notify and allow-transfer
658///
659/// # Returns
660///
661/// Returns `Ok(true)` if the zone was updated, `Ok(false)` if no update was needed.
662///
663/// # Errors
664///
665/// Returns an error if the HTTP request fails or the zone cannot be updated.
666pub 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    // Define the update request structure
674    // IMPORTANT: Must match bindcar's ModifyZoneRequest which uses camelCase
675    #[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    // Use PATCH to update only the specified fields
697    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 the zone doesn't exist, we can't update it
708            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
718/// Add a secondary zone via HTTP API.
719///
720/// Creates a secondary zone configured to transfer from the specified primary servers.
721/// This is idempotent - if the zone already exists, it returns success without re-adding.
722///
723/// # Arguments
724/// * `client` - HTTP client
725/// * `token` - Authentication token
726/// * `zone_name` - Name of the zone (e.g., "example.com")
727/// * `server` - API endpoint of the secondary server (e.g., "bind9-secondary-api:8080")
728/// * `key_data` - RNDC key data
729/// * `primary_ips` - List of primary server IP addresses to transfer from
730///
731/// # Returns
732///
733/// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
734///
735/// # Errors
736///
737/// Returns an error if the HTTP request fails or the zone cannot be added.
738pub 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    // Use the HTTP API to create a minimal secondary zone
749    // Idempotency is handled in the error path below (lines 609-616)
750    let base_url = build_api_url(server);
751    let url = format!("{base_url}/api/v1/zones");
752
753    // Create zone configuration for secondary zone with primaries
754    // Secondary zones don't need SOA/NS records as they are transferred from primary
755    // CRITICAL: Append port number to primary IPs for BIND9 zone transfers
756    // BIND9 defaults to port 53, but we use DNS_CONTAINER_PORT (5353)
757    // Format: "IP port PORT" (e.g., "10.244.1.82 port 5353")
758    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        // Secondary zones don't need DNSSEC policy (they receive signed zones via transfer)
781        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            // Handle "zone already exists" errors as success (idempotent)
802            // Check if this is an HttpError with 409 Conflict status code
803            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            // BIND9 can return various messages for duplicate zones:
809            // - "already exists"
810            // - "already serves the given zone"
811            // - "duplicate zone"
812            // - "zone X/IN: already exists" (BIND9 format)
813            // HTTP 409 Conflict means the zone already exists
814            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/// Add a zone via HTTP API (primary or secondary).
829///
830/// This is the centralized zone addition function that dispatches to either
831/// `add_primary_zone` or `add_secondary_zone` based on the zone type.
832///
833/// This operation is idempotent - if the zone already exists, it returns success
834/// without attempting to re-add it.
835///
836/// # Arguments
837/// * `client` - HTTP client
838/// * `token` - Authentication token
839/// * `zone_name` - Name of the zone (e.g., "example.com")
840/// * `zone_type` - Zone type (use `ZONE_TYPE_PRIMARY` or `ZONE_TYPE_SECONDARY` constants)
841/// * `server` - API endpoint (e.g., "bind9-primary-api:8080" or "bind9-secondary-api:8080")
842/// * `key_data` - RNDC key data
843/// * `soa_record` - Optional SOA record data (required for primary zones, ignored for secondary)
844/// * `name_servers` - Optional list of ALL authoritative nameserver hostnames (for primary zones)
845/// * `name_server_ips` - Optional map of nameserver hostnames to IP addresses (for primary zones)
846/// * `secondary_ips` - Optional list of secondary server IPs for also-notify and allow-transfer (for primary zones)
847/// * `primary_ips` - Optional list of primary server IPs to transfer from (for secondary zones)
848///
849/// # Returns
850///
851/// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
852///
853/// # Errors
854///
855/// Returns an error if:
856/// - The HTTP request fails
857/// - The zone cannot be added
858/// - For primary zones: SOA record is None
859/// - For secondary zones: `primary_ips` is None or empty
860#[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/// Create a zone via HTTP API with structured configuration.
912///
913/// This method sends a POST request to the API sidecar to create a zone using
914/// structured zone configuration from the bindcar library.
915///
916/// # Arguments
917/// * `client` - HTTP client
918/// * `token` - Authentication token
919/// * `zone_name` - Name of the zone (e.g., "example.com")
920/// * `zone_type` - Zone type (use `ZONE_TYPE_PRIMARY` or `ZONE_TYPE_SECONDARY` constants)
921/// * `zone_config` - Structured zone configuration (converted to zone file by bindcar)
922/// * `server` - API endpoint (e.g., "bind9-primary-api:8080")
923/// * `key_data` - RNDC authentication key (used as updateKeyName)
924///
925/// # Errors
926///
927/// Returns an error if the HTTP request fails or the zone cannot be created.
928#[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    // Check if zone already exists (idempotent)
940    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            // Zone doesn't exist, proceed with creation
947        }
948        Err(e) => {
949            // Can't check zone existence (rate limiting, network error, etc.)
950            // Propagate the error rather than blindly attempting creation
951            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    // Add Authorization header only if token is provided
978    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        // Check if it's a "zone already exists" error (idempotent)
996        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 we can't check zone existence due to rate limiting or other errors, propagate that
1009        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        // Check if the error message indicates zone already exists (idempotent)
1030        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 we can't check zone existence due to rate limiting or other errors, propagate that
1042        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
1067/// Delete a zone via HTTP API.
1068///
1069/// # Arguments
1070/// * `client` - HTTP client
1071/// * `token` - Authentication token
1072/// * `zone_name` - Name of the zone to delete
1073/// * `server` - API server address
1074/// * `freeze_before_delete` - Whether to freeze the zone before deletion (true for primary zones, false for secondary zones)
1075///
1076/// # Errors
1077///
1078/// Returns an error if the HTTP request fails or the zone cannot be deleted.
1079pub 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    // Freeze the zone before deletion if requested (only for primary zones)
1087    // Secondary zones should NOT be frozen as they are read-only
1088    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    // Attempt to delete the zone - treat "not found" as success (idempotent)
1101    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 the zone doesn't exist, consider it already deleted (idempotent)
1109            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
1119/// Notify secondaries about zone changes via HTTP API.
1120///
1121/// # Errors
1122///
1123/// Returns an error if the HTTP request fails or the notification cannot be sent.
1124pub 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
1141/// Verify that a zone is signed with DNSSEC by querying for DNSKEY records.
1142///
1143/// This function performs a DNS query to check if the zone has been signed
1144/// with DNSSEC. It queries for DNSKEY records, which are present in signed zones.
1145///
1146/// # Arguments
1147///
1148/// * `zone_name` - The DNS zone name to verify (e.g., "example.com")
1149/// * `server` - The DNS server address (e.g., "bind9-primary.bindy-system.svc.cluster.local:5353")
1150///
1151/// # Returns
1152///
1153/// * `Ok(true)` - Zone is signed (DNSKEY records found)
1154/// * `Ok(false)` - Zone is not signed (no DNSKEY records)
1155/// * `Err(_)` - Query failed (network error, invalid zone name, etc.)
1156///
1157/// # Errors
1158///
1159/// Returns an error if:
1160/// - The DNS server address cannot be parsed
1161/// - The zone name is invalid
1162/// - The DNS query fails (network error, timeout, etc.)
1163///
1164/// # Example
1165///
1166/// ```no_run
1167/// use bindy::bind9::zone_ops::verify_zone_signed;
1168///
1169/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1170/// let signed = verify_zone_signed(
1171///     "example.com",
1172///     "10.0.0.1:5353"
1173/// ).await?;
1174///
1175/// if signed {
1176///     println!("Zone is signed with DNSSEC");
1177/// } else {
1178///     println!("Zone is not signed");
1179/// }
1180/// # Ok(())
1181/// # }
1182/// ```
1183pub 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    // Parse server address
1191    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    // Create UDP client connection
1201    let stream = UdpClientStream::<tokio::net::UdpSocket>::new(server_addr);
1202    let (mut client, bg) = AsyncClient::connect(stream).await?;
1203
1204    // Spawn the background task
1205    tokio::spawn(bg);
1206
1207    // Parse zone name
1208    let name =
1209        Name::from_str(zone_name).with_context(|| format!("Invalid zone name: {zone_name}"))?;
1210
1211    // Query for DNSKEY records
1212    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    // If we got DNSKEY records, the zone is signed
1220    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;