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