package main // 本文件是「搜索提供商」页**探针**的传输层绑定(切片 L1/PLG-2 的执行面接线的一部分): // 探针要用的 HTTP 客户端、目标约束(同主机守卫)与失败状态映射。 // 动作本体的语义在 `websearch_action_data.go`,登记面在 `wire_plugin_actions.go`。 import ( "context" "fmt" "net/http" "net/url" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) // 探针的边界值。名字化的理由与 io 包同一套:不许裸魔法数字(项目规范 §10)。 const ( // webSearchProbeTimeout 是探针的兜底超时(面板文件与 `.env` 都没声明时用它)—— // 取 io 工具集的默认 http 超时同值(15s),免得「面板测通了、工具超时」这种怪象。 webSearchProbeTimeout = 15 * time.Second // webSearchProbeMaxBytes 是探针响应体的读取上限(与 io 包的 http 响应上限同口径 1 MiB)。 webSearchProbeMaxBytes = 1 << 20 // webSearchProbeMaxRedirects 是探针允许的跳转次数(与 io 的 maxRedirects 同值)。 webSearchProbeMaxRedirects = 3 ) // probeErrorStatus 按探针码给出 HTTP 状态:调用方/配置问题 ⇒ 400;上游或网络 ⇒ 502。 // // 它**不**改变探针的语义(no_results 仍然是 no_results,不是「成功」也不是「该家不可用」), // 只是给前端一个非 2xx 的信号,好让面板把结论显示出来(见 testProvider 的注释)。 func probeErrorStatus(code string) int { switch code { case search.ProbeUnknownProvider, search.ProbeNotConfigured: return http.StatusBadRequest default: return http.StatusBadGateway } } // probeTarget 是一次探针的目标约束:**只允许打到配置里那台主机**。 // // 与 io 包 SSRF 守卫的关系(写清楚,免得被当成第二份安全策略):地址来自面板里存下的 // provider 基地址 —— 它是**运维输入**(owner 通过 owner/admin 写动作落下的),不是模型 // 输入。io 的守卫用「私网/回环黑名单 + 允许清单」,而那个守卫在 `plugins/tools/io` // 内部未导出,本片**不改他人文件**(硬边界),因此这里用一条**更严**的规则替代: // 探针只与配置主机通话,跨主机(含跨主机重定向)一律拒绝。 // 缺口如实登记:两处策略不逐字同源(见报告 §未做)。 type probeTarget struct { // host 是配置主机名(小写、无端口)。 host string } // newProbeTarget 解析出配置主机;地址为空 / 非 http(s) / 没有主机名时返回错误。 func newProbeTarget(baseURL string) (probeTarget, error) { raw := strings.TrimSpace(baseURL) if raw == "" { return probeTarget{}, fmt.Errorf("web-search probe: empty base url") } u, err := url.Parse(raw) if err != nil { return probeTarget{}, fmt.Errorf("web-search probe: parse base url: %w", err) } if !isHTTPURL(u) { return probeTarget{}, fmt.Errorf("web-search probe: unsupported scheme %q", u.Scheme) } host := strings.ToLower(u.Hostname()) if host == "" { return probeTarget{}, fmt.Errorf("web-search probe: base url has no host") } return probeTarget{host: host}, nil } // client 造这次探针要用的 HTTP 客户端:超时上限 + 同主机的跳转策略。 // 它是**每次调用新建**的(探针很少发生,不值得为它维护一份共享客户端与连接池); // 也因此不牵动 http / web-fetch / web-search 共用的那份客户端与白名单。 func (t probeTarget) client(timeout time.Duration) *http.Client { return &http.Client{Timeout: timeout, CheckRedirect: t.checkRedirect} } // guard 是注入给 search.Config 的地址判定(发请求前对最终地址做一次判定)。 func (t probeTarget) guard(_ context.Context, rawURL string) error { return t.check(rawURL) } // checkRedirect 是跳转策略:次数上限 + 每一跳都要过同一条判定。 func (t probeTarget) checkRedirect(req *http.Request, via []*http.Request) error { if len(via) >= webSearchProbeMaxRedirects { return fmt.Errorf("web-search probe: too many redirects") } if req.URL == nil { return fmt.Errorf("web-search probe: nil redirect target") } return t.check(req.URL.String()) } // check 判定一个地址能否访问:必须是 http(s) 且主机等于配置主机。 func (t probeTarget) check(rawURL string) error { u, err := url.Parse(rawURL) if err != nil { return fmt.Errorf("web-search probe: parse target: %w", err) } if !isHTTPURL(u) { return fmt.Errorf("web-search probe: unsupported scheme %q", u.Scheme) } if !strings.EqualFold(u.Hostname(), t.host) { return fmt.Errorf("web-search probe: refused cross-host target %q", u.Hostname()) } return nil } // isHTTPURL 报告地址是否是可访问的 http(s) 绝对地址。 func isHTTPURL(u *url.URL) bool { if u == nil { return false } return u.Scheme == "http" || u.Scheme == "https" }