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;