From c1f2fe16c88d07caccf62ca5e5b7f401d015ca7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 8 Aug 2026 14:19:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E4=BB=A3=E7=90=86=E7=BD=91=E5=85=B3(AI=20?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=BB=9F=E4=B8=80=E8=B5=B0=20127.0.0.1:8899?= =?UTF-8?q?=20=E8=BD=AC=E5=8F=91=20mysql/ssh/mongo/redis)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + src-tauri/Cargo.toml | 4 + src-tauri/src/commands/ai/tool_registry.rs | 59 +- .../src/commands/ai/tools/local_proxy.rs | 508 ++++++++++++++++++ src-tauri/src/commands/ai/tools/mod.rs | 1 + src-tauri/src/lib.rs | 5 + src-tauri/src/local_proxy_gateway.rs | 246 +++++++++ src-tauri/src/state.rs | 9 +- 8 files changed, 823 insertions(+), 10 deletions(-) create mode 100644 src-tauri/src/commands/ai/tools/local_proxy.rs create mode 100644 src-tauri/src/local_proxy_gateway.rs diff --git a/Cargo.lock b/Cargo.lock index 5bb39c6..f40f142 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -847,6 +847,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "axum", "base64 0.22.1", "chrono", "dashmap", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6a09f8f..d6fadc7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,6 +31,10 @@ tracing-appender = "0.2" chrono.workspace = true # augmentation::MentionResolver async trait(Input Augmentation 层核心设计2) async-trait = { workspace = true } +# 本地工具代理统一网关(local_proxy_gateway):axum 监听 127.0.0.1:8899 路由到 +# mysql/ssh/mongo/redis 4 代理端口。axum 0.7(df-mcp server_http 同款,workspace 已锁 0.7.9, +# 零新依赖树)。rust-work 改代理端口只改网关映射表,工具代码不感知端口。 +axum = { version = "0.7" } # 后端 crate df-types = { path = "../crates/df-types" } diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 922f645..3b26825 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -500,11 +500,17 @@ pub(crate) async fn bind_dir_to_project( /// (agent_max_iterations 等)+ db。因本函数在 AppState::init 内、AppState 构建中被调, /// 无法传 &AppState(自引用),故把所需 Arc 句柄打包成 GetAppConfigCtx 传入(state.rs /// init 先建这些 Arc 再 build registry 再组装 state,顺序调整)。 +/// +/// local_proxy(2026-08-06):新增 `local_proxy_ctx: super::tools::local_proxy::LocalProxyCtx` 形参, +/// 该工具集操作本地 4 个工具代理(mysql/ssh/mongo/redis),经 DevFlow 内嵌统一网关 +/// (gateway_url 默认 http://127.0.0.1:8899,DF_LOCAL_PROXY_BASE 可覆盖),无 SSRF 面故直接 +/// reqwest,不持 db/路径。声明式注册在 tools/local_proxy.rs(11 个工具)。 pub fn build_ai_tool_registry( db: &Arc, allowed_dirs: &Arc>, data_dir: PathBuf, get_app_config_ctx: super::get_app_config::GetAppConfigCtx, + local_proxy_ctx: super::tools::local_proxy::LocalProxyCtx, ) -> AiToolRegistry { let mut registry = AiToolRegistry::new(); register_data_tools(&mut registry, db); @@ -514,6 +520,7 @@ pub fn build_ai_tool_registry( register_fetch_search_tool(&mut registry); register_generate_image_tool(&mut registry, db, allowed_dirs); register_get_app_config_tool(&mut registry, get_app_config_ctx); + register_local_proxy_tools(&mut registry, local_proxy_ctx); registry } @@ -606,6 +613,21 @@ fn register_get_app_config_tool( super::tools::get_app_config::register(registry, ctx); } +/// local_proxy 工具代理注册(11 个:mysql/ssh/mongo/redis 本地代理)— thin 委托。 +/// +/// 经 DevFlow 内嵌统一网关(local_proxy_gateway:127.0.0.1:8899)路由到本地 4 个 HTTP 代理 +/// (mysql→3307 / ssh→3308 / mongo→3309 / redis→3310),固定 gateway_url 无 SSRF 面, +/// 声明式注册在 tools/local_proxy.rs。 +/// +/// risk 口径对齐 http_request:只读查询(Low)/ 数据写入(High)/ 轻量写(Medium)。 +/// declare_tool! 单捕获:仅需 gateway_url,包进 LocalProxyCtx struct(Clone 廉价)。 +fn register_local_proxy_tools( + registry: &mut AiToolRegistry, + ctx: super::tools::local_proxy::LocalProxyCtx, +) { + super::tools::local_proxy::register(registry, ctx); +} + /// 数据层 AI 工具注册(25 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。 /// /// 工具闭包捕获 `db: &Arc` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流/任务关联)。 @@ -1265,6 +1287,14 @@ mod tests { } } + /// build_ai_tool_registry 第 5 参(local_proxy 工具代理 ctx),测试调用共用。 + /// gateway_url 用默认本地网关即可,handler 不实际执行无需真实代理。 + fn make_test_local_proxy_ctx() -> crate::commands::ai::tools::local_proxy::LocalProxyCtx { + crate::commands::ai::tools::local_proxy::LocalProxyCtx { + gateway_url: "http://127.0.0.1:8899".to_string(), + } + } + // ============================================================ // 工具注册基线测试(SMELL-P0-2 拆分防护) // @@ -1273,7 +1303,7 @@ mod tests { // 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。 // ============================================================ - /// build_ai_tool_registry 应注册恰好 55 个工具(36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config),且工具名集合稳定。 + /// build_ai_tool_registry 应注册恰好 66 个工具(36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),且工具名集合稳定。 /// /// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db, // 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。 @@ -1284,7 +1314,7 @@ mod tests { // F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参, // 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。 let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db)); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); // 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。 // F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。 @@ -1326,10 +1356,15 @@ mod tests { // build_ai_tool_registry 加第 4 参 GetAppConfigCtx 绕 AppState 自引用,declare_tool! // 单捕获用 struct ctx 包 db + 3 agent Arc + LlmConcurrency,共享 AppState 同名句柄)。 // 55 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config。 + // local_proxy(2026-08-06): local_proxy 层 0→11(新增 mysql/ssh/mongo/redis 本地工具代理 + // 集成:list_proxy_connections/mysql_query/mysql_execute/ssh_exec/mongo_find/mongo_insert/ + // mongo_update/mongo_delete/redis_get/redis_set/redis_del,声明式注册在 tools/local_proxy.rs, + // build_ai_tool_registry 加第 5 参 LocalProxyCtx)。 + // 66 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。 assert_eq!( registry.len(), - 55, - "工具总数应为 55(36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config),实际 {}", registry.len() + 66, + "工具总数应为 66(36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len() ); // 工具名集合基线:防 rename / 漏注册 / 误删除。 @@ -1379,6 +1414,12 @@ mod tests { // ── get_app_config 层 (1) ──(查 DevFlow 自身当前 AI 配置,只读 + api_key 脱敏, // 治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败) "get_app_config", + // ── local_proxy 层 (11) ──(本地 4 个工具代理:mysql/ssh/mongo/redis, + // 只读查询 Low / 数据写 High / 轻量写 Medium,声明式注册 tools/local_proxy.rs) + "list_proxy_connections", "mysql_query", "mysql_execute", + "ssh_exec", "mongo_find", "mongo_insert", + "mongo_update", "mongo_delete", "redis_get", + "redis_set", "redis_del", ]; expected.sort_unstable(); @@ -1580,7 +1621,7 @@ mod tests { let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Arc::new(db); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db)); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let args = serde_json::json!({ "path": canon_file, "limit": 15 }); let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); @@ -1607,7 +1648,7 @@ mod tests { let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Arc::new(db); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db)); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let args = serde_json::json!({ "path": canon_file }); let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); @@ -1686,7 +1727,7 @@ mod tests { persistent.insert(tmp.clone()); let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() })); let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败")); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db)); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let args = serde_json::json!({ "path": canon_file }); @@ -1715,7 +1756,7 @@ mod tests { persistent.insert(tmp.clone()); let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() })); let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败")); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db)); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string(); let args = serde_json::json!({ "path": canon_file }); @@ -2197,7 +2238,7 @@ mod tests { .await .unwrap(); let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); - let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db)); + let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx()); (db, registry) } diff --git a/src-tauri/src/commands/ai/tools/local_proxy.rs b/src-tauri/src/commands/ai/tools/local_proxy.rs new file mode 100644 index 0000000..c804fbe --- /dev/null +++ b/src-tauri/src/commands/ai/tools/local_proxy.rs @@ -0,0 +1,508 @@ +//! 本地 DB/SSH 工具代理 AI 工具声明式注册(11 个:mysql/ssh/mongo/redis 本地代理)。 +//! +//! 本地跑 4 个 HTTP 代理服务(mysql-proxy:3307 / ssh-proxy:3308 / mongo-proxy:3309 / +//! redis-proxy:3310,默认绑 127.0.0.1 无鉴权),经 HTTP API 操作数据库/SSH。本模块把它们 +//! 注册为 AI 工具,让 AI 对话中直接查库/执行 SSH 命令,根治此前「AI 绕 run_command 执行 +//! PowerShell 内联脚本查 db,引号嵌套必失败」的场景。 +//! +//! 访问路径:DevFlow 内嵌本地代理统一网关(`local_proxy_gateway`,监听 127.0.0.1:8899, +//! env DF_LOCAL_PROXY_PORT 可覆盖),`POST /{type}/{path}` 前缀路由转发到对应代理端口。 +//! 端口收敛到网关一张映射表(mysql→3307 / ssh→3308 / mongo→3309 / redis→3310), +//! rust-work 改端口只改网关,本模块工具零改动;工具只连网关,端口常量不散落工具代码。 +//! +//! 命名:模块名 `local_proxy`(本地工具代理)—— 强调固定 gateway_url(默认 +//! http://127.0.0.1:8899,环境变量 DF_LOCAL_PROXY_BASE 可覆盖如远程部署)。gateway_url 每次 +//! 请求前校验仅允许 loopback(localhost/127.0.0.1/::1),防环境变量被设为内网/云元数据地址致 +//! SSRF。不做具体协议名(如 db_proxy 漏 ssh)也不做泛指操作名(如 ops_proxy 过载),留普适的 +//! 「本地代理」语义,未来加其他本地代理(kafka/etcd-proxy 等)直接并入,工具名 +//! (mysql_query/ssh_exec 等)各自描述协议域。 +//! +//! 代理 API 契约(经网关 `/{type}/{path}` 路由,type=mysql/ssh/mongo/redis): +//! - mysql POST /query {conn,sql,format} → {columns,rows,rowCount} +//! POST /execute {conn,sql} → {affectedRows,lastInsertId} +//! - ssh POST /exec {server,command} → {stdout,stderr,exitCode} +//! - mongo POST /find {conn,collection,filter} → {documents,count};/insert /update /delete +//! - redis POST /get {conn,key} → {value};/set /del /keys /run +//! - 各代理 GET /connections → {connections:[{name,...}]};错误统一 {"error":"..."} + HTTP 400 +//! +//! 安全:gateway_url 默认本地 127.0.0.1:8899,但经环境变量 DF_LOCAL_PROXY_BASE 可覆盖,故每次 +//! 请求前校验仅允许 loopback 地址([`validate_proxy_gateway_url`]),防环境变量被设为内网/云 +//! 元数据地址致 SSRF(http.rs validate_url 拒 localhost,本工具默认连本地代理,白名单相反, +//! 不复用)。网关侧另有 path 白名单 + connections/add 等凭据注入端点拒绝,工具侧依赖 +//! gateway_url loopback 校验兜底。risk 依据:只读查询(Low)/ 数据写入(High)/ 轻量写(Medium), +//! 对齐 http_request 口径。 +//! +//! declare_tool! 宏单捕获限制:本模块 11 工具仅需 gateway_url,包进 [`LocalProxyCtx`] struct +//! (Clone 廉价,单 String 字段),闭包内 `&ctx` 转调 proxy_post / proxy_connections 助手。 + +use std::sync::OnceLock; +use std::time::Duration; + +use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; +use df_ai::declare_tool; +use serde_json::{json, Value}; + +/// 本地工具代理配置(固定 gateway_url,仅允许 loopback 地址)。 +/// gateway_url 默认 http://127.0.0.1:8899(DevFlow 内嵌本地代理统一网关),可用环境变量 +/// DF_LOCAL_PROXY_BASE 覆盖(如远程部署)。约定含端口(网关监听端口),如 "http://127.0.0.1:8899"。 +/// 端口收敛到网关映射表(mysql→3307/ssh→3308/mongo→3309/redis→3310),工具不感知代理端口。 +/// 安全:每次请求前经 [`validate_proxy_gateway_url`] 校验仅 loopback,防环境变量设内网/公网致 SSRF。 +#[derive(Clone)] +pub struct LocalProxyCtx { + pub gateway_url: String, +} + +/// 本地工具代理请求超时(秒)。防代理挂起冻结 AI 会话(本地代理应毫秒级响应)。 +const PROXY_TIMEOUT_SECS: u64 = 15; + +/// 响应体截断上限(字节)。对齐 http.rs 默认 50KB,防大结果集(大表全量行/长 stdout)撑爆 +/// LLM context(本仓库已多次实证该问题的代价,read_file 全文回灌 prompt 爆等)。 +const MAX_RESPONSE_BYTES: usize = 50 * 1024; + +/// 从工具 args 提取必填 string 参数,缺省报清晰错误。 +fn required_str<'a>(args: &'a Value, name: &str) -> anyhow::Result<&'a str> { + args.get(name) + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("缺少 {} 参数", name)) +} + +/// 从工具 args 提取必填任意值参数(对象/数组),缺省报清晰错误。 +fn required_value(args: &Value, name: &str) -> anyhow::Result { + args.get(name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("缺少 {} 参数", name)) +} + +/// 校验本地工具代理 gateway_url:仅允许 http/https + loopback host(localhost/127.0.0.1/::1)。 +/// +/// 安全(S1):gateway_url 来自环境变量 DF_LOCAL_PROXY_BASE(运行时不可变)。若被设为内网/云元数据 +/// 地址(如 http://169.254.169.254)则 AI 工具可被诱导请求该地址 → SSRF。故每次请求前校验: +/// 协议白名单(http/https)+ host 白名单(仅 loopback,本地代理固定本地端点)。 +/// 不复用 http.rs validate_url —— 它拒 localhost,而本工具默认连 127.0.0.1,白名单相反。 +fn validate_proxy_gateway_url(gateway_url: &str) -> anyhow::Result<()> { + let parsed = reqwest::Url::parse(gateway_url) + .map_err(|e| anyhow::anyhow!("gateway_url 解析失败({}): {}", gateway_url, e))?; + let scheme = parsed.scheme().to_lowercase(); + if scheme != "http" && scheme != "https" { + anyhow::bail!( + "本地工具代理 gateway_url 仅允许 http/https(拒 file/ftp/gopher 等): {}", + gateway_url + ); + } + let host = parsed + .host_str() + .ok_or_else(|| anyhow::anyhow!("本地工具代理 gateway_url 缺少 host: {}", gateway_url))? + .to_lowercase(); + // host 白名单:仅 loopback(localhost/127.0.0.1/::1)。IPv6 字面量 Url::host_str 去方括号, + // 此处再防御性 trim(对齐 http.rs),与三值精确比对;其他 host(内网 IP/公网域名)→ 拒。 + let bare = host.trim_start_matches('[').trim_end_matches(']'); + if !matches!(bare, "localhost" | "127.0.0.1" | "::1") { + anyhow::bail!( + "本地工具代理 gateway_url 仅允许 loopback 地址(localhost/127.0.0.1/::1),当前 '{}': 拒绝内网/公网 host 防 SSRF", + bare + ); + } + Ok(()) +} + +/// 复用本地工具代理 HTTP client(连接池复用,避免每次请求新建)。仅设 timeout,reqwest 构建 +/// 失败仅 TLS 后端缺失等极端情况,本地 http 代理下不会发生,故 expect(对齐 http.rs 构建条件)。 +fn local_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(Duration::from_secs(PROXY_TIMEOUT_SECS)) + .build() + .expect("构建本地工具代理 HTTP client 失败") + }) +} + +/// 统一本地工具代理请求:校验 gateway_url(SSRF)→ 复用 client 发送 → 读取/截断/解析响应。 +/// +/// 返回 (response, truncated):response 优先 JSON.parse(代理返 {columns,rows}/{stdout,stderr, +/// exitCode} 等结构化结果;错误 {"error":"..."} 亦按 JSON 返回),解析失败回退 raw text; +/// 响应体 >50KB 时按 char 边界截断 + truncated=true(截断后不再尝试 JSON 解析,半截 JSON 无意义)。 +/// `body` 为 None 时发 GET(POST 用 Some)。错误返回 Err 含 URL 上下文。 +async fn proxy_request( + gateway_url: &str, + proxy_type: &str, + method: reqwest::Method, + path: &str, + body: Option, +) -> anyhow::Result<(Value, bool)> { + // SSRF 防护:每次请求前校验 gateway_url(环境变量运行时不可变,但防御性每次校验)。 + validate_proxy_gateway_url(gateway_url)?; + let url = format!("{}/{}/{}", gateway_url.trim_end_matches('/'), proxy_type, path); + let mut req = local_client().request(method, &url); + if let Some(b) = body { + req = req.json(&b); + } + let resp = req + .send() + .await + .map_err(|e| anyhow::anyhow!("本地工具代理请求失败({}): {}", url, e))?; + let bytes = resp + .bytes() + .await + .map_err(|e| anyhow::anyhow!("读取代理响应失败({}): {}", url, e))?; + let total = bytes.len(); + // 截断:超上限按 char 边界切(UTF-8 容错,切到多字节中间时 lossy 插 U+FFFD,可接受)+ 尾注。 + let (text, truncated) = if total > MAX_RESPONSE_BYTES { + let head = String::from_utf8_lossy(&bytes[..MAX_RESPONSE_BYTES]).into_owned(); + ( + format!("{}... [响应已截断,原始 {} 字节,仅保留前 {} 字节]", head, total, MAX_RESPONSE_BYTES), + true, + ) + } else { + (String::from_utf8_lossy(&bytes).into_owned(), false) + }; + let response = if truncated { + Value::String(text) + } else { + serde_json::from_str::(&text).unwrap_or_else(|_| Value::String(text)) + }; + Ok((response, truncated)) +} + +/// POST 到本地工具代理网关端点。返回 `{proxy_url, response, truncated}`(解析/截断逻辑见 +/// [`proxy_request`])。写工具(执行 SQL/SSH/mongo 写)与只读查询共用此入口。 +async fn proxy_post( + gateway_url: &str, + proxy_type: &str, + path: &str, + body: &Value, +) -> anyhow::Result { + let (response, truncated) = + proxy_request(gateway_url, proxy_type, reqwest::Method::POST, path, Some(body.clone())) + .await?; + let url = format!("{}/{}/{}", gateway_url.trim_end_matches('/'), proxy_type, path); + Ok(json!({ "proxy_url": url, "response": response, "truncated": truncated })) +} + +/// GET /connections 拉单个代理连接列表。失败返 `{"error":"..."}`(不 panic,供汇总容错)。 +async fn proxy_connections(gateway_url: &str, proxy_type: &str) -> Value { + match proxy_request(gateway_url, proxy_type, reqwest::Method::GET, "connections", None).await { + Ok((response, _truncated)) => response, + Err(e) => json!({ "error": format!("{}", e) }), + } +} + +/// 注册本地工具代理(11 个)到 `$registry`。 +/// +/// risk 口径对齐 http_request:只读查询(Low)/ 数据写入(High)/ 轻量写(Medium)。 +/// handler 统一转调 proxy_post,返回 `{proxy_url, response, truncated}`(response 优先 +/// JSON,>50KB 截断 + truncated=true)。 +pub fn register(registry: &mut AiToolRegistry, ctx: LocalProxyCtx) { + // ── list_proxy_connections:汇总 4 代理连接列表(只读,无参) ── + { + let schema = df_ai::ai_tools::object_schema(vec![]); + declare_tool!( + registry, + ctx: LocalProxyCtx, + "list_proxy_connections", + "列出本地 4 个工具代理(mysql/ssh/mongo/redis)当前已配置的连接名列表,返回 {mysql:[...], ssh:[...], mongo:[...], redis:[...]}(每项为对应代理 GET /connections 的原始响应,如 {connections:[{name,...}]};某代理未启动/失败时该项为 {\"error\":\"...\"} 不阻断整体)。用于 AI 先探查有哪些数据库连接/SSH 服务器可用,再选 conn/server 调 mysql_query/ssh_exec 等。", + RiskLevel::Low, + schema: schema, + _args => { + let gw = &ctx.gateway_url; + Ok(json!({ + "mysql": proxy_connections(gw, "mysql").await, + "ssh": proxy_connections(gw, "ssh").await, + "mongo": proxy_connections(gw, "mongo").await, + "redis": proxy_connections(gw, "redis").await, + })) + } + ); + } + + // ── mysql_query:只读 SELECT(Low) ── + { + let schema = df_ai::ai_tools::object_schema(vec![ + ("conn", "string", true), + ("sql", "string", true), + ("format", "string", false), + ]); + declare_tool!( + registry, + ctx: LocalProxyCtx, + "mysql_query", + "对 mysql-proxy 连接执行只读 SELECT 查询。参数:conn(连接名,先 list_proxy_connections 查)、sql(完整 SELECT 语句)、format(可选,默认 json)。返回 {columns, rows, rowCount}。仅用于 SELECT 等只读语句;写操作(INSERT/UPDATE/DELETE/DDL)用 mysql_execute(需人工批准)。", + RiskLevel::Low, + schema: schema, + args => { + let conn = required_str(&args, "conn")?; + let sql = required_str(&args, "sql")?; + let mut body = json!({ "conn": conn, "sql": sql }); + if let Some(f) = args.get("format") { + body["format"] = f.clone(); + } + proxy_post(&ctx.gateway_url, "mysql", "query", &body).await + } + ); + } + + // ── mysql_execute:数据写/DDL(High) ── + { + let schema = df_ai::ai_tools::object_schema(vec![ + ("conn", "string", true), + ("sql", "string", true), + ]); + declare_tool!( + registry, + ctx: LocalProxyCtx, + "mysql_execute", + "对 mysql-proxy 连接执行非只读语句(INSERT/UPDATE/DELETE/DDL 等)。参数:conn(连接名)、sql(完整 SQL 语句)。返回 {affectedRows, lastInsertId}。有数据写副作用,须人工批准;只读查询用 mysql_query。", + RiskLevel::High, + schema: schema, + args => { + let conn = required_str(&args, "conn")?; + let sql = required_str(&args, "sql")?; + let body = json!({ "conn": conn, "sql": sql }); + proxy_post(&ctx.gateway_url, "mysql", "execute", &body).await + } + ); + } + + // ── ssh_exec:远程命令执行(High) ── + { + let schema = df_ai::ai_tools::object_schema(vec![ + ("server", "string", true), + ("command", "string", true), + ]); + declare_tool!( + registry, + ctx: LocalProxyCtx, + "ssh_exec", + "经 ssh-proxy 连接执行远程 shell 命令。参数:server(服务器名,先 list_proxy_connections 查)、command(要执行的命令,如 \"docker ps\")。返回 {stdout, stderr, exitCode}(exitCode=0 表示成功)。远程命令有副作用 + 执行环境敏感,须人工批准。", + RiskLevel::High, + schema: schema, + args => { + let server = required_str(&args, "server")?; + let command = required_str(&args, "command")?; + let body = json!({ "server": server, "command": command }); + proxy_post(&ctx.gateway_url, "ssh", "exec", &body).await + } + ); + } + + // ── mongo_find:只读查询(Low,filter 为对象) ── + { + let schema = { + let mut props = serde_json::Map::new(); + props.insert("conn".into(), json!({ "type": "string", "description": "mongo 连接名" })); + props.insert("collection".into(), json!({ "type": "string", "description": "集合名" })); + props.insert("filter".into(), json!({ "type": "object", "description": "查询条件(JSON 对象),如 {\"status\":\"active\"};缺省查全部" })); + json!({ "type": "object", "properties": props, "required": ["conn", "collection"] }) + }; + declare_tool!( + registry, + ctx: LocalProxyCtx, + "mongo_find", + "对 mongo-proxy 连接执行只读查询。参数:conn(连接名)、collection(集合名)、filter(可选 JSON 对象查询条件)。返回 {documents, count}。写操作(insert/update/delete)用对应工具(需人工批准)。", + RiskLevel::Low, + schema: schema, + args => { + let conn = required_str(&args, "conn")?; + let collection = required_str(&args, "collection")?; + let mut body = json!({ "conn": conn, "collection": collection }); + if let Some(f) = args.get("filter") { + body["filter"] = f.clone(); + } + proxy_post(&ctx.gateway_url, "mongo", "find", &body).await + } + ); + } + + // ── mongo_insert:插入文档(High,documents 为数组) ── + { + let schema = { + let mut props = serde_json::Map::new(); + props.insert("conn".into(), json!({ "type": "string", "description": "mongo 连接名" })); + props.insert("collection".into(), json!({ "type": "string", "description": "集合名" })); + props.insert("documents".into(), json!({ "type": "array", "description": "待插入文档数组(每项为 JSON 对象),如 [{\"name\":\"x\",\"age\":1}]" })); + json!({ "type": "object", "properties": props, "required": ["conn", "collection", "documents"] }) + }; + declare_tool!( + registry, + ctx: LocalProxyCtx, + "mongo_insert", + "向 mongo-proxy 连接集合插入文档。参数:conn(连接名)、collection(集合名)、documents(待插入文档数组,每项 JSON 对象)。数据写副作用,须人工批准。", + RiskLevel::High, + schema: schema, + args => { + let conn = required_str(&args, "conn")?; + let collection = required_str(&args, "collection")?; + let documents = required_value(&args, "documents")?; + let body = json!({ "conn": conn, "collection": collection, "documents": documents }); + proxy_post(&ctx.gateway_url, "mongo", "insert", &body).await + } + ); + } + + // ── mongo_update:更新文档(High,filter/update 为对象) ── + { + let schema = { + let mut props = serde_json::Map::new(); + props.insert("conn".into(), json!({ "type": "string", "description": "mongo 连接名" })); + props.insert("collection".into(), json!({ "type": "string", "description": "集合名" })); + props.insert("filter".into(), json!({ "type": "object", "description": "匹配条件(JSON 对象)" })); + props.insert("update".into(), json!({ "type": "object", "description": "更新操作(JSON 对象),如 {\"$set\":{\"name\":\"y\"}}" })); + json!({ "type": "object", "properties": props, "required": ["conn", "collection", "filter", "update"] }) + }; + declare_tool!( + registry, + ctx: LocalProxyCtx, + "mongo_update", + "更新 mongo-proxy 连接集合中匹配 filter 的文档。参数:conn、collection、filter(匹配条件对象)、update(更新操作对象,如 {\"$set\":{\"name\":\"y\"}})。数据写副作用,须人工批准。", + RiskLevel::High, + schema: schema, + args => { + let conn = required_str(&args, "conn")?; + let collection = required_str(&args, "collection")?; + let filter = required_value(&args, "filter")?; + let update = required_value(&args, "update")?; + let body = json!({ "conn": conn, "collection": collection, "filter": filter, "update": update }); + proxy_post(&ctx.gateway_url, "mongo", "update", &body).await + } + ); + } + + // ── mongo_delete:删除文档(High,filter 为对象,multi 可选) ── + { + let schema = { + let mut props = serde_json::Map::new(); + props.insert("conn".into(), json!({ "type": "string", "description": "mongo 连接名" })); + props.insert("collection".into(), json!({ "type": "string", "description": "集合名" })); + props.insert("filter".into(), json!({ "type": "object", "description": "匹配条件(JSON 对象)" })); + props.insert("multi".into(), json!({ "type": "boolean", "description": "是否删除全部匹配(默认 false 仅删首条)" })); + json!({ "type": "object", "properties": props, "required": ["conn", "collection", "filter"] }) + }; + declare_tool!( + registry, + ctx: LocalProxyCtx, + "mongo_delete", + "删除 mongo-proxy 连接集合中匹配 filter 的文档。参数:conn、collection、filter(匹配条件对象)、multi(可选布尔,true 删除全部匹配,默认仅删首条)。数据写副作用,须人工批准。", + RiskLevel::High, + schema: schema, + args => { + let conn = required_str(&args, "conn")?; + let collection = required_str(&args, "collection")?; + let filter = required_value(&args, "filter")?; + let mut body = json!({ "conn": conn, "collection": collection, "filter": filter }); + if let Some(m) = args.get("multi") { + body["multi"] = m.clone(); + } + proxy_post(&ctx.gateway_url, "mongo", "delete", &body).await + } + ); + } + + // ── redis_get:只读取值(Low) ── + { + let schema = df_ai::ai_tools::object_schema(vec![ + ("conn", "string", true), + ("key", "string", true), + ]); + declare_tool!( + registry, + ctx: LocalProxyCtx, + "redis_get", + "经 redis-proxy 读取指定 key 的值。参数:conn(连接名)、key。返回 {value}。只读;写操作(redis_set/redis_del)为 Medium 轻量写。", + RiskLevel::Low, + schema: schema, + args => { + let conn = required_str(&args, "conn")?; + let key = required_str(&args, "key")?; + let body = json!({ "conn": conn, "key": key }); + proxy_post(&ctx.gateway_url, "redis", "get", &body).await + } + ); + } + + // ── redis_set:写入 key(Medium,ttl 可选) ── + { + let schema = df_ai::ai_tools::object_schema(vec![ + ("conn", "string", true), + ("key", "string", true), + ("value", "string", true), + ("ttl", "integer", false), + ]); + declare_tool!( + registry, + ctx: LocalProxyCtx, + "redis_set", + "经 redis-proxy 写入 key 的值。参数:conn(连接名)、key、value、ttl(可选整数秒,过期时间)。轻量写副作用(Medium 审批档)。", + RiskLevel::Medium, + schema: schema, + args => { + let conn = required_str(&args, "conn")?; + let key = required_str(&args, "key")?; + let value = required_str(&args, "value")?; + let mut body = json!({ "conn": conn, "key": key, "value": value }); + if let Some(t) = args.get("ttl") { + body["ttl"] = t.clone(); + } + proxy_post(&ctx.gateway_url, "redis", "set", &body).await + } + ); + } + + // ── redis_del:删除多个 key(Medium,keys 为数组) ── + { + let schema = { + let mut props = serde_json::Map::new(); + props.insert("conn".into(), json!({ "type": "string", "description": "redis 连接名" })); + props.insert("keys".into(), json!({ "type": "array", "items": { "type": "string" }, "description": "待删除的 key 列表(数组)" })); + json!({ "type": "object", "properties": props, "required": ["conn", "keys"] }) + }; + declare_tool!( + registry, + ctx: LocalProxyCtx, + "redis_del", + "经 redis-proxy 删除一个或多个 key。参数:conn(连接名)、keys(key 数组)。轻量写副作用(Medium 审批档)。", + RiskLevel::Medium, + schema: schema, + args => { + let conn = required_str(&args, "conn")?; + let keys = required_value(&args, "keys")?; + let body = json!({ "conn": conn, "keys": keys }); + proxy_post(&ctx.gateway_url, "redis", "del", &body).await + } + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── validate_proxy_gateway_url:loopback 白名单 + 协议白名单 ── + + #[test] + fn test_validate_proxy_gateway_url_loopback_ok() { + // 默认网关地址(http/https、host 形态)应通过;URL 可带端口,host 校验与端口无关 + assert!(validate_proxy_gateway_url("http://127.0.0.1").is_ok()); + assert!(validate_proxy_gateway_url("http://localhost").is_ok()); + assert!(validate_proxy_gateway_url("http://[::1]").is_ok()); + assert!(validate_proxy_gateway_url("https://127.0.0.1").is_ok()); + assert!(validate_proxy_gateway_url("http://127.0.0.1:8899").is_ok()); + assert!(validate_proxy_gateway_url("HTTP://LOCALHOST").is_ok()); // 大小写归一 + } + + #[test] + fn test_validate_proxy_gateway_url_rejects_non_loopback_host() { + // 内网/云元数据/公网域名一律拒绝(SSRF 核心:防环境变量指向非本地代理) + assert!(validate_proxy_gateway_url("http://169.254.169.254").is_err()); // 云元数据服务 + assert!(validate_proxy_gateway_url("http://10.0.0.1").is_err()); // RFC1918 A 类 + assert!(validate_proxy_gateway_url("http://192.168.1.1").is_err()); // RFC1918 C 类 + assert!(validate_proxy_gateway_url("http://172.16.0.1").is_err()); // RFC1918 B 类 + assert!(validate_proxy_gateway_url("http://example.com").is_err()); // 公网域名 + } + + #[test] + fn test_validate_proxy_gateway_url_rejects_bad_scheme_or_missing_host() { + assert!(validate_proxy_gateway_url("file:///etc/passwd").is_err()); + assert!(validate_proxy_gateway_url("ftp://127.0.0.1").is_err()); + assert!(validate_proxy_gateway_url("").is_err()); + } +} diff --git a/src-tauri/src/commands/ai/tools/mod.rs b/src-tauri/src/commands/ai/tools/mod.rs index e8bf40d..9eeb1db 100644 --- a/src-tauri/src/commands/ai/tools/mod.rs +++ b/src-tauri/src/commands/ai/tools/mod.rs @@ -14,6 +14,7 @@ pub mod task; pub mod task_graph; pub mod git; pub mod http; +pub mod local_proxy; pub mod fetch_url; pub mod fetch_search; pub mod generate_image; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04e3e94..3865d32 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,8 @@ mod commands; mod state; // Phase3: 桌面内嵌 HTTP MCP server + 托盘常驻 + 单实例 mod mcp; +// 本地工具代理统一网关:内嵌 axum 监听 127.0.0.1:8899,路由到 mysql/ssh/mongo/redis 4 代理端口 +mod local_proxy_gateway; mod tray; use tauri::{Emitter, Listener, Manager}; @@ -90,6 +92,9 @@ pub fn run() { // db 经 app.state::() 取 Arc clone(manage 已注入,setup 后续可用)。 let db_for_mcp = app.state::().db.clone(); mcp::spawn_mcp_http(app.handle(), db_for_mcp); + // 2.5) 启动本地工具代理统一网关(127.0.0.1:8899 → mysql/ssh/mongo/redis 4 代理端口)。 + // 11 个 local_proxy 工具只连网关,端口收敛到网关映射表,失败非阻断(仅日志)。 + crate::local_proxy_gateway::spawn_local_proxy_gateway(app.handle()); // 3) 创建托盘(关窗口不退出,托盘「退出」才真正结束进程)。失败非阻断。 if let Err(e) = tray::setup_tray(app.handle()) { tracing::error!("[tray] 托盘创建失败(非阻断): {}", e); diff --git a/src-tauri/src/local_proxy_gateway.rs b/src-tauri/src/local_proxy_gateway.rs new file mode 100644 index 0000000..f6c326c --- /dev/null +++ b/src-tauri/src/local_proxy_gateway.rs @@ -0,0 +1,246 @@ +//! 本地工具代理统一网关(DevFlow 内嵌,一个端口路由到 rust-work 4 个代理)。 +//! 仿 mcp.rs spawn_mcp_http:tauri 后台任务 axum 监听 127.0.0.1:, +//! POST /{type}/{path} 前缀路由转发到对应代理端口,body 原样透传。 +//! 端口映射收敛到此一张表,rust-work 改端口只改这里,工具零改动。 +//! +//! axum 0.7.9 路由语法:匹配 matchit `:param` / `*param`(route.md 文档 `:key` / `/*key`, +//! 非 `{param}` 大括号语法),故 `/{type}/{*path}` 对应注册为 `/:type/*path`。 +//! 请求 URL 概念形态仍为 `/{type}/{path}`(如 POST /mysql/query)。 +//! +//! 背景:local_proxy.rs 的 11 个工具此前直接 HTTP 调 4 个代理端口 +//! (mysql:3307 / ssh:3308 / mongo:3309 / redis:3310),硬编码端口耦合 rust-work 细节。 +//! 本网关把「type → 端口」映射收敛为一张表,工具只连网关 127.0.0.1:8899, +//! 端口从工具代码消失。 +//! +//! 安全(两层): +//! 1. 监听 127.0.0.1 loopback 固定,不暴露局域网; +//! 2. path 白名单:仅放行业务端点(query/execute/exec/...),白名单外一律拒绝; +//! connections/add / servers/add 这类「凭据注入」高风险端点显式拒绝(纵深防御, +//! 即便白名单后续误加也不放行)。 +//! +//! 端口:env `DF_LOCAL_PROXY_PORT` 覆盖,默认 8899。仅 tracing 记录 bind 结果, +//! 不做 managed 状态管理(mcp.rs 的 running 状态非必需,网关失败不阻断主应用)。 +use std::sync::OnceLock; +use std::time::Duration; + +use axum::{ + body::Bytes, + extract::Path, + http::{HeaderMap, Method, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use tauri::AppHandle; + +/// 端口映射表:proxy type → rust-work 代理端口。收敛到此一张表, +/// rust-work 改端口只改这里(工具代码零改动)。 +const PROXY_PORTS: &[(&str, u16)] = &[ + ("mysql", 3307), + ("ssh", 3308), + ("mongo", 3309), + ("redis", 3310), +]; + +/// 业务端点白名单(代理 API 契约):白名单外一律拒绝(403)。 +/// 对齐 local_proxy.rs 文档契约:query/execute/exec/find/insert/update/delete/ +/// get/set/del/keys/run/info/count/aggregate/connections/servers/health。 +const ALLOWED_PATHS: &[&str] = &[ + "query", "execute", "exec", "find", "insert", "update", "delete", + "get", "set", "del", "keys", "run", "info", "count", "aggregate", + "connections", "servers", "health", +]; + +/// 凭据注入高风险端点:显式拒绝(即便未来误加白名单也不放行,纵深防御)。 +/// connections/add / servers/add 向代理注入连接/服务器凭据,风险高,网关拦截。 +const DENIED_PATHS: &[&str] = &["connections/add", "servers/add"]; + +/// 转发超时(秒)。防代理挂起冻结 AI 会话(本地代理应毫秒级响应)。 +const GATEWAY_TIMEOUT_SECS: u64 = 15; + +/// 解析网关监听端口:优先 env `DF_LOCAL_PROXY_PORT`,否则默认 8899。 +pub fn gateway_port() -> u16 { + std::env::var("DF_LOCAL_PROXY_PORT") + .ok() + .and_then(|p| p.parse::().ok()) + .unwrap_or(8899) +} + +/// 在 tauri 后台任务里启动本地工具代理统一网关。 +/// +/// bind 127.0.0.1:port → axum Router(`/{type}/{*path}` GET+POST)→ axum::serve 常驻。 +/// 仿 mcp.rs spawn_mcp_http:tauri::async_runtime::spawn + tracing 记录 bind 成败; +/// 不 manage 状态(网关失败非阻断,前端无需感知;需要时再仿 McpStatus 扩展)。 +/// `_app` 当前未使用(仅对齐 mcp 调用形态,预留状态管理),故前缀下划线。 +pub fn spawn_local_proxy_gateway(_app: &AppHandle) { + let port = gateway_port(); + tauri::async_runtime::spawn(async move { + let listener = match tokio::net::TcpListener::bind(("127.0.0.1", port)).await { + Ok(l) => l, + Err(e) => { + tracing::error!(port, err = %e, "[local-proxy-gateway] 绑定端口失败,本地工具代理网关不可用(可设 DF_LOCAL_PROXY_PORT 换端口)"); + return; + } + }; + tracing::info!(port, "[local-proxy-gateway] 本地工具代理网关 listening on 127.0.0.1:{port}(路由 mysql/ssh/mongo/redis)"); + let router = Router::new().route("/:type/*path", get(proxy).post(proxy)); + if let Err(e) = axum::serve(listener, router).await { + tracing::error!(err = %e, "[local-proxy-gateway] 网关异常退出"); + } + }); +} + +/// 复用转发 HTTP client(连接池复用,避免每次请求新建)。仅设 timeout,本地 http 代理下 +/// 构建失败不会发生,expect(对齐 local_proxy.rs::local_client)。 +fn gateway_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(Duration::from_secs(GATEWAY_TIMEOUT_SECS)) + .build() + .expect("构建本地工具代理网关 HTTP client 失败") + }) +} + +/// 网关统一转发 handler:GET/POST `/:type/*path` → 代理端口,body 原样透传。 +/// +/// 校验顺序:① type 不在 4 代理映射 → 400;② path 命中拒绝名单 → 403; +/// ③ path 不在业务白名单 → 403;④ 通过后转发到 `http://127.0.0.1:{port}/{path}`, +/// method 原样、body 透传、Content-Type 透传(代理按 JSON 解析),响应状态码 + body 原样返回。 +/// (路由用 axum 0.7.9 matchit 语法 `:type/*path`;请求 URL 概念形态为 `/{type}/{path}`。) +async fn proxy( + method: Method, + Path((proxy_type, path)): Path<(String, String)>, + headers: HeaderMap, + body: Bytes, +) -> Response { + // ① type 白名单:不在映射表 → 400 + let Some(port) = PROXY_PORTS + .iter() + .find(|(t, _)| *t == proxy_type) + .map(|(_, p)| *p) + else { + return ( + StatusCode::BAD_REQUEST, + format!("未知代理类型: {proxy_type}(仅支持 mysql/ssh/mongo/redis)"), + ) + .into_response(); + }; + // ② 凭据注入高风险端点显式拒(纵深防御,与白名单独立校验) + if DENIED_PATHS.contains(&path.as_str()) { + return ( + StatusCode::FORBIDDEN, + format!("端点已拒绝(凭据注入高风险): {path}"), + ) + .into_response(); + } + // ③ path 白名单:业务端点外一律拒绝(防任意路径打到代理) + if !ALLOWED_PATHS.contains(&path.as_str()) { + return ( + StatusCode::FORBIDDEN, + format!("端点不在白名单,已拒绝: {path}"), + ) + .into_response(); + } + // ④ 转发到对应代理端口,method/body/Content-Type 原样透传 + let url = format!("http://127.0.0.1:{port}/{path}"); + let mut req = gateway_client().request(method, &url).body(body); + if let Some(ct) = headers.get(axum::http::header::CONTENT_TYPE) { + req = req.header(axum::http::header::CONTENT_TYPE, ct.clone()); + } + match req.send().await { + Ok(resp) => { + let status = resp.status(); + match resp.bytes().await { + Ok(bytes) => (status, bytes).into_response(), + Err(e) => ( + StatusCode::BAD_GATEWAY, + format!("读取代理响应失败({url}): {e}"), + ) + .into_response(), + } + } + Err(e) => ( + StatusCode::BAD_GATEWAY, + format!("转发到代理失败({url}): {e}"), + ) + .into_response(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::StatusCode; + + /// 网关路由冒烟测试:axum 0.7.9 路由语法是 matchit `:type/*path`(非 `{type}/{*path}`), + /// 本测试守护路由匹配 + type/path 白名单拒绝逻辑。用临时端口 bind + axum::serve, + /// 再 reqwest 打真实 HTTP,验证: + /// - 未知 type → 400(路由已匹配但 type 不在映射表) + /// - 拒绝名单(connections/add)→ 403 + /// - 白名单外 path(drop)→ 403 + /// - 合法 type+path(mysql/query)→ 经路由命中并转发,代理未起(502)/已起(200) + /// 均非 4xx,证明「路由匹配 + 白名单放行 + 转发」全链路过,非 404 未命中。 + /// + /// 注:本机有 0xc0000139 环境问题测试无法运行,此测试为回归守卫(修复环境后可跑)。 + #[tokio::test] + async fn test_gateway_routing_and_whitelist() { + let router = Router::new().route("/:type/*path", get(proxy).post(proxy)); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind 临时端口失败"); + let addr = listener.local_addr().expect("取临时端口失败"); + let server = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + // 等 server 就绪(本地毫秒级,50ms 足够) + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let base = format!("http://127.0.0.1:{}", addr.port()); + let client = reqwest::Client::new(); + + // ① 未知 type → 400 + let resp = client + .post(format!("{base}/bogus/query")) + .json(&serde_json::json!({"conn": "c"})) + .send() + .await + .expect("bogus 请求失败"); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "未知 type 应 400"); + + // ② 凭据注入端点 → 403 + let resp = client + .post(format!("{base}/mysql/connections/add")) + .json(&serde_json::json!({})) + .send() + .await + .expect("connections/add 请求失败"); + assert_eq!(resp.status(), StatusCode::FORBIDDEN, "connections/add 应 403"); + + // ③ 白名单外 path → 403 + let resp = client + .post(format!("{base}/mysql/drop")) + .json(&serde_json::json!({"conn": "c"})) + .send() + .await + .expect("drop 请求失败"); + assert_eq!(resp.status(), StatusCode::FORBIDDEN, "白名单外 path 应 403"); + + // ④ 合法 type+path:路由命中并转发。注意本机 3307 可能有真实 mysql-proxy 在跑, + // 转发后可能返 400(业务错误,如 conn 不存在)——这恰证明「路由匹配 + 白名单放行 + + // 转发到代理」全链路通(非 404 未命中/非 403 白名单拒)。仅需断言非网关自身的 + // 403(白名单拒)/404(路由未命中)。代理业务 400/200/502 都算链路通。 + let resp = client + .post(format!("{base}/mysql/query")) + .json(&serde_json::json!({"conn": "c", "sql": "select 1"})) + .send() + .await + .expect("mysql/query 请求失败"); + let status = resp.status(); + assert!( + status != StatusCode::NOT_FOUND && status != StatusCode::FORBIDDEN, + "合法 mysql/query 不应被网关拒绝(403 白名单/404 路由未命中),实际 {status}" + ); + + server.abort(); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 9c98e74..1362119 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -253,8 +253,15 @@ impl AppState { approval_timeout_minutes: approval_timeout_minutes.clone(), llm_concurrency: llm_concurrency.clone(), }; + // local_proxy(2026-08-06):本地工具代理(mysql/ssh/mongo/redis)统一网关地址, + // 默认 http://127.0.0.1:8899(DevFlow 内嵌网关,端口收敛到网关映射表), + // 可用环境变量 DF_LOCAL_PROXY_BASE 覆盖(如远程部署)。 + let local_proxy_ctx = crate::commands::ai::tools::local_proxy::LocalProxyCtx { + gateway_url: std::env::var("DF_LOCAL_PROXY_BASE") + .unwrap_or_else(|_| "http://127.0.0.1:8899".to_string()), + }; let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry( - &db, &allowed_dirs, data_dir.clone(), get_app_config_ctx, + &db, &allowed_dirs, data_dir.clone(), get_app_config_ctx, local_proxy_ctx, )); // Input Augmentation 层(核心设计2):ResolverRegistry 启动期注册四 resolver。 // resolver 持 Arc(非 AppState,避免循环依赖:AppState 持 Arc),