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_proto::rr::rdata::tsig::TsigAlgorithm;
10use hickory_proto::rr::Name;
11use hickory_proto::rr::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" | "hmac-sha1" => anyhow::bail!(
84                "{algorithm_str} is rejected: MD5 and SHA-1 are deprecated for HMAC \
85                 use (RFC 8945 §10) and are refused by bindcar 0.7.0. Rotate to \
86                 hmac-sha256 or stronger."
87            ),
88            "hmac-sha224" => crate::crd::RndcAlgorithm::HmacSha224,
89            "hmac-sha256" => crate::crd::RndcAlgorithm::HmacSha256,
90            "hmac-sha384" => crate::crd::RndcAlgorithm::HmacSha384,
91            "hmac-sha512" => crate::crd::RndcAlgorithm::HmacSha512,
92            _ => anyhow::bail!("Unsupported RNDC algorithm '{algorithm_str}'. Supported algorithms: hmac-sha224, hmac-sha256, hmac-sha384, hmac-sha512"),
93        };
94
95        return Ok(RndcKeyData {
96            name,
97            algorithm,
98            secret,
99        });
100    }
101
102    // Fall back to parsing the rndc.key file (external Secret format)
103    if let Some(rndc_key_bytes) = data.get("rndc.key") {
104        let rndc_key_content = std::str::from_utf8(rndc_key_bytes)?;
105        return parse_rndc_key_file(rndc_key_content);
106    }
107
108    anyhow::bail!(
109        "Secret must contain either (key-name, algorithm, secret) or rndc.key field. \
110         For external secrets, provide only 'rndc.key' with the BIND9 key file content."
111    )
112}
113
114/// Start delimiter of a C-style block comment in BIND9 configuration files.
115const BLOCK_COMMENT_START: &str = "/*";
116
117/// End delimiter of a C-style block comment in BIND9 configuration files.
118const BLOCK_COMMENT_END: &str = "*/";
119
120/// Remove C-style `/* ... */` block comments from BIND9 configuration content.
121///
122/// An unterminated block comment swallows the rest of the content, matching
123/// how `named` itself treats an unterminated comment.
124fn strip_block_comments(content: &str) -> String {
125    let mut stripped = String::with_capacity(content.len());
126    let mut rest = content;
127
128    while let Some(start) = rest.find(BLOCK_COMMENT_START) {
129        stripped.push_str(&rest[..start]);
130        let after_start = &rest[start + BLOCK_COMMENT_START.len()..];
131        let Some(end) = after_start.find(BLOCK_COMMENT_END) else {
132            // Unterminated block comment: discard the remainder
133            return stripped;
134        };
135        rest = &after_start[end + BLOCK_COMMENT_END.len()..];
136    }
137
138    stripped.push_str(rest);
139    stripped
140}
141
142/// Returns `true` if a trimmed line is a `#` or `//` line comment.
143fn is_line_comment(line: &str) -> bool {
144    line.starts_with('#') || line.starts_with("//")
145}
146
147/// Parse a BIND9 key file (rndc.key format) to extract key metadata.
148///
149/// Comment lines (`#`, `//`) and `/* ... */` block comments are ignored, so a
150/// leading comment such as `# rndc key "docs-example"` cannot poison the
151/// parsed key name (which would cause TSIG NOTAUTH on every update).
152///
153/// Expected format:
154/// ```text
155/// key "key-name" {
156///     algorithm hmac-sha256;
157///     secret "base64secret==";
158/// };
159/// ```
160///
161/// # Errors
162///
163/// Returns an error if the file format is invalid or required fields are missing.
164fn parse_rndc_key_file(content: &str) -> Result<RndcKeyData> {
165    // Simple line-based parser for BIND9 key file format
166    // Format: key "name" { algorithm algo; secret "secret"; };
167    let content = strip_block_comments(content);
168    let statement_lines: Vec<&str> = content
169        .lines()
170        .map(str::trim)
171        .filter(|line| !line.is_empty() && !is_line_comment(line))
172        .collect();
173
174    // Extract key name from the `key "name" {` statement
175    let name = statement_lines
176        .iter()
177        .find(|line| line.starts_with("key ") || line.starts_with("key\""))
178        .and_then(|line| {
179            line.split('"').nth(1) // Get the text between first pair of quotes
180        })
181        .context("Failed to parse key name from rndc.key file")?
182        .to_string();
183
184    // Extract algorithm from the `algorithm <algo>;` statement
185    let algorithm_str = statement_lines
186        .iter()
187        .find(|line| line.starts_with("algorithm"))
188        .and_then(|line| {
189            line.split_whitespace()
190                .nth(1) // After "algorithm"
191                .map(|s| s.trim_end_matches(';'))
192        })
193        .context("Failed to parse algorithm from rndc.key file")?;
194
195    let algorithm = match algorithm_str {
196        "hmac-md5" | "hmac-sha1" => anyhow::bail!(
197            "{algorithm_str} in rndc.key file is rejected: MD5 and SHA-1 are \
198             deprecated for HMAC use (RFC 8945 §10) and are refused by bindcar \
199             0.7.0. Rotate to hmac-sha256 or stronger."
200        ),
201        "hmac-sha224" => crate::crd::RndcAlgorithm::HmacSha224,
202        "hmac-sha256" => crate::crd::RndcAlgorithm::HmacSha256,
203        "hmac-sha384" => crate::crd::RndcAlgorithm::HmacSha384,
204        "hmac-sha512" => crate::crd::RndcAlgorithm::HmacSha512,
205        _ => anyhow::bail!("Unsupported algorithm '{algorithm_str}' in rndc.key file"),
206    };
207
208    // Extract secret from the `secret "...";` statement
209    let secret = statement_lines
210        .iter()
211        .find(|line| line.starts_with("secret"))
212        .and_then(|line| {
213            line.split('"').nth(1) // Get the text between first pair of quotes
214        })
215        .context("Failed to parse secret from rndc.key file")?
216        .to_string();
217
218    Ok(RndcKeyData {
219        name,
220        algorithm,
221        secret,
222    })
223}
224
225/// Create a TSIG signer from RNDC key data.
226///
227/// # Errors
228///
229/// Returns an error if the algorithm is unsupported or key data is invalid.
230pub fn create_tsig_signer(key_data: &RndcKeyData) -> Result<TSigner> {
231    // Map RndcAlgorithm to hickory TsigAlgorithm. HMAC-MD5 is intentionally
232    // absent from the source enum (see crd::RndcAlgorithm) because RFC 8945
233    // deprecates it; nothing can reach this match with MD5.
234    let algorithm = match key_data.algorithm {
235        crate::crd::RndcAlgorithm::HmacSha1 => TsigAlgorithm::HmacSha1,
236        crate::crd::RndcAlgorithm::HmacSha224 => TsigAlgorithm::HmacSha224,
237        crate::crd::RndcAlgorithm::HmacSha256 => TsigAlgorithm::HmacSha256,
238        crate::crd::RndcAlgorithm::HmacSha384 => TsigAlgorithm::HmacSha384,
239        crate::crd::RndcAlgorithm::HmacSha512 => TsigAlgorithm::HmacSha512,
240    };
241
242    // Decode the base64 key
243    let key_bytes = BASE64
244        .decode(&key_data.secret)
245        .context("Failed to decode TSIG key")?;
246
247    // Create TSIG signer
248    let signer = TSigner::new(
249        key_bytes,
250        algorithm,
251        Name::from_str(&key_data.name).context("Invalid TSIG key name")?,
252        u16::try_from(TSIG_FUDGE_TIME_SECS).unwrap_or(300),
253    )
254    .context("Failed to create TSIG signer")?;
255
256    Ok(signer)
257}
258
259/// Create a Kubernetes Secret with RNDC key data and rotation tracking annotations.
260///
261/// This function creates a Secret with the RNDC key data (via `create_rndc_secret_data`)
262/// and adds rotation tracking annotations for automatic key rotation.
263///
264/// # Arguments
265///
266/// * `namespace` - Kubernetes namespace for the Secret
267/// * `name` - Secret name
268/// * `key_data` - RNDC key data (name, algorithm, secret)
269/// * `created_at` - Timestamp when the key was created or last rotated
270/// * `rotate_after` - Optional duration after which to rotate (None = no rotation)
271/// * `rotation_count` - Number of times the key has been rotated (0 for new keys)
272///
273/// # Returns
274///
275/// A Kubernetes Secret resource with:
276/// - RNDC key data in `.data`
277/// - Rotation tracking annotations in `.metadata.annotations`
278///
279/// # Annotations
280///
281/// - `bindy.firestoned.io/rndc-created-at`: ISO 8601 timestamp (always present)
282/// - `bindy.firestoned.io/rndc-rotate-at`: ISO 8601 timestamp (only if `rotate_after` is Some)
283/// - `bindy.firestoned.io/rndc-rotation-count`: Number of rotations (always present)
284///
285/// # Examples
286///
287/// ```rust,no_run
288/// use bindy::bind9::rndc::{generate_rndc_key, create_rndc_secret_with_annotations};
289/// use chrono::Utc;
290/// use std::time::Duration;
291///
292/// let key_data = generate_rndc_key();
293/// let created_at = Utc::now();
294/// let rotate_after = Duration::from_secs(30 * 24 * 3600); // 30 days
295///
296/// let secret = create_rndc_secret_with_annotations(
297///     "bindy-system",
298///     "bind9-primary-rndc-key",
299///     &key_data,
300///     created_at,
301///     Some(rotate_after),
302///     0, // First key, not rotated yet
303/// );
304/// ```
305///
306/// # Panics
307///
308/// May panic if the `rotate_after` duration cannot be converted to a chrono Duration.
309/// This should not happen for valid rotation intervals (1h - 8760h).
310#[must_use]
311pub fn create_rndc_secret_with_annotations(
312    namespace: &str,
313    name: &str,
314    key_data: &RndcKeyData,
315    created_at: chrono::DateTime<chrono::Utc>,
316    rotate_after: Option<std::time::Duration>,
317    rotation_count: u32,
318) -> k8s_openapi::api::core::v1::Secret {
319    use k8s_openapi::api::core::v1::Secret;
320    use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
321    use k8s_openapi::ByteString;
322
323    // Create Secret data with RNDC key
324    let secret_data_map = create_rndc_secret_data(key_data);
325    let mut data = BTreeMap::new();
326    for (k, v) in secret_data_map {
327        data.insert(k, ByteString(v.into_bytes()));
328    }
329
330    // Create rotation tracking annotations
331    let mut annotations = BTreeMap::new();
332    annotations.insert(
333        crate::constants::ANNOTATION_RNDC_CREATED_AT.to_string(),
334        created_at.to_rfc3339(),
335    );
336
337    // Add rotate_at annotation if rotation is enabled
338    if let Some(duration) = rotate_after {
339        let rotate_at = created_at + chrono::Duration::from_std(duration).unwrap();
340        annotations.insert(
341            crate::constants::ANNOTATION_RNDC_ROTATE_AT.to_string(),
342            rotate_at.to_rfc3339(),
343        );
344    }
345
346    annotations.insert(
347        crate::constants::ANNOTATION_RNDC_ROTATION_COUNT.to_string(),
348        rotation_count.to_string(),
349    );
350
351    Secret {
352        metadata: ObjectMeta {
353            name: Some(name.to_string()),
354            namespace: Some(namespace.to_string()),
355            annotations: Some(annotations),
356            ..Default::default()
357        },
358        data: Some(data),
359        ..Default::default()
360    }
361}
362
363/// Parse rotation tracking annotations from a Kubernetes Secret.
364///
365/// Extracts the `created_at`, `rotate_at`, and `rotation_count` annotations
366/// from a Secret's metadata.
367///
368/// # Arguments
369///
370/// * `annotations` - Secret annotations map
371///
372/// # Returns
373///
374/// A tuple of:
375/// - `created_at`: Timestamp when the key was created or last rotated
376/// - `rotate_at`: Optional timestamp when rotation is due (None if rotation disabled)
377/// - `rotation_count`: Number of times the key has been rotated
378///
379/// # Errors
380///
381/// Returns an error if:
382/// - The `created-at` annotation is missing
383/// - Any timestamp cannot be parsed as ISO 8601
384/// - The `rotation-count` annotation cannot be parsed as u32
385///
386/// # Examples
387///
388/// ```rust,no_run
389/// use std::collections::BTreeMap;
390/// use bindy::bind9::rndc::parse_rotation_annotations;
391///
392/// let mut annotations = BTreeMap::new();
393/// annotations.insert(
394///     "bindy.firestoned.io/rndc-created-at".to_string(),
395///     "2025-01-26T10:00:00Z".to_string()
396/// );
397/// annotations.insert(
398///     "bindy.firestoned.io/rndc-rotate-at".to_string(),
399///     "2025-02-25T10:00:00Z".to_string()
400/// );
401/// annotations.insert(
402///     "bindy.firestoned.io/rndc-rotation-count".to_string(),
403///     "5".to_string()
404/// );
405///
406/// let (created_at, rotate_at, count) = parse_rotation_annotations(&annotations).unwrap();
407/// assert_eq!(count, 5);
408/// ```
409pub fn parse_rotation_annotations(
410    annotations: &BTreeMap<String, String>,
411) -> Result<(
412    chrono::DateTime<chrono::Utc>,
413    Option<chrono::DateTime<chrono::Utc>>,
414    u32,
415)> {
416    // Parse created_at (required)
417    let created_at_str = annotations
418        .get(crate::constants::ANNOTATION_RNDC_CREATED_AT)
419        .context("Missing created-at annotation")?;
420    let created_at = chrono::DateTime::parse_from_rfc3339(created_at_str)
421        .context("Failed to parse created-at timestamp")?
422        .with_timezone(&chrono::Utc);
423
424    // Parse rotate_at (optional)
425    let rotate_at =
426        if let Some(rotate_at_str) = annotations.get(crate::constants::ANNOTATION_RNDC_ROTATE_AT) {
427            Some(
428                chrono::DateTime::parse_from_rfc3339(rotate_at_str)
429                    .context("Failed to parse rotate-at timestamp")?
430                    .with_timezone(&chrono::Utc),
431            )
432        } else {
433            None
434        };
435
436    // Parse rotation_count (default to 0 if missing)
437    let rotation_count = annotations
438        .get(crate::constants::ANNOTATION_RNDC_ROTATION_COUNT)
439        .map(|s| s.parse::<u32>().context("Failed to parse rotation-count"))
440        .transpose()?
441        .unwrap_or(0);
442
443    Ok((created_at, rotate_at, rotation_count))
444}
445
446/// Check if RNDC key rotation is due based on the rotation timestamp.
447///
448/// Rotation is due if:
449/// - `rotate_at` is Some AND
450/// - `rotate_at` is less than or equal to `now`
451///
452/// # Arguments
453///
454/// * `rotate_at` - Optional timestamp when rotation should occur (None = no rotation)
455/// * `now` - Current timestamp
456///
457/// # Returns
458///
459/// - `true` if rotation is due (`rotate_at` has passed)
460/// - `false` if rotation is not due or disabled (`rotate_at` is None)
461///
462/// # Examples
463///
464/// ```rust
465/// use bindy::bind9::rndc::is_rotation_due;
466/// use chrono::Utc;
467///
468/// let past_time = Utc::now() - chrono::Duration::hours(1);
469/// let now = Utc::now();
470///
471/// assert!(is_rotation_due(Some(past_time), now)); // Rotation is due
472///
473/// let future_time = Utc::now() + chrono::Duration::hours(1);
474/// assert!(!is_rotation_due(Some(future_time), now)); // Not due yet
475///
476/// assert!(!is_rotation_due(None, now)); // Rotation disabled
477/// ```
478#[must_use]
479pub fn is_rotation_due(
480    rotate_at: Option<chrono::DateTime<chrono::Utc>>,
481    now: chrono::DateTime<chrono::Utc>,
482) -> bool {
483    match rotate_at {
484        Some(rotate_time) => rotate_time <= now,
485        None => false, // No rotation scheduled
486    }
487}
488
489#[cfg(test)]
490#[path = "rndc_tests.rs"]
491mod rndc_tests;