bindy/bind9/
mod.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! BIND9 management via HTTP API sidecar.
5//!
6//! This module provides functionality for managing BIND9 servers using an
7//! HTTP API sidecar container that executes rndc commands locally. It handles:
8//!
9//! - Creating and managing DNS zones via the HTTP API
10//! - Adding and updating DNS zones via dynamic updates (nsupdate protocol)
11//! - Reloading zones after changes
12//! - Managing zone transfers
13//! - RNDC key generation and management
14//!
15//! # Architecture
16//!
17//! The `Bind9Manager` communicates with BIND9 instances via an HTTP API sidecar
18//! running in the same pod. The sidecar executes rndc commands locally and manages
19//! zone files. Authentication uses Kubernetes `ServiceAccount` tokens.
20//!
21//! # Example
22//!
23//! ```rust,no_run
24//! use bindy::bind9::Bind9Manager;
25//!
26//! # async fn example() -> anyhow::Result<()> {
27//! let manager = Bind9Manager::new();
28//!
29//! // Manage zones via HTTP API
30//! manager.reload_zone(
31//!     "example.com",
32//!     "bind9-primary-api.bindy-system.svc.cluster.local:8080"
33//! ).await?;
34//! # Ok(())
35//! # }
36//! ```
37
38// Module declarations
39pub mod duration;
40pub mod records;
41pub mod rndc;
42pub mod types;
43pub mod zone_ops;
44
45// Re-export public types and functions for backwards compatibility
46pub use rndc::{
47    create_rndc_secret_data, create_tsig_signer, generate_rndc_key, parse_rndc_secret_data,
48};
49pub use types::{
50    PTRRecordData, RndcError, RndcKeyData, SRVRecordData, BINDCAR_TOKEN_PATH,
51    SERVICE_ACCOUNT_TOKEN_PATH,
52};
53
54use anyhow::{Context, Result};
55use bindcar::ZoneConfig;
56use k8s_openapi::api::apps::v1::Deployment;
57use reqwest::Client as HttpClient;
58use std::collections::HashMap;
59use std::sync::{Arc, RwLock};
60use std::time::{Duration, Instant};
61use tracing::{debug, warn};
62
63/// Environment variable name that indicates bindcar authentication is enabled.
64/// If this env var is present in the bindcar container, authentication is required.
65const BINDCAR_AUTH_ENV_VAR: &str = "BIND_ALLOWED_SERVICE_ACCOUNTS";
66
67/// Name of the bindcar sidecar container in `Bind9Instance` deployments.
68///
69/// Must match [`crate::constants::CONTAINER_NAME_BINDCAR`] — the operator names
70/// the sidecar `api` (not `bindcar`), so this is what `is_auth_enabled` looks
71/// for when inspecting the operand Deployment.
72const BINDCAR_CONTAINER_NAME: &str = crate::constants::CONTAINER_NAME_BINDCAR;
73
74/// How long a `ServiceAccount` token read from disk may be served from the
75/// in-memory cache before it is re-read (seconds).
76///
77/// The operator's bindcar-audience token is projected with
78/// `expirationSeconds: 3600` (see `deploy/operator/deployment.yaml`) and the
79/// kubelet rewrites the token file at roughly 80% of that lifetime. Caching
80/// the token for the process lifetime therefore presents an **expired** token
81/// after ~1h, which bindcar's TokenReview rejects with a non-retryable 401.
82/// Re-reading every 5 minutes keeps the presented token far fresher than the
83/// rotation window; the file lives on a tmpfs projected volume, so the re-read
84/// is cheap.
85const TOKEN_CACHE_TTL_SECS: u64 = 300;
86
87/// Connect timeout for bindcar HTTP API requests.
88///
89/// Without a connect timeout, a blackholed pod IP (e.g. a stale Endpoints
90/// entry) hangs a reconcile task indefinitely — the retry/backoff in
91/// `zone_ops::bindcar_request` never fires because attempt #1 never returns.
92const BINDCAR_HTTP_CONNECT_TIMEOUT_SECS: u64 = 5;
93
94/// Total request timeout (connect + transfer) for bindcar HTTP API requests.
95///
96/// Bounded well below `zone_ops::bindcar_request`'s 120s max retry window so
97/// a slow attempt still leaves room for retries.
98const BINDCAR_HTTP_REQUEST_TIMEOUT_SECS: u64 = 30;
99
100/// A `ServiceAccount` token (or the absence of one) read from disk, together
101/// with the time it was read.
102///
103/// `token: None` means the token file could not be read (e.g. auth-disabled
104/// or out-of-cluster deployments); the negative result is cached too, so a
105/// missing file is not re-read on every request within the TTL.
106struct CachedToken {
107    /// The token contents, or `None` if no token file was readable.
108    token: Option<String>,
109    /// When the token file was last read.
110    read_at: Instant,
111}
112
113// Custom Debug implementation to prevent logging the token in cleartext
114impl std::fmt::Debug for CachedToken {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("CachedToken")
117            .field("token", &self.token.as_ref().map(|_| "<redacted>"))
118            .field("read_at", &self.read_at)
119            .finish()
120    }
121}
122
123/// Returns `true` while a token read at `read_at` may still be served from
124/// cache at `now` (i.e. it is younger than `TOKEN_CACHE_TTL_SECS`).
125fn is_token_cache_fresh(read_at: Instant, now: Instant) -> bool {
126    now.saturating_duration_since(read_at) < Duration::from_secs(TOKEN_CACHE_TTL_SECS)
127}
128
129/// Build the shared bindcar HTTP client with connect and request timeouts.
130fn build_http_client() -> HttpClient {
131    build_http_client_with_timeouts(
132        Duration::from_secs(BINDCAR_HTTP_CONNECT_TIMEOUT_SECS),
133        Duration::from_secs(BINDCAR_HTTP_REQUEST_TIMEOUT_SECS),
134    )
135}
136
137/// Build an HTTP client with the given connect and total-request timeouts.
138///
139/// Falls back to the default client (no timeouts) if the builder fails, so
140/// manager construction never panics; the failure is logged.
141fn build_http_client_with_timeouts(
142    connect_timeout: Duration,
143    request_timeout: Duration,
144) -> HttpClient {
145    match HttpClient::builder()
146        .connect_timeout(connect_timeout)
147        .timeout(request_timeout)
148        .build()
149    {
150        Ok(client) => client,
151        Err(e) => {
152            warn!(
153                error = %e,
154                "Failed to build HTTP client with timeouts; falling back to default client without timeouts"
155            );
156            HttpClient::new()
157        }
158    }
159}
160
161/// Manager for BIND9 servers via HTTP API sidecar.
162///
163/// The `Bind9Manager` provides methods for managing BIND9 servers running in Kubernetes
164/// pods via an HTTP API sidecar. The API sidecar executes rndc commands locally and
165/// manages zone files. Authentication uses Kubernetes `ServiceAccount` tokens.
166///
167/// # Examples
168///
169/// ```rust,no_run
170/// use bindy::bind9::Bind9Manager;
171///
172/// let manager = Bind9Manager::new();
173/// ```
174#[derive(Debug, Clone)]
175pub struct Bind9Manager {
176    /// HTTP client for API requests
177    client: Arc<HttpClient>,
178    /// Cached `ServiceAccount` token for authentication (only used if auth is
179    /// enabled). Re-read from disk when older than `TOKEN_CACHE_TTL_SECS`
180    /// because the kubelet rotates the projected token file; caching it for
181    /// the process lifetime would present an expired token after ~1h.
182    token_cache: Arc<RwLock<Option<CachedToken>>>,
183    /// Deployment for the `Bind9Instance` (used to check auth status)
184    deployment: Option<Arc<Deployment>>,
185    /// Instance name (for auth checking)
186    instance_name: Option<String>,
187    /// Instance namespace (for auth checking)
188    instance_namespace: Option<String>,
189}
190
191impl Bind9Manager {
192    /// Create a new `Bind9Manager` without deployment information.
193    ///
194    /// Creates an HTTP client (with connect/request timeouts) for API
195    /// requests. The `ServiceAccount` token is read lazily at request time and
196    /// cached for `TOKEN_CACHE_TTL_SECS` so kubelet token rotation is picked
197    /// up. Without deployment information, auth is always assumed to be
198    /// enabled (backward compatible behavior).
199    ///
200    /// For proper auth status detection, use `new_with_deployment()` instead.
201    #[must_use]
202    pub fn new() -> Self {
203        Self {
204            client: Arc::new(build_http_client()),
205            token_cache: Arc::new(RwLock::new(None)),
206            deployment: None,
207            instance_name: None,
208            instance_namespace: None,
209        }
210    }
211
212    /// Create a new `Bind9Manager` with deployment information for auth checking.
213    ///
214    /// Creates an HTTP client (with connect/request timeouts) for API
215    /// requests. The `ServiceAccount` token is read lazily at request time and
216    /// cached for `TOKEN_CACHE_TTL_SECS` so kubelet token rotation is picked
217    /// up. The deployment is used to determine if authentication is enabled or
218    /// disabled by checking for the presence of the
219    /// `BIND_ALLOWED_SERVICE_ACCOUNTS` environment variable in the bindcar container.
220    ///
221    /// # Arguments
222    /// * `deployment` - The Deployment for the `Bind9Instance`
223    /// * `instance_name` - Name of the `Bind9Instance`
224    /// * `instance_namespace` - Namespace of the instance
225    ///
226    /// # Examples
227    ///
228    /// ```rust,no_run
229    /// use bindy::bind9::Bind9Manager;
230    /// use std::sync::Arc;
231    ///
232    /// # fn example(deployment: Arc<k8s_openapi::api::apps::v1::Deployment>) {
233    /// let manager = Bind9Manager::new_with_deployment(
234    ///     deployment,
235    ///     "my-instance".to_string(),
236    ///     "bindy-system".to_string()
237    /// );
238    /// # }
239    /// ```
240    #[must_use]
241    pub fn new_with_deployment(
242        deployment: Arc<Deployment>,
243        instance_name: String,
244        instance_namespace: String,
245    ) -> Self {
246        Self {
247            client: Arc::new(build_http_client()),
248            token_cache: Arc::new(RwLock::new(None)),
249            deployment: Some(deployment),
250            instance_name: Some(instance_name),
251            instance_namespace: Some(instance_namespace),
252        }
253    }
254
255    /// Read the operator's `ServiceAccount` token for bindcar authentication.
256    ///
257    /// bindcar `0.7.0` enforces the token audience (`status.audiences`) from the
258    /// TokenReview response, so the operator must present a token minted with the
259    /// `bindcar` audience. The projected audience-scoped token
260    /// ([`BINDCAR_TOKEN_PATH`]) is read in preference to the default
261    /// API-server-audience token ([`SERVICE_ACCOUNT_TOKEN_PATH`]), which is kept
262    /// only as a backward-compatible fallback for clusters that have not yet
263    /// projected the audience-scoped token.
264    ///
265    /// Called at request time (via [`Self::get_token`]) rather than once at
266    /// construction, because the kubelet rotates the projected token file.
267    fn read_service_account_token() -> Result<String> {
268        match std::fs::read_to_string(BINDCAR_TOKEN_PATH) {
269            Ok(token) => Ok(token),
270            Err(bindcar_err) => {
271                debug!(
272                    path = BINDCAR_TOKEN_PATH,
273                    error = %bindcar_err,
274                    "bindcar-audience token not found; falling back to default ServiceAccount token"
275                );
276                std::fs::read_to_string(SERVICE_ACCOUNT_TOKEN_PATH)
277                    .context("Failed to read ServiceAccount token file")
278            }
279        }
280    }
281
282    /// Check if authentication is enabled for the associated `Bind9Instance`.
283    ///
284    /// **Default behavior**: Returns `true` if no deployment is available (backward compat).
285    ///
286    /// **With deployment**: Checks if the `BIND_ALLOWED_SERVICE_ACCOUNTS` environment
287    /// variable is set in the bindcar container. If the env var is present, auth is enabled.
288    /// If absent, auth is disabled.
289    ///
290    /// # Returns
291    /// * `true` - Authentication is enabled (default if no deployment info)
292    /// * `false` - Authentication is explicitly disabled via env var absence
293    #[must_use]
294    pub fn is_auth_enabled(&self) -> bool {
295        let Some(deployment) = &self.deployment else {
296            // No deployment info - assume auth enabled for backward compatibility
297            debug!("No deployment info available, assuming auth enabled");
298            return true;
299        };
300
301        // Inspect the bindcar container's environment variables
302        let pod_spec = deployment
303            .spec
304            .as_ref()
305            .and_then(|spec| spec.template.spec.as_ref());
306
307        let Some(pod_spec) = pod_spec else {
308            warn!("Deployment has no pod template spec, assuming auth enabled");
309            return true;
310        };
311
312        // Find the bindcar container (sidecar)
313        let bindcar_container = pod_spec
314            .containers
315            .iter()
316            .find(|c| c.name == BINDCAR_CONTAINER_NAME);
317
318        let Some(bindcar_container) = bindcar_container else {
319            warn!(
320                container = BINDCAR_CONTAINER_NAME,
321                "Deployment has no bindcar container, assuming auth enabled"
322            );
323            return true;
324        };
325
326        // Check if BIND_ALLOWED_SERVICE_ACCOUNTS is set (auth enabled)
327        // If the env var is present, auth is enabled
328        // If the env var is absent, auth is disabled
329        let auth_enabled = bindcar_container
330            .env
331            .as_ref()
332            .is_some_and(|env_vars| env_vars.iter().any(|env| env.name == BINDCAR_AUTH_ENV_VAR));
333
334        debug!(
335            instance = ?self.instance_name,
336            namespace = ?self.instance_namespace,
337            auth_enabled = %auth_enabled,
338            env_var = BINDCAR_AUTH_ENV_VAR,
339            "Checked auth status for Bind9Instance"
340        );
341
342        auth_enabled
343    }
344
345    /// Get the authentication token if available and auth is enabled.
346    ///
347    /// The token is read from disk at request time and cached for
348    /// `TOKEN_CACHE_TTL_SECS`; a stale cache entry triggers a re-read so
349    /// kubelet rotation of the projected token file is always picked up.
350    ///
351    /// Returns `None` if:
352    /// - Auth is disabled for this instance
353    /// - Token file couldn't be read
354    ///
355    /// This is a public method to allow external code to check auth status and get the token.
356    #[must_use]
357    pub fn get_token(&self) -> Option<String> {
358        if !self.is_auth_enabled() {
359            return None;
360        }
361
362        self.cached_or_fresh_token()
363    }
364
365    /// Return the cached token if still fresh, otherwise re-read it from disk.
366    ///
367    /// The negative result (no readable token file) is cached too, so an
368    /// auth-disabled or out-of-cluster environment does not re-read the file
369    /// on every request within the TTL. A poisoned lock is treated as a cache
370    /// miss: the token is re-read from disk and returned even if the cache
371    /// cannot be updated.
372    fn cached_or_fresh_token(&self) -> Option<String> {
373        // Fast path: serve a fresh cached token under the read lock.
374        if let Ok(guard) = self.token_cache.read() {
375            if let Some(cached) = guard.as_ref() {
376                if is_token_cache_fresh(cached.read_at, Instant::now()) {
377                    return cached.token.clone();
378                }
379            }
380        }
381
382        // Slow path: (re-)read the token file and refresh the cache.
383        let token = Self::read_service_account_token().ok();
384        if let Ok(mut guard) = self.token_cache.write() {
385            *guard = Some(CachedToken {
386                token: token.clone(),
387                read_at: Instant::now(),
388            });
389        }
390
391        token
392    }
393
394    /// Get a reference to the HTTP client for making API requests.
395    ///
396    /// This allows external code to make custom HTTP requests to the bindcar API
397    /// while still respecting the authentication configuration.
398    #[must_use]
399    pub fn client(&self) -> &Arc<HttpClient> {
400        &self.client
401    }
402
403    /// Build the API base URL from a server address
404    ///
405    /// Converts "service-name.namespace.svc.cluster.local:8080" or "service-name:8080"
406    /// to `<http://service-name.namespace.svc.cluster.local:8080>` or `<http://service-name:8080>`
407    ///
408    /// This is a public method for testing purposes.
409    #[must_use]
410    pub fn build_api_url(server: &str) -> String {
411        zone_ops::build_api_url(server)
412    }
413
414    // ===== Zone management methods =====
415
416    /// Reload a specific zone via HTTP API.
417    ///
418    /// This operation is idempotent - if the zone doesn't exist, it returns an error
419    /// with a clear message indicating the zone was not found.
420    ///
421    /// # Arguments
422    /// * `zone_name` - Name of the zone to reload
423    /// * `server` - API server address (e.g., "bind9-primary-api:8080")
424    ///
425    /// # Errors
426    ///
427    /// Returns an error if the HTTP request fails or the zone cannot be reloaded.
428    pub async fn reload_zone(&self, zone_name: &str, server: &str) -> Result<()> {
429        let token = self.get_token();
430        zone_ops::reload_zone(&self.client, token.as_deref(), zone_name, server).await
431    }
432
433    /// Reload all zones via HTTP API.
434    ///
435    /// # Errors
436    ///
437    /// Returns an error if the HTTP request fails.
438    pub async fn reload_all_zones(&self, server: &str) -> Result<()> {
439        zone_ops::reload_all_zones(&self.client, self.get_token().as_deref(), server).await
440    }
441
442    /// Trigger zone transfer via HTTP API.
443    ///
444    /// # Errors
445    ///
446    /// Returns an error if the HTTP request fails or the zone transfer cannot be initiated.
447    pub async fn retransfer_zone(&self, zone_name: &str, server: &str) -> Result<()> {
448        zone_ops::retransfer_zone(&self.client, self.get_token().as_deref(), zone_name, server)
449            .await
450    }
451
452    /// Freeze a zone to prevent dynamic updates via HTTP API.
453    ///
454    /// # Errors
455    ///
456    /// Returns an error if the HTTP request fails or the zone cannot be frozen.
457    pub async fn freeze_zone(&self, zone_name: &str, server: &str) -> Result<()> {
458        zone_ops::freeze_zone(&self.client, self.get_token().as_deref(), zone_name, server).await
459    }
460
461    /// Thaw a frozen zone to allow dynamic updates via HTTP API.
462    ///
463    /// # Errors
464    ///
465    /// Returns an error if the HTTP request fails or the zone cannot be thawed.
466    pub async fn thaw_zone(&self, zone_name: &str, server: &str) -> Result<()> {
467        zone_ops::thaw_zone(&self.client, self.get_token().as_deref(), zone_name, server).await
468    }
469
470    /// Get zone status via HTTP API.
471    ///
472    /// # Errors
473    ///
474    /// Returns an error if the HTTP request fails or the zone status cannot be retrieved.
475    pub async fn zone_status(&self, zone_name: &str, server: &str) -> Result<String> {
476        zone_ops::zone_status(&self.client, self.get_token().as_deref(), zone_name, server).await
477    }
478
479    /// Check if a zone exists by trying to get its status.
480    ///
481    /// Returns `Ok(true)` if the zone exists and can be queried, `Ok(false)` if the zone
482    /// definitely does not exist (404), or `Err` for transient errors (rate limiting, network
483    /// errors, server errors, etc.) that should be retried.
484    ///
485    /// # Errors
486    ///
487    /// Returns an error if:
488    /// - The server is rate limiting requests (429 Too Many Requests)
489    /// - Network connectivity issues occur
490    /// - The server returns a 5xx error
491    /// - Any other non-404 error occurs
492    pub async fn zone_exists(&self, zone_name: &str, server: &str) -> Result<bool> {
493        zone_ops::zone_exists(&self.client, self.get_token().as_deref(), zone_name, server).await
494    }
495
496    /// Get server status via HTTP API.
497    ///
498    /// # Errors
499    ///
500    /// Returns an error if the HTTP request fails or the server status cannot be retrieved.
501    pub async fn server_status(&self, server: &str) -> Result<String> {
502        zone_ops::server_status(&self.client, self.get_token().as_deref(), server).await
503    }
504
505    /// Add a zone via HTTP API (primary or secondary).
506    ///
507    /// This is the centralized zone addition method that dispatches to either
508    /// `add_primary_zone` or `add_secondary_zone` based on the zone type.
509    ///
510    /// This operation is idempotent - if the zone already exists, it returns success
511    /// without attempting to re-add it.
512    ///
513    /// # Arguments
514    /// * `zone_name` - Name of the zone (e.g., "example.com")
515    /// * `zone_type` - Zone type (use `ZONE_TYPE_PRIMARY` or `ZONE_TYPE_SECONDARY` constants)
516    /// * `server` - API endpoint (e.g., "bind9-primary-api:8080" or "bind9-secondary-api:8080")
517    /// * `key_data` - RNDC key data
518    /// * `soa_record` - Optional SOA record data (required for primary zones, ignored for secondary)
519    /// * `name_servers` - Optional list of ALL authoritative nameserver hostnames (for primary zones)
520    /// * `name_server_ips` - Optional map of nameserver hostnames to IP addresses (for primary zones)
521    /// * `secondary_ips` - Optional list of secondary server IPs for also-notify and allow-transfer (for primary zones)
522    /// * `primary_ips` - Optional list of primary server IPs to transfer from (for secondary zones)
523    ///
524    /// # Returns
525    ///
526    /// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
527    ///
528    /// # Errors
529    ///
530    /// Returns an error if the HTTP request fails or the zone cannot be added.
531    #[allow(clippy::too_many_arguments)]
532    pub async fn add_zones(
533        &self,
534        zone_name: &str,
535        zone_type: &str,
536        server: &str,
537        key_data: &RndcKeyData,
538        soa_record: Option<&crate::crd::SOARecord>,
539        name_servers: Option<&[String]>,
540        name_server_ips: Option<&HashMap<String, String>>,
541        secondary_ips: Option<&[String]>,
542        primary_ips: Option<&[String]>,
543        dnssec_policy: Option<&str>,
544    ) -> Result<bool> {
545        let token = self.get_token();
546        zone_ops::add_zones(
547            &self.client,
548            token.as_deref(),
549            zone_name,
550            zone_type,
551            server,
552            key_data,
553            soa_record,
554            name_servers,
555            name_server_ips,
556            secondary_ips,
557            primary_ips,
558            dnssec_policy,
559        )
560        .await
561    }
562
563    /// Add a new primary zone via HTTP API.
564    ///
565    /// This operation is idempotent - if the zone already exists, it returns success
566    /// without attempting to re-add it.
567    ///
568    /// The zone is created with `allow-update` enabled for the TSIG key used by the operator.
569    /// This allows dynamic DNS updates (RFC 2136) to add/update/delete records in the zone.
570    ///
571    /// **Note:** This method creates a zone without initial content. For creating zones with
572    /// initial SOA/NS records, use `create_zone_http()` instead.
573    ///
574    /// # Arguments
575    /// * `zone_name` - Name of the zone (e.g., "example.com")
576    /// * `server` - API endpoint (e.g., "bind9-primary-api:8080")
577    /// * `key_data` - RNDC key data (used for allow-update configuration)
578    /// * `soa_record` - SOA record data
579    /// * `name_servers` - Optional list of ALL authoritative nameserver hostnames
580    /// * `name_server_ips` - Optional map of nameserver hostnames to IP addresses for glue records
581    /// * `secondary_ips` - Optional list of secondary server IPs for also-notify and allow-transfer
582    ///
583    /// # Returns
584    ///
585    /// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
586    ///
587    /// # Errors
588    ///
589    /// Returns an error if the HTTP request fails or the zone cannot be added.
590    #[allow(
591        clippy::cast_possible_truncation,
592        clippy::cast_sign_loss,
593        clippy::too_many_arguments
594    )]
595    pub async fn add_primary_zone(
596        &self,
597        zone_name: &str,
598        server: &str,
599        key_data: &RndcKeyData,
600        soa_record: &crate::crd::SOARecord,
601        name_servers: Option<&[String]>,
602        name_server_ips: Option<&HashMap<String, String>>,
603        secondary_ips: Option<&[String]>,
604        dnssec_policy: Option<&str>,
605    ) -> Result<bool> {
606        zone_ops::add_primary_zone(
607            &self.client,
608            self.get_token().as_deref(),
609            zone_name,
610            server,
611            key_data,
612            soa_record,
613            name_servers,
614            name_server_ips,
615            secondary_ips,
616            dnssec_policy,
617        )
618        .await
619    }
620
621    /// Add a secondary zone via HTTP API.
622    ///
623    /// Creates a secondary zone that will transfer from the specified primary servers.
624    /// This is a convenience method specifically for secondary zones.
625    ///
626    /// # Arguments
627    /// * `zone_name` - Name of the zone (e.g., "example.com")
628    /// * `server` - API endpoint of the secondary server (e.g., "bind9-secondary-api:8080")
629    /// * `key_data` - RNDC key data
630    /// * `primary_ips` - List of primary server IP addresses to transfer from
631    ///
632    /// # Returns
633    ///
634    /// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
635    ///
636    /// # Errors
637    ///
638    /// Returns an error if the HTTP request fails or the zone cannot be added.
639    pub async fn add_secondary_zone(
640        &self,
641        zone_name: &str,
642        server: &str,
643        key_data: &RndcKeyData,
644        primary_ips: &[String],
645    ) -> Result<bool> {
646        zone_ops::add_secondary_zone(
647            &self.client,
648            self.get_token().as_deref(),
649            zone_name,
650            server,
651            key_data,
652            primary_ips,
653        )
654        .await
655    }
656
657    /// Create a zone via HTTP API with structured configuration.
658    ///
659    /// This method sends a POST request to the API sidecar to create a zone using
660    /// structured zone configuration from the bindcar library.
661    ///
662    /// # Arguments
663    /// * `zone_name` - Name of the zone (e.g., "example.com")
664    /// * `zone_type` - Zone type (use `ZONE_TYPE_PRIMARY` or `ZONE_TYPE_SECONDARY` constants)
665    /// * `zone_config` - Structured zone configuration (converted to zone file by bindcar)
666    /// * `server` - API endpoint (e.g., "bind9-primary-api:8080")
667    /// * `key_data` - RNDC authentication key (used as updateKeyName)
668    ///
669    /// # Errors
670    ///
671    /// Returns an error if the HTTP request fails or the zone cannot be created.
672    #[allow(clippy::too_many_arguments)]
673    pub async fn create_zone_http(
674        &self,
675        zone_name: &str,
676        zone_type: &str,
677        zone_config: ZoneConfig,
678        server: &str,
679        key_data: &RndcKeyData,
680    ) -> Result<()> {
681        zone_ops::create_zone_http(
682            &self.client,
683            self.get_token().as_deref(),
684            zone_name,
685            zone_type,
686            zone_config,
687            server,
688            key_data,
689        )
690        .await
691    }
692
693    /// Delete a zone via HTTP API.
694    ///
695    /// # Arguments
696    /// * `zone_name` - Name of the zone to delete
697    /// * `server` - API server address
698    /// * `freeze_before_delete` - Whether to freeze the zone before deletion (true for primary zones, false for secondary zones)
699    ///
700    /// # Errors
701    ///
702    /// Returns an error if the HTTP request fails or the zone cannot be deleted.
703    pub async fn delete_zone(
704        &self,
705        zone_name: &str,
706        server: &str,
707        freeze_before_delete: bool,
708    ) -> Result<()> {
709        zone_ops::delete_zone(
710            &self.client,
711            self.get_token().as_deref(),
712            zone_name,
713            server,
714            freeze_before_delete,
715        )
716        .await
717    }
718
719    /// Notify secondaries about zone changes via HTTP API.
720    ///
721    /// # Errors
722    ///
723    /// Returns an error if the HTTP request fails or the notification cannot be sent.
724    pub async fn notify_zone(&self, zone_name: &str, server: &str) -> Result<()> {
725        zone_ops::notify_zone(&self.client, self.get_token().as_deref(), zone_name, server).await
726    }
727
728    // ===== DNS record management methods =====
729
730    /// Add A records using dynamic DNS update (RFC 2136) with `RRset` synchronization.
731    ///
732    /// # Arguments
733    /// * `zone_name` - DNS zone name (e.g., "example.com")
734    /// * `name` - Record name (e.g., "www" for www.example.com, or "@" for apex)
735    /// * `ipv4_addresses` - List of IPv4 addresses for round-robin DNS
736    /// * `ttl` - Time to live in seconds (None = use zone default)
737    /// * `server` - DNS server address with port (e.g., "10.0.0.1:53")
738    /// * `key_data` - TSIG key for authentication
739    ///
740    /// # Errors
741    ///
742    /// Returns an error if the DNS update fails or the server rejects it.
743    #[allow(clippy::too_many_arguments)]
744    pub async fn add_a_record(
745        &self,
746        zone_name: &str,
747        name: &str,
748        ipv4_addresses: &[String],
749        ttl: Option<i32>,
750        server: &str,
751        key_data: &RndcKeyData,
752    ) -> Result<()> {
753        records::a::add_a_record(zone_name, name, ipv4_addresses, ttl, server, key_data).await
754    }
755
756    /// Add AAAA records using dynamic DNS update (RFC 2136) with `RRset` synchronization.
757    ///
758    /// # Arguments
759    /// * `zone_name` - DNS zone name (e.g., "example.com")
760    /// * `name` - Record name (e.g., "www" for www.example.com, or "@" for apex)
761    /// * `ipv6_addresses` - List of IPv6 addresses for round-robin DNS
762    /// * `ttl` - Time to live in seconds (None = use zone default)
763    /// * `server` - DNS server address with port (e.g., "10.0.0.1:53")
764    /// * `key_data` - TSIG key for authentication
765    ///
766    /// # Errors
767    ///
768    /// Returns an error if the DNS update fails or the server rejects it.
769    #[allow(clippy::too_many_arguments)]
770    pub async fn add_aaaa_record(
771        &self,
772        zone_name: &str,
773        name: &str,
774        ipv6_addresses: &[String],
775        ttl: Option<i32>,
776        server: &str,
777        key_data: &RndcKeyData,
778    ) -> Result<()> {
779        records::a::add_aaaa_record(zone_name, name, ipv6_addresses, ttl, server, key_data).await
780    }
781
782    /// Add a CNAME record using dynamic DNS update (RFC 2136).
783    ///
784    /// # Errors
785    ///
786    /// Returns an error if the DNS update fails or the server rejects it.
787    #[allow(clippy::too_many_arguments)]
788    pub async fn add_cname_record(
789        &self,
790        zone_name: &str,
791        name: &str,
792        target: &str,
793        ttl: Option<i32>,
794        server: &str,
795        key_data: &RndcKeyData,
796    ) -> Result<()> {
797        records::cname::add_cname_record(zone_name, name, target, ttl, server, key_data).await
798    }
799
800    /// Add a TXT record using dynamic DNS update (RFC 2136).
801    ///
802    /// # Errors
803    ///
804    /// Returns an error if the DNS update fails or the server rejects it.
805    #[allow(clippy::too_many_arguments)]
806    pub async fn add_txt_record(
807        &self,
808        zone_name: &str,
809        name: &str,
810        texts: &[String],
811        ttl: Option<i32>,
812        server: &str,
813        key_data: &RndcKeyData,
814    ) -> Result<()> {
815        records::txt::add_txt_record(zone_name, name, texts, ttl, server, key_data).await
816    }
817
818    /// Add an MX record using dynamic DNS update (RFC 2136).
819    ///
820    /// # Errors
821    ///
822    /// Returns an error if the DNS update fails or the server rejects it.
823    #[allow(clippy::too_many_arguments)]
824    pub async fn add_mx_record(
825        &self,
826        zone_name: &str,
827        name: &str,
828        priority: i32,
829        mail_server: &str,
830        ttl: Option<i32>,
831        server: &str,
832        key_data: &RndcKeyData,
833    ) -> Result<()> {
834        records::mx::add_mx_record(
835            zone_name,
836            name,
837            priority,
838            mail_server,
839            ttl,
840            server,
841            key_data,
842        )
843        .await
844    }
845
846    /// Add an NS record using dynamic DNS update (RFC 2136).
847    ///
848    /// # Errors
849    ///
850    /// Returns an error if the DNS update fails or the server rejects it.
851    #[allow(clippy::too_many_arguments)]
852    pub async fn add_ns_record(
853        &self,
854        zone_name: &str,
855        name: &str,
856        nameserver: &str,
857        ttl: Option<i32>,
858        server: &str,
859        key_data: &RndcKeyData,
860    ) -> Result<()> {
861        records::ns::add_ns_record(zone_name, name, nameserver, ttl, server, key_data).await
862    }
863
864    /// Add an SRV record using dynamic DNS update (RFC 2136).
865    ///
866    /// # Errors
867    ///
868    /// Returns an error if:
869    /// - DNS server connection fails
870    /// - TSIG signer creation fails
871    /// - DNS update is rejected by the server
872    /// - Invalid domain name or target
873    #[allow(clippy::too_many_arguments)]
874    pub async fn add_srv_record(
875        &self,
876        zone_name: &str,
877        name: &str,
878        srv_data: &SRVRecordData,
879        server: &str,
880        key_data: &RndcKeyData,
881    ) -> Result<()> {
882        records::srv::add_srv_record(zone_name, name, srv_data, server, key_data).await
883    }
884
885    /// Add a CAA record using dynamic DNS update (RFC 2136).
886    ///
887    /// # Errors
888    ///
889    /// Returns an error if:
890    /// - DNS server connection fails
891    /// - TSIG signer creation fails
892    /// - DNS update is rejected by the server
893    /// - Invalid domain name, flags, tag, or value
894    #[allow(clippy::too_many_arguments)]
895    #[allow(clippy::too_many_lines)]
896    pub async fn add_caa_record(
897        &self,
898        zone_name: &str,
899        name: &str,
900        flags: i32,
901        tag: &str,
902        value: &str,
903        ttl: Option<i32>,
904        server: &str,
905        key_data: &RndcKeyData,
906    ) -> Result<()> {
907        records::caa::add_caa_record(zone_name, name, flags, tag, value, ttl, server, key_data)
908            .await
909    }
910
911    /// Add a PTR record using dynamic DNS update (RFC 2136).
912    ///
913    /// # Errors
914    ///
915    /// Returns an error if:
916    /// - DNS server connection fails
917    /// - TSIG signer creation fails
918    /// - DNS update is rejected by the server
919    /// - Invalid domain name or target
920    #[allow(clippy::too_many_arguments)]
921    pub async fn add_ptr_record(
922        &self,
923        zone_name: &str,
924        name: &str,
925        ptr_data: &PTRRecordData,
926        server: &str,
927        key_data: &RndcKeyData,
928    ) -> Result<()> {
929        records::ptr::add_ptr_record(zone_name, name, ptr_data, server, key_data).await
930    }
931
932    /// Delete a DNS record using dynamic DNS update (RFC 2136).
933    ///
934    /// This method deletes ALL records of the specified type for the given name.
935    /// It's idempotent - deleting a non-existent record is a no-op.
936    ///
937    /// # Arguments
938    ///
939    /// * `zone_name` - The DNS zone name
940    /// * `name` - The record name (e.g., "www" for www.example.com)
941    /// * `record_type` - The type of record to delete (A, AAAA, CNAME, etc.)
942    /// * `server` - The DNS server address (IP:port)
943    /// * `key_data` - TSIG key for authentication
944    ///
945    /// # Errors
946    ///
947    /// Returns an error if the DNS server rejects the update or connection fails.
948    pub async fn delete_record(
949        &self,
950        zone_name: &str,
951        name: &str,
952        record_type: hickory_proto::rr::RecordType,
953        server: &str,
954        key_data: &RndcKeyData,
955    ) -> Result<()> {
956        records::delete_dns_record(zone_name, name, record_type, server, key_data).await
957    }
958
959    // ===== RNDC static methods (exposed through the struct for backwards compatibility) =====
960
961    /// Generate a new RNDC key with HMAC-SHA256.
962    ///
963    /// Returns a base64-encoded 256-bit (32-byte) key suitable for rndc authentication.
964    #[must_use]
965    pub fn generate_rndc_key() -> RndcKeyData {
966        rndc::generate_rndc_key()
967    }
968
969    /// Create a Kubernetes Secret manifest for an RNDC key.
970    ///
971    /// Returns a `BTreeMap` suitable for use as Secret data.
972    #[must_use]
973    pub fn create_rndc_secret_data(
974        key_data: &RndcKeyData,
975    ) -> std::collections::BTreeMap<String, String> {
976        rndc::create_rndc_secret_data(key_data)
977    }
978
979    /// Parse RNDC key data from a Kubernetes Secret.
980    ///
981    /// Supports two Secret formats:
982    /// 1. **Operator-generated** (all 4 fields): `key-name`, `algorithm`, `secret`, `rndc.key`
983    /// 2. **External/user-managed** (minimal): `rndc.key` only - parses the BIND9 key file
984    ///
985    /// # Errors
986    ///
987    /// Returns an error if:
988    /// - Neither the metadata fields nor `rndc.key` are present
989    /// - The `rndc.key` file cannot be parsed
990    /// - Values are not valid UTF-8 strings
991    pub fn parse_rndc_secret_data(
992        data: &std::collections::BTreeMap<String, Vec<u8>>,
993    ) -> Result<RndcKeyData> {
994        rndc::parse_rndc_secret_data(data)
995    }
996}
997
998impl Default for Bind9Manager {
999    fn default() -> Self {
1000        Self::new()
1001    }
1002}
1003
1004// Declare test modules
1005#[cfg(test)]
1006mod mod_tests;