Skip to main content

rucora_tools/web/
http.rs

1//! HTTP 请求工具
2//!
3//! 提供 HTTP 请求功能,支持多种方法和安全限制
4
5use async_trait::async_trait;
6use rucora_core::{
7    error::ToolError,
8    tool::{Tool, ToolCategory},
9};
10use serde_json::{Value, json};
11use std::time::Duration;
12use tracing::{info, warn};
13
14use super::security::validate_public_http_url;
15
16/// 默认超时时间(秒)
17const DEFAULT_TIMEOUT_SECS: u64 = 30;
18
19/// 最大响应体大小(字节),默认 5MB
20const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
21
22/// HTTP 请求工具:发送 HTTP 请求。
23///
24/// 安全限制:
25/// - 禁止访问内网资源(防止 SSRF 攻击)
26/// - 支持域名白名单/黑名单
27/// - 限制重定向次数
28/// - 限制响应体大小
29///
30/// 适用场景:
31/// - 发送 HTTP 请求
32/// - 获取网页内容
33///
34/// 输入格式:
35/// ```json
36/// {
37///   "method": "GET",
38///   "url": "https://example.com",
39///   "headers": {
40///     "Accept": "text/html"
41///   },
42///   "body": "请求体",
43///   "timeout": 60 // 可选,超时时间(秒)
44/// }
45/// ```
46pub struct HttpRequestTool {
47    /// 允许的域名白名单(可选)
48    allowed_domains: Option<Vec<String>>,
49    /// 禁止的域名黑名单(可选)
50    blocked_domains: Option<Vec<String>>,
51    /// 最大重定向次数
52    max_redirects: u32,
53}
54
55impl HttpRequestTool {
56    /// 创建一个新的 HttpRequestTool 实例。
57    pub fn new() -> Self {
58        Self {
59            allowed_domains: None,
60            blocked_domains: None,
61            max_redirects: 3,
62        }
63    }
64
65    /// 设置允许的域名白名单
66    pub fn with_allowed_domains(mut self, domains: Vec<String>) -> Self {
67        self.allowed_domains = Some(domains);
68        self
69    }
70
71    /// 设置禁止的域名黑名单
72    pub fn with_blocked_domains(mut self, domains: Vec<String>) -> Self {
73        self.blocked_domains = Some(domains);
74        self
75    }
76
77    /// 设置最大重定向次数
78    pub fn with_max_redirects(mut self, max: u32) -> Self {
79        self.max_redirects = max;
80        self
81    }
82}
83
84impl Default for HttpRequestTool {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90#[async_trait]
91impl Tool for HttpRequestTool {
92    fn name(&self) -> &str {
93        "http_request"
94    }
95
96    fn description(&self) -> Option<&str> {
97        Some("发送 HTTP 请求(有安全限制:禁止内网访问,支持域名白名单/黑名单)")
98    }
99
100    fn categories(&self) -> &'static [ToolCategory] {
101        &[ToolCategory::Network]
102    }
103
104    fn input_schema(&self) -> Value {
105        json!({
106            "type": "object",
107            "properties": {
108                "method": {
109                    "type": "string",
110                    "description": "HTTP 方法",
111                    "enum": ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
112                },
113                "url": {
114                    "type": "string",
115                    "description": "请求 URL(必须是 http/https 协议)"
116                },
117                "headers": {
118                    "type": "object",
119                    "additionalProperties": {
120                        "type": "string"
121                    },
122                    "description": "请求头"
123                },
124                "body": {
125                    "type": "string",
126                    "description": "请求体"
127                },
128                "timeout": {
129                    "type": "integer",
130                    "description": "超时时间(秒),默认 30 秒",
131                    "default": 30
132                }
133            },
134            "required": ["url"]
135        })
136    }
137
138    async fn call(&self, input: Value) -> Result<Value, ToolError> {
139        let url = input
140            .get("url")
141            .and_then(|v| v.as_str())
142            .ok_or_else(|| ToolError::Message("缺少必需的 'url' 字段".to_string()))?;
143
144        // 验证 URL 安全性
145        validate_public_http_url(
146            url,
147            self.allowed_domains.as_deref(),
148            self.blocked_domains.as_deref(),
149        )
150        .await?;
151
152        let method_str = input
153            .get("method")
154            .and_then(|v| v.as_str())
155            .unwrap_or("GET")
156            .to_uppercase();
157
158        let timeout_secs = input
159            .get("timeout")
160            .and_then(|v| v.as_u64())
161            .unwrap_or(DEFAULT_TIMEOUT_SECS);
162
163        info!(
164            tool.name = "http_request",
165            http.method = %method_str,
166            http.url = %url,
167            http.timeout_secs = timeout_secs,
168            "http_request.start"
169        );
170
171        let start = std::time::Instant::now();
172
173        // 解析 HTTP 方法
174        let method = match method_str.as_str() {
175            "GET" => reqwest::Method::GET,
176            "POST" => reqwest::Method::POST,
177            "PUT" => reqwest::Method::PUT,
178            "DELETE" => reqwest::Method::DELETE,
179            "PATCH" => reqwest::Method::PATCH,
180            "HEAD" => reqwest::Method::HEAD,
181            "OPTIONS" => reqwest::Method::OPTIONS,
182            _ => {
183                return Err(ToolError::Message(format!(
184                    "不支持的 HTTP 方法:{method_str}"
185                )));
186            }
187        };
188
189        // 构建 HTTP 客户端
190        let _configured_redirect_limit = self.max_redirects;
191        // 自动重定向会让跳转目标绕过调用前 URL 校验,因此这里保守禁用。
192        let redirect_policy = reqwest::redirect::Policy::none();
193        let client = reqwest::Client::builder()
194            .timeout(Duration::from_secs(timeout_secs))
195            .redirect(redirect_policy)
196            .user_agent("Mozilla/5.0 (compatible; rucora/0.1)")
197            .build()
198            .map_err(|e| ToolError::Message(format!("HTTP 客户端创建失败:{e}")))?;
199
200        // 构建请求
201        let mut request = client.request(method, url);
202
203        // 添加请求头
204        if let Some(headers_map) = input.get("headers").and_then(|v| v.as_object()) {
205            for (key, value) in headers_map {
206                if let Some(val_str) = value.as_str() {
207                    request = request.header(key, val_str);
208                }
209            }
210        }
211
212        // 添加请求体
213        if let Some(body_str) = input.get("body").and_then(|v| v.as_str()) {
214            request = request.body(body_str.to_string());
215        }
216
217        // 发送请求
218        let response = request.send().await.map_err(|e| {
219            warn!(
220                tool.name = "http_request",
221                http.method = %method_str,
222                http.url = %url,
223                error = %e,
224                "http_request.error"
225            );
226            ToolError::Message(format!("HTTP 请求失败:{e}"))
227        })?;
228
229        let status = response.status().as_u16();
230
231        // 获取响应体,限制大小
232        let body_bytes = response
233            .bytes()
234            .await
235            .map_err(|e| ToolError::Message(format!("读取响应体失败:{e}")))?;
236
237        if body_bytes.len() > MAX_RESPONSE_SIZE {
238            return Err(ToolError::Message(format!(
239                "响应体过大({} 字节),超过限制({} 字节)",
240                body_bytes.len(),
241                MAX_RESPONSE_SIZE
242            )));
243        }
244
245        let body = String::from_utf8_lossy(&body_bytes).to_string();
246
247        let elapsed_ms = start.elapsed().as_millis() as u64;
248        let body_len = body.len();
249
250        info!(
251            tool.name = "http_request",
252            http.method = %method_str,
253            http.url = %url,
254            http.status = status,
255            http.success = (200..300).contains(&status),
256            http.body_len = body_len,
257            http.elapsed_ms = elapsed_ms,
258            "http_request.done"
259        );
260
261        Ok(json!({
262            "url": url,
263            "status": status,
264            "body": body,
265            "body_len": body_len,
266            "success": (200..300).contains(&status),
267            "elapsed_ms": elapsed_ms
268        }))
269    }
270}