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;