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;