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;