shohei 0.5.1

Infrastructure diagnostics library: DNS, DNSSEC, TLS certificate inspection, email security, DNS propagation, and MCP-integrated AI agent support
Documentation
//! High-level library API for DNS diagnostics.

use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use std::str::FromStr;
use crate::error::{Result, ShoheError};
use crate::resolver::QueryOptions;
use hickory_proto::rr::RecordType;

pub use crate::resolver::{DnsQueryResult, DnsQuery, DnsRecord, RecordData, TrustState};
pub use crate::resolver::iterative::{ResolutionTrace, ResolutionStep, StepResponseType};
pub use crate::dnssec::chain::{DnssecChain, DnssecStep, DnssecStepType};

pub mod propagation;
pub mod email;
pub mod bench;
pub mod tls;

pub use propagation::{check_propagation, check_propagation_global, PropagationRequest, PropagationResolver, PropagationResult, PropagationStatus, ResolverCheckResult};
pub use email::{check_email_security, EmailSecurityRequest, EmailSecurityResult};
pub use bench::{benchmark_latency, LatencyBenchRequest, BenchTransport, LatencyBenchResult};
pub use tls::{check_tls_chain, TlsCheckRequest, TlsCheckResult, CertInfo, DaneTlsaResult};

// ── Transport enum ─────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", content = "config")]
pub enum Transport {
    #[serde(rename = "system")]
    System,
    #[serde(rename = "server")]
    Server(String),
    #[serde(rename = "doh")]
    Doh(String),
    #[serde(rename = "dot")]
    Dot(String),
    #[serde(rename = "doq")]
    Doq(String),
}

impl Default for Transport {
    fn default() -> Self { Transport::System }
}

// ── DnsCheckRequest ────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnsCheckRequest {
    pub domain: String,
    #[serde(default = "default_record_types")]
    pub record_types: Vec<String>,
    #[serde(default)]
    pub transport: Transport,
    #[serde(default)]
    pub validate_dnssec: bool,
    #[serde(default = "default_timeout")]
    pub timeout_secs: u64,
    #[serde(default)]
    pub ipv4_only: bool,
    #[serde(default)]
    pub ipv6_only: bool,
    #[serde(default)]
    pub no_recurse: bool,
    #[serde(default)]
    pub force_tcp: bool,
}

fn default_record_types() -> Vec<String> { vec!["A".to_string()] }
fn default_timeout() -> u64 { 5 }

impl Default for DnsCheckRequest {
    fn default() -> Self {
        Self {
            domain: String::new(),
            record_types: default_record_types(),
            transport: Transport::default(),
            validate_dnssec: false,
            timeout_secs: default_timeout(),
            ipv4_only: false,
            ipv6_only: false,
            no_recurse: false,
            force_tcp: false,
        }
    }
}

// ── DnssecCheckRequest ─────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnssecCheckRequest {
    pub domain: String,
    #[serde(default = "default_record_type")]
    pub record_type: String,
    #[serde(default)]
    pub resolver_ip: Option<String>,
    #[serde(default)]
    pub verbose: bool,
}

fn default_record_type() -> String { "A".to_string() }

impl Default for DnssecCheckRequest {
    fn default() -> Self {
        Self {
            domain: String::new(),
            record_type: default_record_type(),
            resolver_ip: None,
            verbose: false,
        }
    }
}

// ── Entry points ───────────────────────────────────────────────────────────
pub async fn check_dns(req: &DnsCheckRequest) -> Result<Vec<DnsQueryResult>> {
    if req.domain.is_empty() {
        return Err(ShoheError::Parse("domain cannot be empty".to_string()));
    }

    let record_types = if req.record_types.is_empty() {
        vec!["A".to_string()]
    } else {
        req.record_types.clone()
    };

    let transport = build_transport_config(&req.transport).await?;
    let mut results = Vec::new();
    let mut handles = vec![];

    for rtype_str in record_types {
        let rtype = RecordType::from_str(&rtype_str)
            .map_err(|_| ShoheError::Parse(format!("invalid record type: {}", rtype_str)))?;

        let opts = QueryOptions {
            domain: req.domain.clone(),
            record_type: rtype,
            server: parse_server_addr(&req.transport),
            transport: transport.clone(),
            validate_dnssec: req.validate_dnssec,
            force_tcp: req.force_tcp,
            no_recurse: req.no_recurse,
            timeout_secs: req.timeout_secs,
            ipv4_only: req.ipv4_only,
            ipv6_only: req.ipv6_only,
        };

        let handle = tokio::spawn(async move { crate::resolver::standard::query(&opts).await });
        handles.push(handle);
    }

    for handle in handles {
        let result = handle.await.map_err(|e| ShoheError::Transport(e.to_string()))??;
        results.push(result);
    }

    Ok(results)
}

pub async fn check_dnssec(req: &DnssecCheckRequest) -> Result<DnssecChain> {
    if req.domain.is_empty() {
        return Err(ShoheError::Parse("domain cannot be empty".to_string()));
    }

    let rtype = RecordType::from_str(&req.record_type)
        .map_err(|_| ShoheError::Parse(format!("invalid record type: {}", req.record_type)))?;

    let resolver_ip = if let Some(ref ip_str) = req.resolver_ip {
        Some(IpAddr::from_str(ip_str)
            .map_err(|_| ShoheError::Parse(format!("invalid IP address: {}", ip_str)))?)
    } else {
        None
    };

    crate::dnssec::chain::build_chain(&req.domain, rtype, resolver_ip, req.verbose).await
}

pub async fn trace_resolution(domain: &str, record_type: &str) -> Result<ResolutionTrace> {
    if domain.is_empty() {
        return Err(ShoheError::Parse("domain cannot be empty".to_string()));
    }

    let rtype = RecordType::from_str(record_type)
        .map_err(|_| ShoheError::Parse(format!("invalid record type: {}", record_type)))?;

    crate::resolver::iterative::trace(domain, rtype, None).await
}

// ── Helpers ────────────────────────────────────────────────────────────────
async fn build_transport_config(transport: &Transport) -> Result<Option<(hickory_resolver::config::ResolverConfig, String)>> {
    match transport {
        Transport::System => Ok(None),
        Transport::Server(_) => Ok(None),
        Transport::Doh(url) => {
            let (config, label) = crate::transport::doh::build_doh_config(url).await
                .map_err(|e| ShoheError::Transport(format!("DoH config failed: {}", e)))?;
            Ok(Some((config, label)))
        }
        Transport::Dot(addr) => {
            let (config, label) = crate::transport::dot::build_dot_config(addr).await
                .map_err(|e| ShoheError::Transport(format!("DoT config failed: {}", e)))?;
            Ok(Some((config, label)))
        }
        Transport::Doq(addr) => {
            let (config, label) = crate::transport::doq::build_doq_config(addr).await
                .map_err(|e| ShoheError::Transport(format!("DoQ config failed: {}", e)))?;
            Ok(Some((config, label)))
        }
    }
}

fn parse_server_addr(transport: &Transport) -> Option<std::net::SocketAddr> {
    match transport {
        Transport::Server(addr_str) => {
            std::net::SocketAddr::from_str(addr_str)
                .or_else(|_| std::net::IpAddr::from_str(addr_str)
                    .map(|ip| std::net::SocketAddr::new(ip, 53)))
                .ok()
        }
        _ => None,
    }
}