bindy/bind9/
rndc.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! RNDC key generation and management functions.
5
6use super::types::RndcKeyData;
7use anyhow::{Context, Result};
8use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
9use hickory_client::rr::rdata::tsig::TsigAlgorithm;
10use hickory_client::rr::Name;
11use hickory_proto::rr::dnssec::tsig::TSigner;
12use rand::Rng;
13use std::collections::BTreeMap;
14use std::str::FromStr;
15
16use crate::constants::TSIG_FUDGE_TIME_SECS;
17
18/// Generate a new RNDC key with HMAC-SHA256.
19///
20/// Returns a base64-encoded 256-bit (32-byte) key suitable for rndc authentication.
21#[must_use]
22pub fn generate_rndc_key() -> RndcKeyData {
23    let mut rng = rand::rng();
24    let mut key_bytes = [0u8; 32]; // 256 bits for HMAC-SHA256
25    rng.fill_bytes(&mut key_bytes);
26
27    RndcKeyData {
28        name: String::new(), // Will be set by caller
29        algorithm: crate::crd::RndcAlgorithm::HmacSha256,
30        secret: BASE64.encode(key_bytes),
31    }
32}
33
34/// Create a Kubernetes Secret manifest for an RNDC key.
35///
36/// Returns a `BTreeMap` suitable for use as Secret data.
37#[must_use]
38pub fn create_rndc_secret_data(key_data: &RndcKeyData) -> BTreeMap<String, String> {
39    let mut data = BTreeMap::new();
40    data.insert("key-name".to_string(), key_data.name.clone());
41    data.insert(
42        "algorithm".to_string(),
43        key_data.algorithm.as_str().to_string(),
44    );
45    data.insert("secret".to_string(), key_data.secret.clone());
46
47    // Add rndc.key file content for BIND9 to use
48    let rndc_key_content = format!(
49        "key \"{}\" {{\n    algorithm {};\n    secret \"{}\";\n}};\n",
50        key_data.name,
51        key_data.algorithm.as_str(),
52        key_data.secret
53    );
54    data.insert("rndc.key".to_string(), rndc_key_content);
55
56    data
57}
58
59/// Parse RNDC key data from a Kubernetes Secret.
60///
61/// Supports two Secret formats:
62/// 1. **Operator-generated** (all 4 fields): `key-name`, `algorithm`, `secret`, `rndc.key`
63/// 2. **External/user-managed** (minimal): `rndc.key` only - parses the BIND9 key file
64///
65/// # Errors
66///
67/// Returns an error if:
68/// - Neither the metadata fields nor `rndc.key` are present
69/// - The `rndc.key` file cannot be parsed
70/// - Values are not valid UTF-8 strings
71pub fn parse_rndc_secret_data(data: &BTreeMap<String, Vec<u8>>) -> Result<RndcKeyData> {
72    // Try the operator-generated format first (has all metadata fields)
73    if let (Some(name_bytes), Some(algo_bytes), Some(secret_bytes)) = (
74        data.get("key-name"),
75        data.get("algorithm"),
76        data.get("secret"),
77    ) {
78        let name = std::str::from_utf8(name_bytes)?.to_string();
79        let algorithm_str = std::str::from_utf8(algo_bytes)?;
80        let secret = std::str::from_utf8(secret_bytes)?.to_string();
81
82        let algorithm = match algorithm_str {
83            "hmac-md5" => crate::crd::RndcAlgorithm::HmacMd5,
84            "hmac-sha1" => crate::crd::RndcAlgorithm::HmacSha1,
85            "hmac-sha224" => crate::crd::RndcAlgorithm::HmacSha224,
86            "hmac-sha256" => crate::crd::RndcAlgorithm::HmacSha256,
87            "hmac-sha384" => crate::crd::RndcAlgorithm::HmacSha384,
88            "hmac-sha512" => crate::crd::RndcAlgorithm::HmacSha512,
89            _ => anyhow::bail!("Unsupported RNDC algorithm '{algorithm_str}'. Supported algorithms: hmac-md5, hmac-sha1, hmac-sha224, hmac-sha256, hmac-sha384, hmac-sha512"),
90        };
91
92        return Ok(RndcKeyData {
93            name,
94            algorithm,
95            secret,
96        });
97    }
98
99    // Fall back to parsing the rndc.key file (external Secret format)
100    if let Some(rndc_key_bytes) = data.get("rndc.key") {
101        let rndc_key_content = std::str::from_utf8(rndc_key_bytes)?;
102        return parse_rndc_key_file(rndc_key_content);
103    }
104
105    anyhow::bail!(
106        "Secret must contain either (key-name, algorithm, secret) or rndc.key field. \
107         For external secrets, provide only 'rndc.key' with the BIND9 key file content."
108    )
109}
110
111/// Parse a BIND9 key file (rndc.key format) to extract key metadata.
112///
113/// Expected format:
114/// ```text
115/// key "key-name" {
116///     algorithm hmac-sha256;
117///     secret "base64secret==";
118/// };
119/// ```
120///
121/// # Errors
122///
123/// Returns an error if the file format is invalid or required fields are missing.
124fn parse_rndc_key_file(content: &str) -> Result<RndcKeyData> {
125    // Simple regex-based parser for BIND9 key file format
126    // Format: key "name" { algorithm algo; secret "secret"; };
127
128    // Extract key name
129    let name = content
130        .lines()
131        .find(|line| line.contains("key"))
132        .and_then(|line| {
133            line.split('"').nth(1) // Get the text between first pair of quotes
134        })
135        .context("Failed to parse key name from rndc.key file")?
136        .to_string();
137
138    // Extract algorithm
139    let algorithm_str = content
140        .lines()
141        .find(|line| line.contains("algorithm"))
142        .and_then(|line| {
143            line.split_whitespace()
144                .nth(1) // After "algorithm"
145                .map(|s| s.trim_end_matches(';'))
146        })
147        .context("Failed to parse algorithm from rndc.key file")?;
148
149    let algorithm = match algorithm_str {
150        "hmac-md5" => crate::crd::RndcAlgorithm::HmacMd5,
151        "hmac-sha1" => crate::crd::RndcAlgorithm::HmacSha1,
152        "hmac-sha224" => crate::crd::RndcAlgorithm::HmacSha224,
153        "hmac-sha256" => crate::crd::RndcAlgorithm::HmacSha256,
154        "hmac-sha384" => crate::crd::RndcAlgorithm::HmacSha384,
155        "hmac-sha512" => crate::crd::RndcAlgorithm::HmacSha512,
156        _ => anyhow::bail!("Unsupported algorithm '{algorithm_str}' in rndc.key file"),
157    };
158
159    // Extract secret
160    let secret = content
161        .lines()
162        .find(|line| line.contains("secret"))
163        .and_then(|line| {
164            line.split('"').nth(1) // Get the text between first pair of quotes
165        })
166        .context("Failed to parse secret from rndc.key file")?
167        .to_string();
168
169    Ok(RndcKeyData {
170        name,
171        algorithm,
172        secret,
173    })
174}
175
176/// Create a TSIG signer from RNDC key data.
177///
178/// # Errors
179///
180/// Returns an error if the algorithm is unsupported or key data is invalid.
181pub fn create_tsig_signer(key_data: &RndcKeyData) -> Result<TSigner> {
182    // Map RndcAlgorithm to hickory TsigAlgorithm
183    let algorithm = match key_data.algorithm {
184        crate::crd::RndcAlgorithm::HmacMd5 => TsigAlgorithm::HmacMd5,
185        crate::crd::RndcAlgorithm::HmacSha1 => TsigAlgorithm::HmacSha1,
186        crate::crd::RndcAlgorithm::HmacSha224 => TsigAlgorithm::HmacSha224,
187        crate::crd::RndcAlgorithm::HmacSha256 => TsigAlgorithm::HmacSha256,
188        crate::crd::RndcAlgorithm::HmacSha384 => TsigAlgorithm::HmacSha384,
189        crate::crd::RndcAlgorithm::HmacSha512 => TsigAlgorithm::HmacSha512,
190    };
191
192    // Decode the base64 key
193    let key_bytes = BASE64
194        .decode(&key_data.secret)
195        .context("Failed to decode TSIG key")?;
196
197    // Create TSIG signer
198    let signer = TSigner::new(
199        key_bytes,
200        algorithm,
201        Name::from_str(&key_data.name).context("Invalid TSIG key name")?,
202        u16::try_from(TSIG_FUDGE_TIME_SECS).unwrap_or(300),
203    )
204    .context("Failed to create TSIG signer")?;
205
206    Ok(signer)
207}
208
209/// Create a Kubernetes Secret with RNDC key data and rotation tracking annotations.
210///
211/// This function creates a Secret with the RNDC key data (via `create_rndc_secret_data`)
212/// and adds rotation tracking annotations for automatic key rotation.
213///
214/// # Arguments
215///
216/// * `namespace` - Kubernetes namespace for the Secret
217/// * `name` - Secret name
218/// * `key_data` - RNDC key data (name, algorithm, secret)
219/// * `created_at` - Timestamp when the key was created or last rotated
220/// * `rotate_after` - Optional duration after which to rotate (None = no rotation)
221/// * `rotation_count` - Number of times the key has been rotated (0 for new keys)
222///
223/// # Returns
224///
225/// A Kubernetes Secret resource with:
226/// - RNDC key data in `.data`
227/// - Rotation tracking annotations in `.metadata.annotations`
228///
229/// # Annotations
230///
231/// - `bindy.firestoned.io/rndc-created-at`: ISO 8601 timestamp (always present)
232/// - `bindy.firestoned.io/rndc-rotate-at`: ISO 8601 timestamp (only if `rotate_after` is Some)
233/// - `bindy.firestoned.io/rndc-rotation-count`: Number of rotations (always present)
234///
235/// # Examples
236///
237/// ```rust,no_run
238/// use bindy::bind9::rndc::{generate_rndc_key, create_rndc_secret_with_annotations};
239/// use chrono::Utc;
240/// use std::time::Duration;
241///
242/// let key_data = generate_rndc_key();
243/// let created_at = Utc::now();
244/// let rotate_after = Duration::from_secs(30 * 24 * 3600); // 30 days
245///
246/// let secret = create_rndc_secret_with_annotations(
247///     "bindy-system",
248///     "bind9-primary-rndc-key",
249///     &key_data,
250///     created_at,
251///     Some(rotate_after),
252///     0, // First key, not rotated yet
253/// );
254/// ```
255///
256/// # Panics
257///
258/// May panic if the `rotate_after` duration cannot be converted to a chrono Duration.
259/// This should not happen for valid rotation intervals (1h - 8760h).
260#[must_use]
261pub fn create_rndc_secret_with_annotations(
262    namespace: &str,
263    name: &str,
264    key_data: &RndcKeyData,
265    created_at: chrono::DateTime<chrono::Utc>,
266    rotate_after: Option<std::time::Duration>,
267    rotation_count: u32,
268) -> k8s_openapi::api::core::v1::Secret {
269    use k8s_openapi::api::core::v1::Secret;
270    use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
271    use k8s_openapi::ByteString;
272
273    // Create Secret data with RNDC key
274    let secret_data_map = create_rndc_secret_data(key_data);
275    let mut data = BTreeMap::new();
276    for (k, v) in secret_data_map {
277        data.insert(k, ByteString(v.into_bytes()));
278    }
279
280    // Create rotation tracking annotations
281    let mut annotations = BTreeMap::new();
282    annotations.insert(
283        crate::constants::ANNOTATION_RNDC_CREATED_AT.to_string(),
284        created_at.to_rfc3339(),
285    );
286
287    // Add rotate_at annotation if rotation is enabled
288    if let Some(duration) = rotate_after {
289        let rotate_at = created_at + chrono::Duration::from_std(duration).unwrap();
290        annotations.insert(
291            crate::constants::ANNOTATION_RNDC_ROTATE_AT.to_string(),
292            rotate_at.to_rfc3339(),
293        );
294    }
295
296    annotations.insert(
297        crate::constants::ANNOTATION_RNDC_ROTATION_COUNT.to_string(),
298        rotation_count.to_string(),
299    );
300
301    Secret {
302        metadata: ObjectMeta {
303            name: Some(name.to_string()),
304            namespace: Some(namespace.to_string()),
305            annotations: Some(annotations),
306            ..Default::default()
307        },
308        data: Some(data),
309        ..Default::default()
310    }
311}
312
313/// Parse rotation tracking annotations from a Kubernetes Secret.
314///
315/// Extracts the `created_at`, `rotate_at`, and `rotation_count` annotations
316/// from a Secret's metadata.
317///
318/// # Arguments
319///
320/// * `annotations` - Secret annotations map
321///
322/// # Returns
323///
324/// A tuple of:
325/// - `created_at`: Timestamp when the key was created or last rotated
326/// - `rotate_at`: Optional timestamp when rotation is due (None if rotation disabled)
327/// - `rotation_count`: Number of times the key has been rotated
328///
329/// # Errors
330///
331/// Returns an error if:
332/// - The `created-at` annotation is missing
333/// - Any timestamp cannot be parsed as ISO 8601
334/// - The `rotation-count` annotation cannot be parsed as u32
335///
336/// # Examples
337///
338/// ```rust,no_run
339/// use std::collections::BTreeMap;
340/// use bindy::bind9::rndc::parse_rotation_annotations;
341///
342/// let mut annotations = BTreeMap::new();
343/// annotations.insert(
344///     "bindy.firestoned.io/rndc-created-at".to_string(),
345///     "2025-01-26T10:00:00Z".to_string()
346/// );
347/// annotations.insert(
348///     "bindy.firestoned.io/rndc-rotate-at".to_string(),
349///     "2025-02-25T10:00:00Z".to_string()
350/// );
351/// annotations.insert(
352///     "bindy.firestoned.io/rndc-rotation-count".to_string(),
353///     "5".to_string()
354/// );
355///
356/// let (created_at, rotate_at, count) = parse_rotation_annotations(&annotations).unwrap();
357/// assert_eq!(count, 5);
358/// ```
359pub fn parse_rotation_annotations(
360    annotations: &BTreeMap<String, String>,
361) -> Result<(
362    chrono::DateTime<chrono::Utc>,
363    Option<chrono::DateTime<chrono::Utc>>,
364    u32,
365)> {
366    // Parse created_at (required)
367    let created_at_str = annotations
368        .get(crate::constants::ANNOTATION_RNDC_CREATED_AT)
369        .context("Missing created-at annotation")?;
370    let created_at = chrono::DateTime::parse_from_rfc3339(created_at_str)
371        .context("Failed to parse created-at timestamp")?
372        .with_timezone(&chrono::Utc);
373
374    // Parse rotate_at (optional)
375    let rotate_at =
376        if let Some(rotate_at_str) = annotations.get(crate::constants::ANNOTATION_RNDC_ROTATE_AT) {
377            Some(
378                chrono::DateTime::parse_from_rfc3339(rotate_at_str)
379                    .context("Failed to parse rotate-at timestamp")?
380                    .with_timezone(&chrono::Utc),
381            )
382        } else {
383            None
384        };
385
386    // Parse rotation_count (default to 0 if missing)
387    let rotation_count = annotations
388        .get(crate::constants::ANNOTATION_RNDC_ROTATION_COUNT)
389        .map(|s| s.parse::<u32>().context("Failed to parse rotation-count"))
390        .transpose()?
391        .unwrap_or(0);
392
393    Ok((created_at, rotate_at, rotation_count))
394}
395
396/// Check if RNDC key rotation is due based on the rotation timestamp.
397///
398/// Rotation is due if:
399/// - `rotate_at` is Some AND
400/// - `rotate_at` is less than or equal to `now`
401///
402/// # Arguments
403///
404/// * `rotate_at` - Optional timestamp when rotation should occur (None = no rotation)
405/// * `now` - Current timestamp
406///
407/// # Returns
408///
409/// - `true` if rotation is due (`rotate_at` has passed)
410/// - `false` if rotation is not due or disabled (`rotate_at` is None)
411///
412/// # Examples
413///
414/// ```rust
415/// use bindy::bind9::rndc::is_rotation_due;
416/// use chrono::Utc;
417///
418/// let past_time = Utc::now() - chrono::Duration::hours(1);
419/// let now = Utc::now();
420///
421/// assert!(is_rotation_due(Some(past_time), now)); // Rotation is due
422///
423/// let future_time = Utc::now() + chrono::Duration::hours(1);
424/// assert!(!is_rotation_due(Some(future_time), now)); // Not due yet
425///
426/// assert!(!is_rotation_due(None, now)); // Rotation disabled
427/// ```
428#[must_use]
429pub fn is_rotation_due(
430    rotate_at: Option<chrono::DateTime<chrono::Utc>>,
431    now: chrono::DateTime<chrono::Utc>,
432) -> bool {
433    match rotate_at {
434        Some(rotate_time) => rotate_time <= now,
435        None => false, // No rotation scheduled
436    }
437}
438
439#[cfg(test)]
440#[path = "rndc_tests.rs"]
441mod rndc_tests;