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.dns-system.svc.cluster.local:8080"
33//! ).await?;
34//! # Ok(())
35//! # }
36//! ```
37
38// Module declarations
39pub mod records;
40pub mod rndc;
41pub mod types;
42pub mod zone_ops;
43
44// Re-export public types and functions for backwards compatibility
45pub use rndc::{
46    create_rndc_secret_data, create_tsig_signer, generate_rndc_key, parse_rndc_secret_data,
47};
48pub use types::{RndcError, RndcKeyData, SRVRecordData, SERVICE_ACCOUNT_TOKEN_PATH};
49
50use anyhow::{Context, Result};
51use bindcar::ZoneConfig;
52use reqwest::Client as HttpClient;
53use std::collections::HashMap;
54use std::sync::Arc;
55use tracing::warn;
56
57/// Manager for BIND9 servers via HTTP API sidecar.
58///
59/// The `Bind9Manager` provides methods for managing BIND9 servers running in Kubernetes
60/// pods via an HTTP API sidecar. The API sidecar executes rndc commands locally and
61/// manages zone files. Authentication uses Kubernetes `ServiceAccount` tokens.
62///
63/// # Examples
64///
65/// ```rust,no_run
66/// use bindy::bind9::Bind9Manager;
67///
68/// let manager = Bind9Manager::new();
69/// ```
70#[derive(Debug, Clone)]
71pub struct Bind9Manager {
72    /// HTTP client for API requests
73    client: Arc<HttpClient>,
74    /// `ServiceAccount` token for authentication
75    token: Arc<String>,
76}
77
78impl Bind9Manager {
79    /// Create a new `Bind9Manager`.
80    ///
81    /// Reads the `ServiceAccount` token from the default location and creates
82    /// an HTTP client for API requests.
83    #[must_use]
84    pub fn new() -> Self {
85        let token = Self::read_service_account_token().unwrap_or_else(|e| {
86            warn!(
87                "Failed to read ServiceAccount token: {}. Using empty token.",
88                e
89            );
90            String::new()
91        });
92
93        Self {
94            client: Arc::new(HttpClient::new()),
95            token: Arc::new(token),
96        }
97    }
98
99    /// Read the `ServiceAccount` token from the mounted secret
100    fn read_service_account_token() -> Result<String> {
101        std::fs::read_to_string(SERVICE_ACCOUNT_TOKEN_PATH)
102            .context("Failed to read ServiceAccount token file")
103    }
104
105    /// Build the API base URL from a server address
106    ///
107    /// Converts "service-name.namespace.svc.cluster.local:8080" or "service-name:8080"
108    /// to `<http://service-name.namespace.svc.cluster.local:8080>` or `<http://service-name:8080>`
109    ///
110    /// This is a public method for testing purposes.
111    #[must_use]
112    pub fn build_api_url(server: &str) -> String {
113        zone_ops::build_api_url(server)
114    }
115
116    // ===== Zone management methods =====
117
118    /// Reload a specific zone via HTTP API.
119    ///
120    /// This operation is idempotent - if the zone doesn't exist, it returns an error
121    /// with a clear message indicating the zone was not found.
122    ///
123    /// # Arguments
124    /// * `zone_name` - Name of the zone to reload
125    /// * `server` - API server address (e.g., "bind9-primary-api:8080")
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if the HTTP request fails or the zone cannot be reloaded.
130    pub async fn reload_zone(&self, zone_name: &str, server: &str) -> Result<()> {
131        zone_ops::reload_zone(&self.client, &self.token, zone_name, server).await
132    }
133
134    /// Reload all zones via HTTP API.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if the HTTP request fails.
139    pub async fn reload_all_zones(&self, server: &str) -> Result<()> {
140        zone_ops::reload_all_zones(&self.client, &self.token, server).await
141    }
142
143    /// Trigger zone transfer via HTTP API.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the HTTP request fails or the zone transfer cannot be initiated.
148    pub async fn retransfer_zone(&self, zone_name: &str, server: &str) -> Result<()> {
149        zone_ops::retransfer_zone(&self.client, &self.token, zone_name, server).await
150    }
151
152    /// Freeze a zone to prevent dynamic updates via HTTP API.
153    ///
154    /// # Errors
155    ///
156    /// Returns an error if the HTTP request fails or the zone cannot be frozen.
157    pub async fn freeze_zone(&self, zone_name: &str, server: &str) -> Result<()> {
158        zone_ops::freeze_zone(&self.client, &self.token, zone_name, server).await
159    }
160
161    /// Thaw a frozen zone to allow dynamic updates via HTTP API.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if the HTTP request fails or the zone cannot be thawed.
166    pub async fn thaw_zone(&self, zone_name: &str, server: &str) -> Result<()> {
167        zone_ops::thaw_zone(&self.client, &self.token, zone_name, server).await
168    }
169
170    /// Get zone status via HTTP API.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if the HTTP request fails or the zone status cannot be retrieved.
175    pub async fn zone_status(&self, zone_name: &str, server: &str) -> Result<String> {
176        zone_ops::zone_status(&self.client, &self.token, zone_name, server).await
177    }
178
179    /// Check if a zone exists by trying to get its status.
180    ///
181    /// Returns `true` if the zone exists and can be queried, `false` otherwise.
182    pub async fn zone_exists(&self, zone_name: &str, server: &str) -> bool {
183        zone_ops::zone_exists(&self.client, &self.token, zone_name, server).await
184    }
185
186    /// Get server status via HTTP API.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if the HTTP request fails or the server status cannot be retrieved.
191    pub async fn server_status(&self, server: &str) -> Result<String> {
192        zone_ops::server_status(&self.client, &self.token, server).await
193    }
194
195    /// Add a zone via HTTP API (primary or secondary).
196    ///
197    /// This is the centralized zone addition method that dispatches to either
198    /// `add_primary_zone` or `add_secondary_zone` based on the zone type.
199    ///
200    /// This operation is idempotent - if the zone already exists, it returns success
201    /// without attempting to re-add it.
202    ///
203    /// # Arguments
204    /// * `zone_name` - Name of the zone (e.g., "example.com")
205    /// * `zone_type` - Zone type (use `ZONE_TYPE_PRIMARY` or `ZONE_TYPE_SECONDARY` constants)
206    /// * `server` - API endpoint (e.g., "bind9-primary-api:8080" or "bind9-secondary-api:8080")
207    /// * `key_data` - RNDC key data
208    /// * `soa_record` - Optional SOA record data (required for primary zones, ignored for secondary)
209    /// * `name_server_ips` - Optional map of nameserver hostnames to IP addresses (for primary zones)
210    /// * `secondary_ips` - Optional list of secondary server IPs for also-notify and allow-transfer (for primary zones)
211    /// * `primary_ips` - Optional list of primary server IPs to transfer from (for secondary zones)
212    ///
213    /// # Returns
214    ///
215    /// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if the HTTP request fails or the zone cannot be added.
220    #[allow(clippy::too_many_arguments)]
221    pub async fn add_zones(
222        &self,
223        zone_name: &str,
224        zone_type: &str,
225        server: &str,
226        key_data: &RndcKeyData,
227        soa_record: Option<&crate::crd::SOARecord>,
228        name_server_ips: Option<&HashMap<String, String>>,
229        secondary_ips: Option<&[String]>,
230        primary_ips: Option<&[String]>,
231    ) -> Result<bool> {
232        zone_ops::add_zones(
233            &self.client,
234            &self.token,
235            zone_name,
236            zone_type,
237            server,
238            key_data,
239            soa_record,
240            name_server_ips,
241            secondary_ips,
242            primary_ips,
243        )
244        .await
245    }
246
247    /// Add a new primary zone via HTTP API.
248    ///
249    /// This operation is idempotent - if the zone already exists, it returns success
250    /// without attempting to re-add it.
251    ///
252    /// The zone is created with `allow-update` enabled for the TSIG key used by the operator.
253    /// This allows dynamic DNS updates (RFC 2136) to add/update/delete records in the zone.
254    ///
255    /// **Note:** This method creates a zone without initial content. For creating zones with
256    /// initial SOA/NS records, use `create_zone_http()` instead.
257    ///
258    /// # Arguments
259    /// * `zone_name` - Name of the zone (e.g., "example.com")
260    /// * `server` - API endpoint (e.g., "bind9-primary-api:8080")
261    /// * `key_data` - RNDC key data (used for allow-update configuration)
262    /// * `soa_record` - SOA record data
263    /// * `name_server_ips` - Optional map of nameserver hostnames to IP addresses for glue records
264    /// * `secondary_ips` - Optional list of secondary server IPs for also-notify and allow-transfer
265    ///
266    /// # Returns
267    ///
268    /// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if the HTTP request fails or the zone cannot be added.
273    #[allow(
274        clippy::cast_possible_truncation,
275        clippy::cast_sign_loss,
276        clippy::too_many_arguments
277    )]
278    pub async fn add_primary_zone(
279        &self,
280        zone_name: &str,
281        server: &str,
282        key_data: &RndcKeyData,
283        soa_record: &crate::crd::SOARecord,
284        name_server_ips: Option<&HashMap<String, String>>,
285        secondary_ips: Option<&[String]>,
286    ) -> Result<bool> {
287        zone_ops::add_primary_zone(
288            &self.client,
289            &self.token,
290            zone_name,
291            server,
292            key_data,
293            soa_record,
294            name_server_ips,
295            secondary_ips,
296        )
297        .await
298    }
299
300    /// Add a secondary zone via HTTP API.
301    ///
302    /// Creates a secondary zone that will transfer from the specified primary servers.
303    /// This is a convenience method specifically for secondary zones.
304    ///
305    /// # Arguments
306    /// * `zone_name` - Name of the zone (e.g., "example.com")
307    /// * `server` - API endpoint of the secondary server (e.g., "bind9-secondary-api:8080")
308    /// * `key_data` - RNDC key data
309    /// * `primary_ips` - List of primary server IP addresses to transfer from
310    ///
311    /// # Returns
312    ///
313    /// Returns `Ok(true)` if the zone was added, `Ok(false)` if it already existed.
314    ///
315    /// # Errors
316    ///
317    /// Returns an error if the HTTP request fails or the zone cannot be added.
318    pub async fn add_secondary_zone(
319        &self,
320        zone_name: &str,
321        server: &str,
322        key_data: &RndcKeyData,
323        primary_ips: &[String],
324    ) -> Result<bool> {
325        zone_ops::add_secondary_zone(
326            &self.client,
327            &self.token,
328            zone_name,
329            server,
330            key_data,
331            primary_ips,
332        )
333        .await
334    }
335
336    /// Create a zone via HTTP API with structured configuration.
337    ///
338    /// This method sends a POST request to the API sidecar to create a zone using
339    /// structured zone configuration from the bindcar library.
340    ///
341    /// # Arguments
342    /// * `zone_name` - Name of the zone (e.g., "example.com")
343    /// * `zone_type` - Zone type (use `ZONE_TYPE_PRIMARY` or `ZONE_TYPE_SECONDARY` constants)
344    /// * `zone_config` - Structured zone configuration (converted to zone file by bindcar)
345    /// * `server` - API endpoint (e.g., "bind9-primary-api:8080")
346    /// * `key_data` - RNDC authentication key (used as updateKeyName)
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if the HTTP request fails or the zone cannot be created.
351    #[allow(clippy::too_many_arguments)]
352    pub async fn create_zone_http(
353        &self,
354        zone_name: &str,
355        zone_type: &str,
356        zone_config: ZoneConfig,
357        server: &str,
358        key_data: &RndcKeyData,
359    ) -> Result<()> {
360        zone_ops::create_zone_http(
361            &self.client,
362            &self.token,
363            zone_name,
364            zone_type,
365            zone_config,
366            server,
367            key_data,
368        )
369        .await
370    }
371
372    /// Delete a zone via HTTP API.
373    ///
374    /// # Errors
375    ///
376    /// Returns an error if the HTTP request fails or the zone cannot be deleted.
377    pub async fn delete_zone(&self, zone_name: &str, server: &str) -> Result<()> {
378        zone_ops::delete_zone(&self.client, &self.token, zone_name, server).await
379    }
380
381    /// Notify secondaries about zone changes via HTTP API.
382    ///
383    /// # Errors
384    ///
385    /// Returns an error if the HTTP request fails or the notification cannot be sent.
386    pub async fn notify_zone(&self, zone_name: &str, server: &str) -> Result<()> {
387        zone_ops::notify_zone(&self.client, &self.token, zone_name, server).await
388    }
389
390    // ===== DNS record management methods =====
391
392    /// Add an A record using dynamic DNS update (RFC 2136).
393    ///
394    /// # Arguments
395    /// * `zone_name` - DNS zone name (e.g., "example.com")
396    /// * `name` - Record name (e.g., "www" for www.example.com, or "@" for apex)
397    /// * `ipv4` - IPv4 address
398    /// * `ttl` - Time to live in seconds (None = use zone default)
399    /// * `server` - DNS server address with port (e.g., "10.0.0.1:53")
400    /// * `key_data` - TSIG key for authentication
401    ///
402    /// # Errors
403    ///
404    /// Returns an error if the DNS update fails or the server rejects it.
405    #[allow(clippy::too_many_arguments)]
406    pub async fn add_a_record(
407        &self,
408        zone_name: &str,
409        name: &str,
410        ipv4: &str,
411        ttl: Option<i32>,
412        server: &str,
413        key_data: &RndcKeyData,
414    ) -> Result<()> {
415        records::a::add_a_record(zone_name, name, ipv4, ttl, server, key_data).await
416    }
417
418    /// Add an AAAA record using dynamic DNS update (RFC 2136).
419    ///
420    /// # Errors
421    ///
422    /// Returns an error if the DNS update fails or the server rejects it.
423    #[allow(clippy::too_many_arguments)]
424    pub async fn add_aaaa_record(
425        &self,
426        zone_name: &str,
427        name: &str,
428        ipv6: &str,
429        ttl: Option<i32>,
430        server: &str,
431        key_data: &RndcKeyData,
432    ) -> Result<()> {
433        records::a::add_aaaa_record(zone_name, name, ipv6, ttl, server, key_data).await
434    }
435
436    /// Add a CNAME record using dynamic DNS update (RFC 2136).
437    ///
438    /// # Errors
439    ///
440    /// Returns an error if the DNS update fails or the server rejects it.
441    #[allow(clippy::too_many_arguments)]
442    pub async fn add_cname_record(
443        &self,
444        zone_name: &str,
445        name: &str,
446        target: &str,
447        ttl: Option<i32>,
448        server: &str,
449        key_data: &RndcKeyData,
450    ) -> Result<()> {
451        records::cname::add_cname_record(zone_name, name, target, ttl, server, key_data).await
452    }
453
454    /// Add a TXT record using dynamic DNS update (RFC 2136).
455    ///
456    /// # Errors
457    ///
458    /// Returns an error if the DNS update fails or the server rejects it.
459    #[allow(clippy::too_many_arguments)]
460    pub async fn add_txt_record(
461        &self,
462        zone_name: &str,
463        name: &str,
464        texts: &[String],
465        ttl: Option<i32>,
466        server: &str,
467        key_data: &RndcKeyData,
468    ) -> Result<()> {
469        records::txt::add_txt_record(zone_name, name, texts, ttl, server, key_data).await
470    }
471
472    /// Add an MX record using dynamic DNS update (RFC 2136).
473    ///
474    /// # Errors
475    ///
476    /// Returns an error if the DNS update fails or the server rejects it.
477    #[allow(clippy::too_many_arguments)]
478    pub async fn add_mx_record(
479        &self,
480        zone_name: &str,
481        name: &str,
482        priority: i32,
483        mail_server: &str,
484        ttl: Option<i32>,
485        server: &str,
486        key_data: &RndcKeyData,
487    ) -> Result<()> {
488        records::mx::add_mx_record(
489            zone_name,
490            name,
491            priority,
492            mail_server,
493            ttl,
494            server,
495            key_data,
496        )
497        .await
498    }
499
500    /// Add an NS record using dynamic DNS update (RFC 2136).
501    ///
502    /// # Errors
503    ///
504    /// Returns an error if the DNS update fails or the server rejects it.
505    #[allow(clippy::too_many_arguments)]
506    pub async fn add_ns_record(
507        &self,
508        zone_name: &str,
509        name: &str,
510        nameserver: &str,
511        ttl: Option<i32>,
512        server: &str,
513        key_data: &RndcKeyData,
514    ) -> Result<()> {
515        records::ns::add_ns_record(zone_name, name, nameserver, ttl, server, key_data).await
516    }
517
518    /// Add an SRV record using dynamic DNS update (RFC 2136).
519    ///
520    /// # Errors
521    ///
522    /// Returns an error if:
523    /// - DNS server connection fails
524    /// - TSIG signer creation fails
525    /// - DNS update is rejected by the server
526    /// - Invalid domain name or target
527    #[allow(clippy::too_many_arguments)]
528    pub async fn add_srv_record(
529        &self,
530        zone_name: &str,
531        name: &str,
532        srv_data: &SRVRecordData,
533        server: &str,
534        key_data: &RndcKeyData,
535    ) -> Result<()> {
536        records::srv::add_srv_record(zone_name, name, srv_data, server, key_data).await
537    }
538
539    /// Add a CAA record using dynamic DNS update (RFC 2136).
540    ///
541    /// # Errors
542    ///
543    /// Returns an error if:
544    /// - DNS server connection fails
545    /// - TSIG signer creation fails
546    /// - DNS update is rejected by the server
547    /// - Invalid domain name, flags, tag, or value
548    #[allow(clippy::too_many_arguments)]
549    #[allow(clippy::too_many_lines)]
550    pub async fn add_caa_record(
551        &self,
552        zone_name: &str,
553        name: &str,
554        flags: i32,
555        tag: &str,
556        value: &str,
557        ttl: Option<i32>,
558        server: &str,
559        key_data: &RndcKeyData,
560    ) -> Result<()> {
561        records::caa::add_caa_record(zone_name, name, flags, tag, value, ttl, server, key_data)
562            .await
563    }
564
565    // ===== RNDC static methods (exposed through the struct for backwards compatibility) =====
566
567    /// Generate a new RNDC key with HMAC-SHA256.
568    ///
569    /// Returns a base64-encoded 256-bit (32-byte) key suitable for rndc authentication.
570    #[must_use]
571    pub fn generate_rndc_key() -> RndcKeyData {
572        rndc::generate_rndc_key()
573    }
574
575    /// Create a Kubernetes Secret manifest for an RNDC key.
576    ///
577    /// Returns a `BTreeMap` suitable for use as Secret data.
578    #[must_use]
579    pub fn create_rndc_secret_data(
580        key_data: &RndcKeyData,
581    ) -> std::collections::BTreeMap<String, String> {
582        rndc::create_rndc_secret_data(key_data)
583    }
584
585    /// Parse RNDC key data from a Kubernetes Secret.
586    ///
587    /// Supports two Secret formats:
588    /// 1. **Operator-generated** (all 4 fields): `key-name`, `algorithm`, `secret`, `rndc.key`
589    /// 2. **External/user-managed** (minimal): `rndc.key` only - parses the BIND9 key file
590    ///
591    /// # Errors
592    ///
593    /// Returns an error if:
594    /// - Neither the metadata fields nor `rndc.key` are present
595    /// - The `rndc.key` file cannot be parsed
596    /// - Values are not valid UTF-8 strings
597    pub fn parse_rndc_secret_data(
598        data: &std::collections::BTreeMap<String, Vec<u8>>,
599    ) -> Result<RndcKeyData> {
600        rndc::parse_rndc_secret_data(data)
601    }
602}
603
604impl Default for Bind9Manager {
605    fn default() -> Self {
606        Self::new()
607    }
608}
609
610// Declare test modules
611#[cfg(test)]
612mod mod_tests;