use anyhow::{Context, Result};
use rndc::RndcClient;
use std::time::Instant;
use tracing::{debug, error, info};
use crate::metrics;
#[derive(Clone)]
pub struct RndcConfig {
pub server: String,
pub algorithm: String,
pub secret: String,
}
impl std::fmt::Debug for RndcConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RndcConfig")
.field("server", &self.server)
.field("algorithm", &self.algorithm)
.field("secret", &"[REDACTED]")
.finish()
}
}
const ACCEPTED_RNDC_ALGORITHMS: &[&str] = &["sha224", "sha256", "sha384", "sha512"];
const MAX_ZONE_NAME_LEN: usize = 253;
const DEFAULT_RNDC_PORT: u16 = 953;
pub(crate) fn validate_rndc_zone_name(zone_name: &str) -> Result<()> {
if zone_name.is_empty() || zone_name.len() > MAX_ZONE_NAME_LEN {
return Err(anyhow::anyhow!("invalid zone name length"));
}
if zone_name.contains("..") {
return Err(anyhow::anyhow!("zone name must not contain '..'"));
}
if let Some(bad) = zone_name
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')))
{
return Err(anyhow::anyhow!(
"zone name contains invalid character: {:?}",
bad
));
}
Ok(())
}
pub struct RndcExecutor {
client: RndcClient,
}
impl RndcExecutor {
pub fn new(server: String, algorithm: String, secret: String) -> Result<Self> {
let server = server.trim();
let mut algorithm = algorithm.trim().to_string();
let secret = secret.trim();
if algorithm.starts_with("hmac-") {
algorithm = algorithm.trim_start_matches("hmac-").to_string();
}
debug!("Using algorithm: {} for server: {}", algorithm, server);
if !ACCEPTED_RNDC_ALGORITHMS.contains(&algorithm.as_str()) {
return Err(anyhow::anyhow!(
"Invalid or deprecated algorithm '{}'. Accepted algorithms (without 'hmac-' prefix): {:?}",
algorithm,
ACCEPTED_RNDC_ALGORITHMS
));
}
let client = RndcClient::new(server, &algorithm, secret)?;
Ok(Self { client })
}
async fn execute(&self, command: &str) -> Result<String> {
debug!("Executing RNDC command: {}", command);
let start = Instant::now();
let command_name = command.split_whitespace().next().unwrap_or("unknown");
let result = tokio::task::spawn_blocking({
let client = self.client.clone();
let command = command.to_string();
move || client.rndc_command(&command)
})
.await
.with_context(|| {
format!(
"Failed to execute RNDC command '{}': task join error",
command_name
)
})?;
let duration = start.elapsed().as_secs_f64();
let rndc_result = result.map_err(|e| {
let error_msg = format!("RNDC command '{}' failed: {}", command_name, e);
error!("{}", error_msg);
metrics::record_rndc_command(command_name, false, duration);
anyhow::anyhow!("{}", error_msg)
})?;
if let Some(err) = &rndc_result.err {
let error_msg = format!("RNDC command '{}' failed: {}", command_name, err);
error!("{}", error_msg);
metrics::record_rndc_command(command_name, false, duration);
return Err(anyhow::anyhow!("{}", error_msg));
}
let response = rndc_result.text.unwrap_or_default();
debug!("RNDC command '{}' succeeded: {}", command_name, response);
metrics::record_rndc_command(command_name, true, duration);
Ok(response)
}
pub async fn status(&self) -> Result<String> {
self.execute("status").await
}
pub async fn addzone(&self, zone_name: &str, zone_config: &str) -> Result<String> {
validate_rndc_zone_name(zone_name)?;
let command = format!("addzone {} {}", zone_name, zone_config);
self.execute(&command).await
}
pub async fn delzone(&self, zone_name: &str) -> Result<String> {
validate_rndc_zone_name(zone_name)?;
let command = format!("delzone {}", zone_name);
self.execute(&command).await
}
pub async fn reload(&self, zone_name: &str) -> Result<String> {
validate_rndc_zone_name(zone_name)?;
let command = format!("reload {}", zone_name);
self.execute(&command).await
}
pub async fn zonestatus(&self, zone_name: &str) -> Result<String> {
validate_rndc_zone_name(zone_name)?;
let command = format!("zonestatus {}", zone_name);
self.execute(&command).await
}
pub async fn freeze(&self, zone_name: &str) -> Result<String> {
validate_rndc_zone_name(zone_name)?;
let command = format!("freeze {}", zone_name);
self.execute(&command).await
}
pub async fn thaw(&self, zone_name: &str) -> Result<String> {
validate_rndc_zone_name(zone_name)?;
let command = format!("thaw {}", zone_name);
self.execute(&command).await
}
pub async fn notify(&self, zone_name: &str) -> Result<String> {
validate_rndc_zone_name(zone_name)?;
let command = format!("notify {}", zone_name);
self.execute(&command).await
}
pub async fn retransfer(&self, zone_name: &str) -> Result<String> {
validate_rndc_zone_name(zone_name)?;
let command = format!("retransfer {}", zone_name);
self.execute(&command).await
}
pub async fn modzone(&self, zone_name: &str, zone_config: &str) -> Result<String> {
validate_rndc_zone_name(zone_name)?;
let command = format!("modzone {} {}", zone_name, zone_config);
self.execute(&command).await
}
pub async fn showzone(&self, zone_name: &str) -> Result<String> {
validate_rndc_zone_name(zone_name)?;
let command = format!("showzone {}", zone_name);
self.execute(&command).await
}
}
impl Clone for RndcExecutor {
fn clone(&self) -> Self {
Self {
client: self.client.clone(),
}
}
}
pub fn parse_rndc_conf(path: &str) -> Result<RndcConfig> {
use crate::rndc_conf_parser::parse_rndc_conf_file;
use std::path::Path;
info!("Parsing rndc.conf from {}", path);
let conf_file = parse_rndc_conf_file(Path::new(path))
.map_err(|e| anyhow::anyhow!("Failed to parse rndc.conf: {}", e))?;
let default_key_name = match conf_file.options.default_key.clone() {
Some(name) => Some(name),
None => {
if conf_file.keys.len() > 1 {
return Err(anyhow::anyhow!(
"rndc.conf defines {} keys but no `default-key`; specify one explicitly",
conf_file.keys.len()
));
}
let mut names: Vec<&String> = conf_file.keys.keys().collect();
names.sort();
names.first().map(|name| (*name).clone())
}
};
let key_block = if let Some(ref key_name) = default_key_name {
conf_file
.keys
.get(key_name)
.ok_or_else(|| anyhow::anyhow!("Default key '{}' not found", key_name))?
} else {
return Err(anyhow::anyhow!("No keys found in configuration"));
};
if key_block.secret.trim().is_empty() {
return Err(anyhow::anyhow!(
"key '{}' has an empty secret",
default_key_name.as_deref().unwrap_or("unnamed")
));
}
let server = conf_file
.options
.default_server
.clone()
.unwrap_or_else(|| "127.0.0.1".to_string());
let server = if server.contains(':') {
server
} else {
let port = conf_file.options.default_port.unwrap_or(DEFAULT_RNDC_PORT);
format!("{}:{}", server, port)
};
info!(
"Parsed rndc configuration: server={}, algorithm={}, key={}",
server,
key_block.algorithm,
default_key_name.unwrap_or_else(|| "unnamed".to_string())
);
Ok(RndcConfig {
server,
algorithm: key_block.algorithm.clone(),
secret: key_block.secret.clone(),
})
}