use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use serde_yaml::Value;
use std::sync::Arc;
use tracing::warn;
use crate::config::Config;
use crate::config::PluginConfig;
use crate::web::state::WebState;
#[derive(Debug, Serialize)]
struct ErrorResponse {
success: bool,
error: String,
}
#[derive(Debug, Serialize)]
pub struct ConfigDumpResponse {
pub version: String,
pub log: LogSummary,
pub admin: Option<AddrSummary>,
pub monitoring: Option<AddrSummary>,
pub web: Option<WebSummary>,
pub plugin_count: usize,
pub plugins: Vec<PluginSummary>,
}
#[derive(Debug, Serialize)]
pub struct LogSummary {
pub level: String,
pub console: bool,
pub format: String,
pub file_enabled: bool,
}
#[derive(Debug, Serialize)]
pub struct AddrSummary {
pub enabled: bool,
pub addr: String,
}
#[derive(Debug, Serialize)]
pub struct WebSummary {
pub enabled: bool,
pub listen: String,
}
#[derive(Debug, Serialize)]
pub struct PluginSummary {
pub tag: String,
pub plugin_type: String,
pub args_summary: serde_json::Value,
pub is_sequence: bool,
pub sequence_steps: Option<Vec<SequenceStepSummary>>,
}
#[derive(Debug, Serialize)]
pub struct SequenceStepSummary {
pub matches: Option<String>,
pub exec: Option<String>,
}
pub async fn dump(State(state): State<Arc<WebState>>) -> Response {
let Some(config_arc) = state.config_arc() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
success: false,
error: "Configuration not available (requires admin build)".to_string(),
}),
)
.into_response();
};
let config = config_arc.read().await;
let response = build_response(&config);
Json(response).into_response()
}
fn build_response(config: &Config) -> ConfigDumpResponse {
let plugins: Vec<PluginSummary> = config.plugins.iter().map(summarize_plugin).collect();
ConfigDumpResponse {
version: env!("CARGO_PKG_VERSION").to_string(),
log: LogSummary {
level: config.log.level.clone(),
console: config.log.console,
format: config.log.format.clone(),
file_enabled: config.log.is_file_logging_enabled(),
},
admin: Some(AddrSummary {
enabled: config.admin.enabled,
addr: config.admin.addr.clone(),
}),
monitoring: Some(AddrSummary {
enabled: config.monitoring.enabled,
addr: config.monitoring.addr.clone(),
}),
#[cfg(feature = "web")]
web: Some(WebSummary {
enabled: config.web.enabled,
listen: config.web.listen.clone(),
}),
#[cfg(not(feature = "web"))]
web: None,
plugin_count: plugins.len(),
plugins,
}
}
fn summarize_plugin(plugin: &PluginConfig) -> PluginSummary {
let plugin_type = plugin.plugin_type.clone();
let is_sequence = plugin_type == "sequence";
let (args_summary, sequence_steps) = if is_sequence {
(
serde_json::Value::Null,
Some(parse_sequence_steps(&plugin.args)),
)
} else {
(yaml_value_to_json(&plugin.args), None)
};
PluginSummary {
tag: plugin.effective_name().to_string(),
plugin_type,
args_summary,
is_sequence,
sequence_steps,
}
}
fn parse_sequence_steps(args: &Value) -> Vec<SequenceStepSummary> {
let Value::Sequence(steps) = args else {
if !matches!(args, Value::Null | Value::Sequence(_)) {
warn!(
args_kind = ?std::mem::discriminant(args),
"sequence plugin args is not a sequence; returning empty steps"
);
}
return Vec::new();
};
steps
.iter()
.filter_map(|step| {
let Value::Mapping(map) = step else {
return None;
};
let matches = map
.get(Value::String("matches".to_string()))
.and_then(value_to_string);
let exec = map
.get(Value::String("exec".to_string()))
.and_then(value_to_string);
if matches.is_none() && exec.is_none() {
return None;
}
Some(SequenceStepSummary { matches, exec })
})
.collect()
}
fn yaml_value_to_json(value: &Value) -> serde_json::Value {
match value {
Value::Null => serde_json::json!({}),
other => serde_json::to_value(other).unwrap_or(serde_json::json!({})),
}
}
fn value_to_string(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn plugin(type_: &str, args_yaml: &str) -> PluginConfig {
PluginConfig::new(type_.to_string())
.with_tag(format!("{}_tag", type_))
.with_arg(
"key".to_string(),
serde_yaml::from_str(args_yaml).unwrap_or(Value::Null),
)
}
#[test]
fn test_parse_sequence_steps_extracts_matches_and_exec() {
let yaml = r#"
- exec: $forward
- matches: has_resp
exec: accept
- matches: qname $reject_list
exec: black_hole 127.0.0.1
"#;
let args: Value = serde_yaml::from_str(yaml).unwrap();
let steps = parse_sequence_steps(&args);
assert_eq!(steps.len(), 3);
assert_eq!(steps[0].exec.as_deref(), Some("$forward"));
assert!(steps[0].matches.is_none());
assert_eq!(steps[1].matches.as_deref(), Some("has_resp"));
assert_eq!(steps[1].exec.as_deref(), Some("accept"));
assert_eq!(steps[2].matches.as_deref(), Some("qname $reject_list"));
assert_eq!(steps[2].exec.as_deref(), Some("black_hole 127.0.0.1"));
}
#[test]
fn parse_sequence_steps_empty_for_non_sequence() {
let args: Value = serde_yaml::from_str("size: 1024").unwrap();
let steps = parse_sequence_steps(&args);
assert!(steps.is_empty());
assert!(parse_sequence_steps(&Value::Null).is_empty());
}
#[test]
fn test_parse_sequence_steps_skips_empty_steps() {
let yaml = r#"
- {}
- exec: accept
"#;
let args: Value = serde_yaml::from_str(yaml).unwrap();
let steps = parse_sequence_steps(&args);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].exec.as_deref(), Some("accept"));
}
#[test]
fn summarize_non_sequence_returns_args_json() {
let cfg = plugin("cache", "1024");
let summary = summarize_plugin(&cfg);
assert!(!summary.is_sequence);
assert!(summary.sequence_steps.is_none());
assert!(summary.args_summary.is_object());
}
#[test]
fn test_summarize_sequence_plugin_marks_is_sequence() {
let mut cfg = PluginConfig::new("sequence".to_string()).with_tag("main".to_string());
cfg = cfg.with_arg(
"steps".to_string(),
serde_yaml::from_str("- exec: accept").unwrap(),
);
cfg.args = serde_yaml::from_str(
r#"
- exec: $forward
- exec: accept
"#,
)
.unwrap();
let summary = summarize_plugin(&cfg);
assert!(summary.is_sequence);
let steps = summary.sequence_steps.expect("sequence steps");
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].exec.as_deref(), Some("$forward"));
}
#[test]
fn test_build_response_includes_log_and_plugins() {
let config = Config::default();
let resp = build_response(&config);
assert_eq!(resp.log.level, "info");
assert!(!resp.log.console);
assert_eq!(resp.plugin_count, 0);
assert!(resp.plugins.is_empty());
assert!(resp.admin.is_some());
assert!(resp.monitoring.is_some());
}
}