feat(search): web-search 接线本机 SearXNG 兜底 + 显式开关(她不再拿一把用不了的锤子)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人报的故障:她调 web-search 必失败(no search endpoint configured)。根因两条:组合根从未注入 WithSearchEndpoint + 配置层根本没有搜索配置项。
- internal/config/websearch.go:WEB_SEARCH_ENDPOINT(默认本机兜底 http://127.0.0.1:38471)+ WEB_SEARCH_TIMEOUT;ResolveEndpoint() 三态裁决(可用/显式关闭/非法),非法值启动即报错
- cmd/cyrene/wire_io_tools.go:组合根唯一的 io 选项装配点;端点不可用 ⇒ WithoutSearch() + 带 source 的 WARN(只记键名与原因,不打印 URL 值)
- plugins/tools/io:新增 WithoutSearch()(**根本不进注册面** ⇒ 模型看不见)+ WithSearchTimeout();协议解析逻辑一字未动
- 隐蔽坑(额外查出并修):SearXNG 对 /?q= 回 308 → /search,重定向那一跳走**共享 SSRF 白名单** ⇒ 只注入端点照样被拦。改为**只给 web-search 一件**加宽自己的客户端与白名单,共享白名单与 http/web-fetch 客户端一字未动(有测试钉住它仍拒绝本机网关地址)
- .env.example 新增 2 键 + 注释说明「空串/off/none = 不注册」
- 真机 e2e 两次绿(SearXNG 返回 9 条);变异:不注入端点 / 关闭态仍注册 / 默认值改错 / 不并入端点主机 / 重定向用共享白名单 ⇒ 5 条全红
This commit is contained in:
AskaEth committed 2026-10-05 20:29:21 +08:00
1 parent 8e56163418
commit 8e6c77ad10
11 files changed
+1137 -20

No files matched your search

+49 -2
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
@@ -24,16 +25,29 @@ type webSearchTool struct {
maxBytes int64
maxResults int
log *slog.Logger
// timeout 是本次搜索的超时覆盖(0 = 沿用共享客户端自己的超时)。它只约束
// web-search 一次请求,不牵动 http / web-fetch 共用的那个客户端(WS1-A)。
timeout time.Duration
}
func newWebSearchTool(t *tools) *webSearchTool {
// 端点来自**运维配置**,不是模型输入(模型的 query 只会进查询串,主机名恒为
// 配置里那一个):因此把它并入本工具**自己**的 SSRF 白名单。否则本机兜底端点
// http://127.0.0.1:38471 会被 SSRF 守卫按回环地址拒掉——那正是「端点配了却
// 仍然用不了」的另一种形态(SearXNG 还会 308 跳到 /search,重定向那一跳同样
// 要过守卫,所以加宽后的名单也必须交给客户端,见 tools.clientFor)。
//
// 刻意只并入 web-search:http / web-fetch 的 URL 由模型给出,它们共用
// t.allowedHosts,把回环塞进**那份**名单会让模型能伸手去摸本机服务。
allowedHosts := withSearchEndpointHost(t.allowedHosts, t.searchURL)
return &webSearchTool{
client: t.client,
client: t.clientFor(allowedHosts),
endpoint: t.searchURL,
allowedHosts: t.allowedHosts,
allowedHosts: allowedHosts,
maxBytes: t.maxHTTPBytes,
maxResults: t.maxResults,
log: t.log,
timeout: t.searchTimeout,
}
}
@@ -70,6 +84,12 @@ func (s *webSearchTool) Execute(ctx context.Context, args any) (any, error) {
if strings.TrimSpace(s.endpoint) == "" {
return nil, fmt.Errorf("io: web-search: no search endpoint configured (set a SearXNG or custom URL)")
}
// 每次调用各自的超时预算:ctx 在此派生,取消随本次调用一起结束。
if s.timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, s.timeout)
defer cancel()
}
target, err := buildSearchURL(s.endpoint, query)
if err != nil {
return nil, err
@@ -135,6 +155,33 @@ func buildSearchURL(endpoint, query string) (string, error) {
return endpoint + sep + "q=" + q + "&format=json", nil
}
// withSearchEndpointHost 把**配置里给定的**搜索端点主机并入 web-search 自己的
// SSRF 白名单(返回新切片,不改动入参切片——组合根可能把它交给别的工具)。
// 端点解析不出主机、或主机已在名单里时原样返回。
//
// 理由见 newWebSearchTool:本机兜底端点必然是回环地址(127.0.0.1:38471),而
// SSRF 守卫默认拒绝回环;端点是运维配置而非模型输入,因此对它放行**不会**给模型
// 打开新的目标面——工具能去的主机恒等于配置里的那一个。
func withSearchEndpointHost(allowed []string, endpoint string) []string {
host := searchEndpointHost(endpoint)
if host == "" || isHostAllowed(host, allowed) {
return allowed
}
out := make([]string, 0, len(allowed)+1)
out = append(out, allowed...)
return append(out, host)
}
// searchEndpointHost 取出搜索端点的主机名(去端口、去 IPv6 方括号);解析不出来
// 或没有主机时返回空串。
func searchEndpointHost(endpoint string) string {
u, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil {
return ""
}
return hostOnly(u.Host)
}
// searchResult is one entry of a SearXNG-compatible results payload.
type searchResult struct {
Title string `json:"title"`