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;