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::{RndcError, RndcKeyData, SRVRecordData, SERVICE_ACCOUNT_TOKEN_PATH};
50
51use anyhow::{Context, Result};
52use bindcar::ZoneConfig;
53use k8s_openapi::api::apps::v1::Deployment;
54use reqwest::Client as HttpClient;
55use std::collections::HashMap;
56use std::sync::Arc;
57use tracing::{debug, warn};
58
59/// Environment variable name that indicates bindcar authentication is enabled.
60/// If this env var is present in the bindcar container, authentication is required.
61const BINDCAR_AUTH_ENV_VAR: &str = "BIND_ALLOWED_SERVICE_ACCOUNTS";
62
63/// Name of the bindcar sidecar container in `Bind9Instance` deployments.
64const BINDCAR_CONTAINER_NAME: &str = "bindcar";
65
66/// Manager for BIND9 servers via HTTP API sidecar.
67///
68/// The `Bind9Manager` provides methods for managing BIND9 servers running in Kubernetes
69/// pods via an HTTP API sidecar. The API sidecar executes rndc commands locally and
70/// manages zone files. Authentication uses Kubernetes `ServiceAccount` tokens.
71///
72/// # Examples
73///
74/// ```rust,no_run
75/// use bindy::bind9::Bind9Manager;
76///
77/// let manager = Bind9Manager::new();
78/// ```
79#[derive(Debug, Clone)]
80pub struct Bind9Manager {
81    /// HTTP client for API requests
82    client: Arc<HttpClient>,
83    /// `ServiceAccount` token for authentication (optional - only used if auth is enabled)
84    token: Arc<Option<String>>,
85    /// Deployment for the `Bind9Instance` (used to check auth status)
86    deployment: Option<Arc<Deployment>>,
87    /// Instance name (for auth checking)
88    instance_name: Option<String>,
89    /// Instance namespace (for auth checking)
90    instance_namespace: Option<String>,
91}
92
93impl Bind9Manager {
94    /// Create a new `Bind9Manager` without deployment information.
95    ///
96    /// Reads the `ServiceAccount` token from the default location and creates
97    /// an HTTP client for API requests. Without deployment information, auth is
98    /// always assumed to be enabled (backward compatible behavior).
99    ///
100    /// For proper auth status detection, use `new_with_deployment()` instead.
101    #[must_use]
102    pub fn new() -> Self {
103        let token = Self::read_service_account_token().ok();
104
105        Self {
106            client: Arc::new(HttpClient::new()),
107            token: Arc::new(token),
108            deployment: None,
109            instance_name: None,
110            instance_namespace: None,
111        }
112    }
113
114    /// Create a new `Bind9Manager` with deployment information for auth checking.
115    ///
116    /// Reads the `ServiceAccount` token from the default location and creates
117    /// an HTTP client for API requests. The deployment is used to determine if
118    /// authentication is enabled or disabled by checking for the presence of
119    /// the `BIND_ALLOWED_SERVICE_ACCOUNTS` environment variable in the bindcar container.
120    ///
121    /// # Arguments
122    /// * `deployment` - The Deployment for the `Bind9Instance`
123    /// * `instance_name` - Name of the `Bind9Instance`
124    /// * `instance_namespace` - Namespace of the instance
125    ///
126    /// # Examples
127    ///
128    /// ```rust,no_run
129    /// use bindy::bind9::Bind9Manager;
130    /// use std::sync::Arc;
131    ///
132    /// # fn example(deployment: Arc<k8s_openapi::api::apps::v1::Deployment>) {
133    /// let manager = Bind9Manager::new_with_deployment(
134    ///     deployment,
135    ///     "my-instance".to_string(),
136    ///     "bindy-system".to_string()
137    /// );
138    /// # }
139    /// ```
140    #[must_use]
141    pub fn new_with_deployment(
142        deployment: Arc<Deployment>,
143        instance_name: String,
144        instance_namespace: String,
145    ) -> Self {
146        let token = Self::read_service_account_token().ok();
147
148        Self {
149            client: Arc::new(HttpClient::new()),
150            token: Arc::new(token),
151            deployment: Some(deployment),
152            instance_name: Some(instance_name),
153            instance_namespace: Some(instance_namespace),
154        }
155    }
156
157    /// Read the `ServiceAccount` token from the mounted secret
158    fn read_service_account_token() -> Result<String> {
159        std::fs::read_to_string(SERVICE_ACCOUNT_TOKEN_PATH)
160            .context("Failed to read ServiceAccount token file")
161    }
162
163    /// Check if authentication is enabled for the associated `Bind9Instance`.
164    ///
165    /// **Default behavior**: Returns `true` if no deployment is available (backward compat).
166    ///
167    /// **With deployment**: Checks if the `BIND_ALLOWED_SERVICE_ACCOUNTS` environment
168    /// variable is set in the bindcar container. If the env var is present, auth is enabled.
169    /// If absent, auth is disabled.
170    ///
171    /// # Returns
172    /// * `true` - Authentication is enabled (default if no deployment info)
173    /// * `false` - Authentication is explicitly disabled via env var absence
174    #[must_use]
175    pub fn is_auth_enabled(&self) -> bool {
176        let Some(deployment) = &self.deployment else {
177            // No deployment info - assume auth enabled for backward compatibility
178            debug!("No deployment info available, assuming auth enabled");
179            return true;
180        };
181
182        // Inspect the bindcar container's environment variables
183        let pod_spec = deployment
184            .spec
185            .as_ref()
186            .and_then(|spec| spec.template.spec.as_ref());
187
188        let Some(pod_spec) = pod_spec else {
189            warn!("Deployment has no pod template spec, assuming auth enabled");
190            return true;
191        };
192
193        // Find the bindcar container (sidecar)
194        let bindcar_container = pod_spec
195            .containers
196            .iter()
197            .find(|c| c.name == BINDCAR_CONTAINER_NAME);
198
199        let Some(bindcar_container) = bindcar_container else {
200            warn!(
201                container = BINDCAR_CONTAINER_NAME,
202                "Deployment has no bindcar container, assuming auth enabled"
203            );
204            return true;
205        };
206
207        // Check if BIND_ALLOWED_SERVICE_ACCOUNTS is set (auth enabled)
208        // If the env var is present, auth is enabled
209        // If the env var is absent, auth is disabled
210        let auth_enabled = bindcar_container
211            .env
212            .as_ref()
213            .is_some_and(|env_vars| env_vars.iter().any(|env| env.name == BINDCAR_AUTH_ENV_VAR));
214
215        debug!(
216            instance = ?self.instance_name,
217            namespace = ?self.instance_namespace,
218            auth_enabled = %auth_enabled,
219            env_var = BINDCAR_AUTH_ENV_VAR,
220            "Checked auth status for Bind9Instance"
221        );
222
223        auth_enabled
224    }
225
226    /// Get the authentication token if available and auth is enabled.
227    ///
228    /// Returns `None` if:
229    /// - Auth is disabled for this instance
230    /// - Token file couldn't be read
231    /// - No token was loaded
232    ///
233    /// This is a public method to allow external code to check auth status and get the token.
234    #[must_use]
235    pub fn get_token(&self) -> Option<String> {
236        if !self.is_auth_enabled() {
237            return None;
238        }
239
240        self.token.as_ref().clone()
241    }
242
243    /// Get a reference to the HTTP client for making API requests.
244    ///
245    /// This allows external code to make custom HTTP requests to the bindcar API
246    /// while still respecting the authentication configuration.
247    #[must_use]
248    pub fn client(&self) -> &Arc<HttpClient> {
249        &self.client
250    }
251
252    /// Build the API base URL from a server address
253    ///
254    /// Converts "service-name.namespace.svc.cluster.local:8080" or "service-name:8080"
255    /// to `<http://service-name.namespace.svc.cluster.local:8080>` or `<http://service-name:8080>`
256    ///
257    /// This is a public method for testing purposes.
258    #[must_use]
259    pub fn build_api_url(server: &str) -> String {
260        zone_ops::build_api_url(server)
261    }
262
263    // ===== Zone management methods =====
264
265    /// Reload a specific zone via HTTP API.
266    ///
267    /// This operation is idempotent - if the zone doesn't exist, it returns an error
268    /// with a clear message indicating the zone was not found.
269    ///
270    /// # Arguments
271    /// * `zone_name` - Name of the zone to reload
272    /// * `server` - API server address (e.g., "bind9-primary-api:8080")
273    ///
274    /// # Errors
275    ///
276    /// Returns an error if the HTTP request fails or the zone cannot be reloaded.
277    pub async fn reload_zone(&self, zone_name: &str, server: &str) -> Result<()> {
278        let token = self.get_token();
279        zone_ops::reload_zone(&self.client, token.as_deref(), zone_name, server).await
280    }
281
282    /// Reload all zones via HTTP API.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error if the HTTP request fails.
287    pub async fn reload_all_zones(&self, server: &str) -> Result<()> {
288        zone_ops::reload_all_zones(&self.client, self.get_token().as_deref(), server).await
289    }
290
291    /// Trigger zone transfer via HTTP API.
292    ///
293    /// # Errors
294    ///
295    /// Returns an error if the HTTP request fails or the zone transfer cannot be initiated.
296    pub async fn retransfer_zone(&self, zone_name: &str, server: &str) -> Result<()> {
297        zone_ops::retransfer_zone(&self.client, self.get_token().as_deref(), zone_name, server)
298            .await
299    }
300
301    /// Freeze a zone to prevent dynamic updates via HTTP API.
302    ///
303    /// # Errors
304    ///
305    /// Returns an error if the HTTP request fails or the zone cannot be frozen.
306    pub async fn freeze_zone(&self, zone_name: &str, server: &str) -> Result<()> {
307        zone_ops::freeze_zone(&self.client, self.get_token().as_deref(), zone_name, server).await
308    }
309
310    /// Thaw a frozen zone to allow dynamic updates via HTTP API.
311    ///
312    /// # Errors
313    ///
314    /// Returns an error if the HTTP request fails or the zone cannot be thawed.
315    pub async fn thaw_zone(&self, zone_name: &str, server: &str) -> Result<()> {
316        zone_ops::thaw_zone(&self.client, self.get_token().as_deref(), zone_name, server).await
317    }
318
319    /// Get zone status via HTTP API.
320    ///
321    /// # Errors
322    ///
323    /// Returns an error if the HTTP request fails or the zone status cannot be retrieved.
324    pub async fn zone_status(&self, zone_name: &str, server: &str) -> Result<String> {
325        zone_ops::zone_status(&self.client, self.get_token().as_deref(), zone_name, server).await
326    }
327
328    /// Check if a zone exists by trying to get its status.
329    ///
330    /// Returns `Ok(true)` if the zone exists and can be queried, `Ok(false)` if the zone
331    /// definitely does not exist (404), or `Err` for transient errors (rate limiting, network
332    /// errors, server errors, etc.) that should be retried.
333    ///
334    /// # Errors
335    ///
336    /// Returns an error if:
337    /// - The server is rate limiting requests (429 Too Many Requests)
338    /// - Network connectivity issues occur
339    /// - The server returns a 5xx error
340    /// - Any other non-404 error occurs
341    pub async fn zone_exists(&self, zone_name: &str, server: &str) -> Result<bool> {
342        zone_ops::zone_exists(&self.client, self.get_token().as_deref(), zone_name, server).await
343    }
344
345    /// Get server status via HTTP API.
346    ///
347    /// # Errors
348    ///
349    /// Returns an error if the HTTP request fails or the server status cannot be retrieved.
350    pub async fn server_status(&self, server: &str) -> Result<String> {
351        zone_ops::server_status(&self.client, self.get_token().as_deref(), server).await
352    }
353
354    /// Add a zone via HTTP API (primary or secondary).
355    ///
356    /// This is the centralized zone addition method that dispatches to either
357    /// `add_primary_zone` or `add_secondary_zone` based on the zone type.
358    ///
359    /// This operation is idempotent - if the zone already exists, it returns success
360    /// without attempting to re-add it.
361    ///
362    /// # Arguments
363    /// * `zone_name` - Name of the zone (e.g., "example.com")
364    /// * `zone_type` - Zone type (use `ZONE_TYPE_PRIMARY` or `ZONE_TYPE_SECONDARY` constants)
365    /// * `server` - API endpoint (e.g., "bind9-primary-api:8080" or "bind9-secondary-api:8080")
366    /// * `key_data` - RNDC key data
367    /// * `soa_record` - Optional SOA record data (required for primary zones, ignored for secondary)
368    /// * `name_servers` - Optional list of ALL authoritative nameserver hostnames (for primary zones)
369    /// * `name_server_ips` - Optional map of nameserver hostnames to IP addresses (for primary zones)
370    /// * `secondary_ips` - Optional list of secondary server IPs for also-notify and allow-transfer (for primary zones)
371    /// * `primary_ips` - Optional list of primary server IPs to transfer from (for secondary zones)
372    ///
373    /// # Returns
374    ///
375    /// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
376    ///
377    /// # Errors
378    ///
379    /// Returns an error if the HTTP request fails or the zone cannot be added.
380    #[allow(clippy::too_many_arguments)]
381    pub async fn add_zones(
382        &self,
383        zone_name: &str,
384        zone_type: &str,
385        server: &str,
386        key_data: &RndcKeyData,
387        soa_record: Option<&crate::crd::SOARecord>,
388        name_servers: Option<&[String]>,
389        name_server_ips: Option<&HashMap<String, String>>,
390        secondary_ips: Option<&[String]>,
391        primary_ips: Option<&[String]>,
392        dnssec_policy: Option<&str>,
393    ) -> Result<bool> {
394        let token = self.get_token();
395        zone_ops::add_zones(
396            &self.client,
397            token.as_deref(),
398            zone_name,
399            zone_type,
400            server,
401            key_data,
402            soa_record,
403            name_servers,
404            name_server_ips,
405            secondary_ips,
406            primary_ips,
407            dnssec_policy,
408        )
409        .await
410    }
411
412    /// Add a new primary zone via HTTP API.
413    ///
414    /// This operation is idempotent - if the zone already exists, it returns success
415    /// without attempting to re-add it.
416    ///
417    /// The zone is created with `allow-update` enabled for the TSIG key used by the operator.
418    /// This allows dynamic DNS updates (RFC 2136) to add/update/delete records in the zone.
419    ///
420    /// **Note:** This method creates a zone without initial content. For creating zones with
421    /// initial SOA/NS records, use `create_zone_http()` instead.
422    ///
423    /// # Arguments
424    /// * `zone_name` - Name of the zone (e.g., "example.com")
425    /// * `server` - API endpoint (e.g., "bind9-primary-api:8080")
426    /// * `key_data` - RNDC key data (used for allow-update configuration)
427    /// * `soa_record` - SOA record data
428    /// * `name_servers` - Optional list of ALL authoritative nameserver hostnames
429    /// * `name_server_ips` - Optional map of nameserver hostnames to IP addresses for glue records
430    /// * `secondary_ips` - Optional list of secondary server IPs for also-notify and allow-transfer
431    ///
432    /// # Returns
433    ///
434    /// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
435    ///
436    /// # Errors
437    ///
438    /// Returns an error if the HTTP request fails or the zone cannot be added.
439    #[allow(
440        clippy::cast_possible_truncation,
441        clippy::cast_sign_loss,
442        clippy::too_many_arguments
443    )]
444    pub async fn add_primary_zone(
445        &self,
446        zone_name: &str,
447        server: &str,
448        key_data: &RndcKeyData,
449        soa_record: &crate::crd::SOARecord,
450        name_servers: Option<&[String]>,
451        name_server_ips: Option<&HashMap<String, String>>,
452        secondary_ips: Option<&[String]>,
453        dnssec_policy: Option<&str>,
454    ) -> Result<bool> {
455        zone_ops::add_primary_zone(
456            &self.client,
457            self.get_token().as_deref(),
458            zone_name,
459            server,
460            key_data,
461            soa_record,
462            name_servers,
463            name_server_ips,
464            secondary_ips,
465            dnssec_policy,
466        )
467        .await
468    }
469
470    /// Add a secondary zone via HTTP API.
471    ///
472    /// Creates a secondary zone that will transfer from the specified primary servers.
473    /// This is a convenience method specifically for secondary zones.
474    ///
475    /// # Arguments
476    /// * `zone_name` - Name of the zone (e.g., "example.com")
477    /// * `server` - API endpoint of the secondary server (e.g., "bind9-secondary-api:8080")
478    /// * `key_data` - RNDC key data
479    /// * `primary_ips` - List of primary server IP addresses to transfer from
480    ///
481    /// # Returns
482    ///
483    /// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
484    ///
485    /// # Errors
486    ///
487    /// Returns an error if the HTTP request fails or the zone cannot be added.
488    pub async fn add_secondary_zone(
489        &self,
490        zone_name: &str,
491        server: &str,
492        key_data: &RndcKeyData,
493        primary_ips: &[String],
494    ) -> Result<bool> {
495        zone_ops::add_secondary_zone(
496            &self.client,
497            self.get_token().as_deref(),
498            zone_name,
499            server,
500            key_data,
501            primary_ips,
502        )
503        .await
504    }
505
506    /// Create a zone via HTTP API with structured configuration.
507    ///
508    /// This method sends a POST request to the API sidecar to create a zone using
509    /// structured zone configuration from the bindcar library.
510    ///
511    /// # Arguments
512    /// * `zone_name` - Name of the zone (e.g., "example.com")
513    /// * `zone_type` - Zone type (use `ZONE_TYPE_PRIMARY` or `ZONE_TYPE_SECONDARY` constants)
514    /// * `zone_config` - Structured zone configuration (converted to zone file by bindcar)
515    /// * `server` - API endpoint (e.g., "bind9-primary-api:8080")
516    /// * `key_data` - RNDC authentication key (used as updateKeyName)
517    ///
518    /// # Errors
519    ///
520    /// Returns an error if the HTTP request fails or the zone cannot be created.
521    #[allow(clippy::too_many_arguments)]
522    pub async fn create_zone_http(
523        &self,
524        zone_name: &str,
525        zone_type: &str,
526        zone_config: ZoneConfig,
527        server: &str,
528        key_data: &RndcKeyData,
529    ) -> Result<()> {
530        zone_ops::create_zone_http(
531            &self.client,
532            self.get_token().as_deref(),
533            zone_name,
534            zone_type,
535            zone_config,
536            server,
537            key_data,
538        )
539        .await
540    }
541
542    /// Delete a zone via HTTP API.
543    ///
544    /// # Arguments
545    /// * `zone_name` - Name of the zone to delete
546    /// * `server` - API server address
547    /// * `freeze_before_delete` - Whether to freeze the zone before deletion (true for primary zones, false for secondary zones)
548    ///
549    /// # Errors
550    ///
551    /// Returns an error if the HTTP request fails or the zone cannot be deleted.
552    pub async fn delete_zone(
553        &self,
554        zone_name: &str,
555        server: &str,
556        freeze_before_delete: bool,
557    ) -> Result<()> {
558        zone_ops::delete_zone(
559            &self.client,
560            self.get_token().as_deref(),
561            zone_name,
562            server,
563            freeze_before_delete,
564        )
565        .await
566    }
567
568    /// Notify secondaries about zone changes via HTTP API.
569    ///
570    /// # Errors
571    ///
572    /// Returns an error if the HTTP request fails or the notification cannot be sent.
573    pub async fn notify_zone(&self, zone_name: &str, server: &str) -> Result<()> {
574        zone_ops::notify_zone(&self.client, self.get_token().as_deref(), zone_name, server).await
575    }
576
577    // ===== DNS record management methods =====
578
579    /// Add A records using dynamic DNS update (RFC 2136) with `RRset` synchronization.
580    ///
581    /// # Arguments
582    /// * `zone_name` - DNS zone name (e.g., "example.com")
583    /// * `name` - Record name (e.g., "www" for www.example.com, or "@" for apex)
584    /// * `ipv4_addresses` - List of IPv4 addresses for round-robin DNS
585    /// * `ttl` - Time to live in seconds (None = use zone default)
586    /// * `server` - DNS server address with port (e.g., "10.0.0.1:53")
587    /// * `key_data` - TSIG key for authentication
588    ///
589    /// # Errors
590    ///
591    /// Returns an error if the DNS update fails or the server rejects it.
592    #[allow(clippy::too_many_arguments)]
593    pub async fn add_a_record(
594        &self,
595        zone_name: &str,
596        name: &str,
597        ipv4_addresses: &[String],
598        ttl: Option<i32>,
599        server: &str,
600        key_data: &RndcKeyData,
601    ) -> Result<()> {
602        records::a::add_a_record(zone_name, name, ipv4_addresses, ttl, server, key_data).await
603    }
604
605    /// Add AAAA records using dynamic DNS update (RFC 2136) with `RRset` synchronization.
606    ///
607    /// # Arguments
608    /// * `zone_name` - DNS zone name (e.g., "example.com")
609    /// * `name` - Record name (e.g., "www" for www.example.com, or "@" for apex)
610    /// * `ipv6_addresses` - List of IPv6 addresses for round-robin DNS
611    /// * `ttl` - Time to live in seconds (None = use zone default)
612    /// * `server` - DNS server address with port (e.g., "10.0.0.1:53")
613    /// * `key_data` - TSIG key for authentication
614    ///
615    /// # Errors
616    ///
617    /// Returns an error if the DNS update fails or the server rejects it.
618    #[allow(clippy::too_many_arguments)]
619    pub async fn add_aaaa_record(
620        &self,
621        zone_name: &str,
622        name: &str,
623        ipv6_addresses: &[String],
624        ttl: Option<i32>,
625        server: &str,
626        key_data: &RndcKeyData,
627    ) -> Result<()> {
628        records::a::add_aaaa_record(zone_name, name, ipv6_addresses, ttl, server, key_data).await
629    }
630
631    /// Add a CNAME record using dynamic DNS update (RFC 2136).
632    ///
633    /// # Errors
634    ///
635    /// Returns an error if the DNS update fails or the server rejects it.
636    #[allow(clippy::too_many_arguments)]
637    pub async fn add_cname_record(
638        &self,
639        zone_name: &str,
640        name: &str,
641        target: &str,
642        ttl: Option<i32>,
643        server: &str,
644        key_data: &RndcKeyData,
645    ) -> Result<()> {
646        records::cname::add_cname_record(zone_name, name, target, ttl, server, key_data).await
647    }
648
649    /// Add a TXT record using dynamic DNS update (RFC 2136).
650    ///
651    /// # Errors
652    ///
653    /// Returns an error if the DNS update fails or the server rejects it.
654    #[allow(clippy::too_many_arguments)]
655    pub async fn add_txt_record(
656        &self,
657        zone_name: &str,
658        name: &str,
659        texts: &[String],
660        ttl: Option<i32>,
661        server: &str,
662        key_data: &RndcKeyData,
663    ) -> Result<()> {
664        records::txt::add_txt_record(zone_name, name, texts, ttl, server, key_data).await
665    }
666
667    /// Add an MX record using dynamic DNS update (RFC 2136).
668    ///
669    /// # Errors
670    ///
671    /// Returns an error if the DNS update fails or the server rejects it.
672    #[allow(clippy::too_many_arguments)]
673    pub async fn add_mx_record(
674        &self,
675        zone_name: &str,
676        name: &str,
677        priority: i32,
678        mail_server: &str,
679        ttl: Option<i32>,
680        server: &str,
681        key_data: &RndcKeyData,
682    ) -> Result<()> {
683        records::mx::add_mx_record(
684            zone_name,
685            name,
686            priority,
687            mail_server,
688            ttl,
689            server,
690            key_data,
691        )
692        .await
693    }
694
695    /// Add an NS record using dynamic DNS update (RFC 2136).
696    ///
697    /// # Errors
698    ///
699    /// Returns an error if the DNS update fails or the server rejects it.
700    #[allow(clippy::too_many_arguments)]
701    pub async fn add_ns_record(
702        &self,
703        zone_name: &str,
704        name: &str,
705        nameserver: &str,
706        ttl: Option<i32>,
707        server: &str,
708        key_data: &RndcKeyData,
709    ) -> Result<()> {
710        records::ns::add_ns_record(zone_name, name, nameserver, ttl, server, key_data).await
711    }
712
713    /// Add an SRV record using dynamic DNS update (RFC 2136).
714    ///
715    /// # Errors
716    ///
717    /// Returns an error if:
718    /// - DNS server connection fails
719    /// - TSIG signer creation fails
720    /// - DNS update is rejected by the server
721    /// - Invalid domain name or target
722    #[allow(clippy::too_many_arguments)]
723    pub async fn add_srv_record(
724        &self,
725        zone_name: &str,
726        name: &str,
727        srv_data: &SRVRecordData,
728        server: &str,
729        key_data: &RndcKeyData,
730    ) -> Result<()> {
731        records::srv::add_srv_record(zone_name, name, srv_data, server, key_data).await
732    }
733
734    /// Add a CAA record using dynamic DNS update (RFC 2136).
735    ///
736    /// # Errors
737    ///
738    /// Returns an error if:
739    /// - DNS server connection fails
740    /// - TSIG signer creation fails
741    /// - DNS update is rejected by the server
742    /// - Invalid domain name, flags, tag, or value
743    #[allow(clippy::too_many_arguments)]
744    #[allow(clippy::too_many_lines)]
745    pub async fn add_caa_record(
746        &self,
747        zone_name: &str,
748        name: &str,
749        flags: i32,
750        tag: &str,
751        value: &str,
752        ttl: Option<i32>,
753        server: &str,
754        key_data: &RndcKeyData,
755    ) -> Result<()> {
756        records::caa::add_caa_record(zone_name, name, flags, tag, value, ttl, server, key_data)
757            .await
758    }
759
760    /// Delete a DNS record using dynamic DNS update (RFC 2136).
761    ///
762    /// This method deletes ALL records of the specified type for the given name.
763    /// It's idempotent - deleting a non-existent record is a no-op.
764    ///
765    /// # Arguments
766    ///
767    /// * `zone_name` - The DNS zone name
768    /// * `name` - The record name (e.g., "www" for www.example.com)
769    /// * `record_type` - The type of record to delete (A, AAAA, CNAME, etc.)
770    /// * `server` - The DNS server address (IP:port)
771    /// * `key_data` - TSIG key for authentication
772    ///
773    /// # Errors
774    ///
775    /// Returns an error if the DNS server rejects the update or connection fails.
776    pub async fn delete_record(
777        &self,
778        zone_name: &str,
779        name: &str,
780        record_type: hickory_client::rr::RecordType,
781        server: &str,
782        key_data: &RndcKeyData,
783    ) -> Result<()> {
784        records::delete_dns_record(zone_name, name, record_type, server, key_data).await
785    }
786
787    // ===== RNDC static methods (exposed through the struct for backwards compatibility) =====
788
789    /// Generate a new RNDC key with HMAC-SHA256.
790    ///
791    /// Returns a base64-encoded 256-bit (32-byte) key suitable for rndc authentication.
792    #[must_use]
793    pub fn generate_rndc_key() -> RndcKeyData {
794        rndc::generate_rndc_key()
795    }
796
797    /// Create a Kubernetes Secret manifest for an RNDC key.
798    ///
799    /// Returns a `BTreeMap` suitable for use as Secret data.
800    #[must_use]
801    pub fn create_rndc_secret_data(
802        key_data: &RndcKeyData,
803    ) -> std::collections::BTreeMap<String, String> {
804        rndc::create_rndc_secret_data(key_data)
805    }
806
807    /// Parse RNDC key data from a Kubernetes Secret.
808    ///
809    /// Supports two Secret formats:
810    /// 1. **Operator-generated** (all 4 fields): `key-name`, `algorithm`, `secret`, `rndc.key`
811    /// 2. **External/user-managed** (minimal): `rndc.key` only - parses the BIND9 key file
812    ///
813    /// # Errors
814    ///
815    /// Returns an error if:
816    /// - Neither the metadata fields nor `rndc.key` are present
817    /// - The `rndc.key` file cannot be parsed
818    /// - Values are not valid UTF-8 strings
819    pub fn parse_rndc_secret_data(
820        data: &std::collections::BTreeMap<String, Vec<u8>>,
821    ) -> Result<RndcKeyData> {
822        rndc::parse_rndc_secret_data(data)
823    }
824}
825
826impl Default for Bind9Manager {
827    fn default() -> Self {
828        Self::new()
829    }
830}
831
832// Declare test modules
833#[cfg(test)]
834mod mod_tests;