use std::fs;
use std::path::{Path, PathBuf};
use super::SweepContext;
use crate::doctor::check::Check;
const NAME: &str = "caddy_resolvers";
const CADDY_DIR: &str = "/etc/caddy";
pub async fn run(_ctx: SweepContext) -> Check {
if !cfg!(target_os = "linux") {
return Check::skip(
NAME,
"not supported on this platform",
"systemd-resolved stall only affects Linux caddy hosts",
);
}
run_against(Path::new(CADDY_DIR))
}
fn run_against(dir: &Path) -> Check {
if !dir.is_dir() {
return Check::skip(
NAME,
"no caddy config",
format!("{} does not exist", dir.display()),
);
}
let mut files = Vec::new();
let mut unreadable = Vec::new();
collect_files(dir, &mut files, &mut unreadable);
let mut total_blocks = 0;
let mut misses = Vec::new();
for file in &files {
match fs::read_to_string(file) {
Ok(content) => {
let (blocks, missing_lines) = blocks_missing_resolvers(&content);
total_blocks += blocks;
misses.extend(
missing_lines
.into_iter()
.map(|line| format!("{}:{line}", file.display())),
);
}
Err(_) => unreadable.push(file.clone()),
}
}
if !misses.is_empty() {
return Check::fail(
NAME,
format!(
"{} of {total_blocks} dynamic upstream(s) missing resolvers",
misses.len()
),
format!(
"dynamic upstream block(s) without a resolvers directive resolve via \
systemd-resolved, which stalls requests under load; redeploy the ops \
caddy playbook: {}",
misses.join(", ")
),
)
.with_detail("missing", misses);
}
if !unreadable.is_empty() {
return Check::broken(
NAME,
"caddy config partially unreadable",
format!(
"could not read: {}",
unreadable
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
),
);
}
if total_blocks == 0 {
return Check::pass(NAME, "no dynamic upstreams");
}
Check::pass(
NAME,
format!("all {total_blocks} dynamic upstream(s) have resolvers"),
)
}
fn collect_files(dir: &Path, files: &mut Vec<PathBuf>, unreadable: &mut Vec<PathBuf>) {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => {
unreadable.push(dir.to_path_buf());
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files(&path, files, unreadable);
} else if path.is_file() {
files.push(path);
}
}
}
fn blocks_missing_resolvers(content: &str) -> (usize, Vec<usize>) {
let mut total = 0;
let mut misses = Vec::new();
let mut open_block: Option<(usize, i32, bool)> = None;
let mut depth = 0i32;
for (idx, raw_line) in content.lines().enumerate() {
let line = raw_line.split('#').next().unwrap_or("");
let mut tokens = line.split_whitespace();
let first = tokens.next();
if let Some((start, close_depth, has_resolvers)) = open_block.as_mut() {
if first == Some("resolvers") {
*has_resolvers = true;
}
let (start, close_depth, has_resolvers) = (*start, *close_depth, *has_resolvers);
depth += brace_delta(line);
if depth <= close_depth {
if !has_resolvers {
misses.push(start);
}
open_block = None;
}
continue;
}
if first == Some("dynamic") {
total += 1;
if line.contains('{') {
open_block = Some((idx + 1, depth, false));
} else {
misses.push(idx + 1);
}
}
depth += brace_delta(line);
}
if let Some((start, _, false)) = open_block {
misses.push(start);
}
(total, misses)
}
fn brace_delta(line: &str) -> i32 {
line.chars().fold(0, |acc, c| match c {
'{' => acc + 1,
'}' => acc - 1,
_ => acc,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn block_with_resolvers_passes() {
let content = "reverse_proxy {\n\tdynamic a {\n\t\tname api.tamanu.internal\n\t\tresolvers 10.100.0.1\n\t}\n}\n";
assert_eq!(blocks_missing_resolvers(content), (1, vec![]));
}
#[test]
fn block_without_resolvers_flagged() {
let content = "reverse_proxy {\n\tdynamic a {\n\t\tname api.msupply.internal\n\t\tport 8000\n\t}\n}\n";
assert_eq!(blocks_missing_resolvers(content), (1, vec![2]));
}
#[test]
fn mixed_blocks_flag_only_missing() {
let content =
"dynamic a {\n\tname one\n\tresolvers 10.100.0.1\n}\ndynamic a {\n\tname two\n}\n";
assert_eq!(blocks_missing_resolvers(content), (2, vec![5]));
}
#[test]
fn commented_resolvers_does_not_count() {
let content = "dynamic a {\n\tname one\n\t# resolvers 10.100.0.1\n}\n";
assert_eq!(blocks_missing_resolvers(content), (1, vec![1]));
}
#[test]
fn blockless_dynamic_flagged() {
let content = "reverse_proxy {\n\tdynamic a\n}\n";
assert_eq!(blocks_missing_resolvers(content), (1, vec![2]));
}
#[test]
fn no_dynamic_upstreams() {
let content = "example.com {\n\treverse_proxy localhost:3000\n}\n";
assert_eq!(blocks_missing_resolvers(content), (0, vec![]));
}
#[test]
fn unterminated_block_at_eof_flagged() {
let content = "dynamic a {\n\tname one\n";
assert_eq!(blocks_missing_resolvers(content), (1, vec![1]));
}
#[test]
fn nested_braces_stay_in_block() {
let content = "dynamic a {\n\tsub {\n\t\tinner value\n\t}\n\tresolvers 10.100.0.1\n}\n";
assert_eq!(blocks_missing_resolvers(content), (1, vec![]));
}
}