1use crate::registry::{Tool, ToolContext};
10use aegis_security::is_safe_url;
11use anyhow::Result;
12use async_trait::async_trait;
13use serde_json::{json, Value};
14
15pub struct HttpProbeTool;
17
18impl HttpProbeTool {
19 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
114pub struct DnsLookupTool;
116
117impl DnsLookupTool {
118 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 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 let host_for_task = host.clone();
162 let ips = tokio::task::spawn_blocking(move || {
163 use std::net::ToSocketAddrs;
164 (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}