Skip to main content

aegis_tools/
netdiag.rs

1//! # Network diagnostics tools
2//!
3//! - **http_probe**: HTTP health/uptime probe (status, latency, redirects).
4//! - **dns_lookup**: resolve a hostname to IP addresses.
5//!
6//! `http_probe` reuses `reqwest`; `dns_lookup` uses only `std::net` (zero new
7//! deps). Both are read-only and light (Core tier, default-on).
8
9use crate::registry::{Tool, ToolContext};
10use aegis_security::is_safe_url;
11use anyhow::Result;
12use async_trait::async_trait;
13use serde_json::{json, Value};
14
15/// Probes an HTTP endpoint and reports status + latency.
16pub struct HttpProbeTool;
17
18impl HttpProbeTool {
19    /// Create a new `HttpProbeTool`.
20    pub fn new() -> Self {
21        HttpProbeTool
22    }
23}
24
25impl Default for HttpProbeTool {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31#[async_trait]
32impl Tool for HttpProbeTool {
33    fn name(&self) -> &str {
34        "http_probe"
35    }
36
37    fn description(&self) -> &str {
38        "Probe an HTTP(S) endpoint for health/uptime: reports status code, response latency, final URL after redirects, and body size. Read-only."
39    }
40
41    fn parameters(&self) -> Value {
42        json!({
43            "type": "object",
44            "properties": {
45                "url": { "type": "string", "description": "URL to probe (http/https)" },
46                "method": { "type": "string", "enum": ["GET", "HEAD"], "description": "Probe method (default HEAD)" },
47                "timeout_secs": { "type": "integer", "description": "Timeout in seconds (default 15)" }
48            },
49            "required": ["url"]
50        })
51    }
52
53    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
54        let url = args["url"].as_str().unwrap_or("").trim();
55        if url.is_empty() {
56            return Ok("Error: url is required".to_string());
57        }
58        is_safe_url(url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
59
60        let method = args["method"].as_str().unwrap_or("HEAD").to_uppercase();
61        let timeout = args["timeout_secs"].as_u64().unwrap_or(15);
62
63        let client = reqwest::Client::builder()
64            .timeout(std::time::Duration::from_secs(timeout))
65            .user_agent("aegis-agent/2.0 (health-probe)")
66            .build()?;
67
68        let m = if method == "GET" {
69            reqwest::Method::GET
70        } else {
71            reqwest::Method::HEAD
72        };
73
74        let started = std::time::Instant::now();
75        let resp = match client.request(m, url).send().await {
76            Ok(r) => r,
77            Err(e) => {
78                let elapsed = started.elapsed().as_millis();
79                return Ok(format!("DOWN — request failed after {elapsed}ms: {e}"));
80            }
81        };
82        let elapsed = started.elapsed().as_millis();
83        let status = resp.status();
84        let final_url = resp.url().to_string();
85        let len = resp
86            .headers()
87            .get(reqwest::header::CONTENT_LENGTH)
88            .and_then(|v| v.to_str().ok())
89            .map(|s| format!("{s} bytes"))
90            .unwrap_or_else(|| "unknown".to_string());
91
92        let health = if status.is_success() {
93            "UP"
94        } else if status.is_redirection() {
95            "REDIRECT"
96        } else {
97            "DEGRADED"
98        };
99
100        let mut out = format!(
101            "{health} — HTTP {} {} in {}ms\ncontent-length: {}",
102            status.as_u16(),
103            status.canonical_reason().unwrap_or(""),
104            elapsed,
105            len
106        );
107        if final_url != url {
108            out.push_str(&format!("\nfinal-url: {final_url}"));
109        }
110        Ok(out)
111    }
112}
113
114/// Resolves a hostname to IP addresses via the system resolver.
115pub struct DnsLookupTool;
116
117impl DnsLookupTool {
118    /// Create a new `DnsLookupTool`.
119    pub fn new() -> Self {
120        DnsLookupTool
121    }
122}
123
124impl Default for DnsLookupTool {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130#[async_trait]
131impl Tool for DnsLookupTool {
132    fn name(&self) -> &str {
133        "dns_lookup"
134    }
135
136    fn description(&self) -> &str {
137        "Resolve a hostname to its IP addresses (A/AAAA) using the system resolver. Read-only."
138    }
139
140    fn parameters(&self) -> Value {
141        json!({
142            "type": "object",
143            "properties": {
144                "host": { "type": "string", "description": "Hostname to resolve, e.g. 'example.com'" }
145            },
146            "required": ["host"]
147        })
148    }
149
150    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
151        let host = args["host"].as_str().unwrap_or("").trim().to_string();
152        if host.is_empty() {
153            return Ok("Error: host is required".to_string());
154        }
155        // Reject obviously invalid input (no scheme, no path, no spaces).
156        if host.contains("://") || host.contains('/') || host.contains(char::is_whitespace) {
157            return Ok("Error: provide a bare hostname (no scheme or path), e.g. 'example.com'".to_string());
158        }
159
160        // std resolution is blocking; run it off the async runtime.
161        let host_for_task = host.clone();
162        let ips = tokio::task::spawn_blocking(move || {
163            use std::net::ToSocketAddrs;
164            // Port 0 is a placeholder; we only want the IPs.
165            (host_for_task.as_str(), 0u16)
166                .to_socket_addrs()
167                .map(|iter| {
168                    let mut v: Vec<String> = iter.map(|sa| sa.ip().to_string()).collect();
169                    v.sort();
170                    v.dedup();
171                    v
172                })
173        })
174        .await
175        .map_err(|e| anyhow::anyhow!("dns_lookup task failed: {e}"))?;
176
177        match ips {
178            Ok(list) if !list.is_empty() => {
179                Ok(format!("{host} resolves to:\n{}", list.join("\n")))
180            }
181            Ok(_) => Ok(format!("{host}: no addresses found")),
182            Err(e) => Ok(format!("{host}: resolution failed: {e}")),
183        }
184    }
185}