feat(web-search): add SearXNG provider (#1166)

支持对接自建/公共 SearXNG 实例作为网络搜索引擎,缓解免费搜索引擎在国内
网络环境下访问受限的问题。

- types: 新增 WebSearchProviderTypeSearxng 与 BaseURL 参数字段;
  类型元数据新增 RequiresBaseURL,前端可动态渲染 Instance URL 表单。
- infrastructure/web_search/searxng.go: 调用 /search?format=json,强制
  utils.ValidateURLForSSRF 校验 base_url,可选 api_key 透传给反代鉴权。
- service: isValidProviderType 与参数校验接入 searxng。
- container: 注册 NewSearxngProvider 工厂。
- frontend: WebSearchSettings 表单根据 requires_base_url 渲染 Instance
  URL 输入框;编辑回填、free 判定同步更新。
- docker: 新增可选 searxng 服务(profile=searxng/full),附带最小化
  settings.yml(启用 JSON 格式、关闭 limiter、关闭遥测),
  docker-compose 默认 SSRF_WHITELIST 包含 searxng 容器名。
- .env.example: 补充 SEARXNG_PORT / SEARXNG_SECRET 说明。

Closes #1166
This commit is contained in:
wizardchen
2026-05-10 22:01:28 +08:00
committed by lyingbug
parent 9dcb61fb45
commit 1110615300
10 changed files with 295 additions and 6 deletions
+7
View File
@@ -203,6 +203,13 @@ SYSTEM_AES_KEY=weknora-system-aes-key-32bytes!!
# 列入者会在 URL 校验等地方绕过常规 SSRF 规则,生产环境请谨慎配置。
# SSRF_WHITELIST=internal.service,*.corp.example,172.16.0.0/12,2001:db8::1,fd00::/8
# ===== SearXNG(可选自建网络搜索)=====
# 启用:docker compose --profile searxng up -d
# 默认对外端口 8888,容器内主机名 searxng:8080;后端通过 docker network 直接访问。
# WeKnora 控制台中 Provider 类型选 "SearXNG",Instance URL 填 http://searxng:8080 即可。
# SEARXNG_PORT=8888
# SEARXNG_SECRET=please-change-me-to-a-long-random-string # openssl rand -hex 32
# 是否开启知识图谱构建和检索(构建阶段需调用大模型,耗时较长)
ENABLE_GRAPH_RAG=false
+24
View File
@@ -35,6 +35,30 @@ services:
networks:
- WeKnora-network-dev
searxng:
image: searxng/searxng:latest
container_name: WeKnora-searxng-dev
ports:
- "${SEARXNG_PORT:-8888}:8080"
volumes:
- ./docker/searxng/settings.yml:/etc/searxng/settings.yml:rw
environment:
- SEARXNG_BASE_URL=http://localhost:${SEARXNG_PORT:-8888}/
- INSTANCE_NAME=weknora-searxng-dev
- SEARXNG_SECRET=${SEARXNG_SECRET:-please-change-me-to-a-long-random-string}
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
restart: unless-stopped
networks:
- WeKnora-network-dev
profiles:
- searxng
- full
minio:
image: minio/minio:latest
container_name: WeKnora-minio-dev
+25 -1
View File
@@ -134,7 +134,7 @@ services:
- NEO4J_PASSWORD=${NEO4J_PASSWORD:-password}
- TENANT_AES_KEY=${TENANT_AES_KEY:-}
- SYSTEM_AES_KEY=${SYSTEM_AES_KEY:-}
- SSRF_WHITELIST=${SSRF_WHITELIST:-}
- SSRF_WHITELIST=${SSRF_WHITELIST:-searxng}
- CONCURRENCY_POOL_SIZE=${CONCURRENCY_POOL_SIZE:-5}
- JWT_SECRET=${JWT_SECRET:-}
# Crypto: 主密钥和盐值,用于 AppSecret 等敏感字段的 AES-256 加密
@@ -238,6 +238,30 @@ services:
networks:
- WeKnora-network
searxng:
image: searxng/searxng:latest
container_name: WeKnora-searxng
ports:
- "${SEARXNG_PORT:-8888}:8080"
volumes:
- ./docker/searxng/settings.yml:/etc/searxng/settings.yml:rw
environment:
- SEARXNG_BASE_URL=http://localhost:${SEARXNG_PORT:-8888}/
- INSTANCE_NAME=weknora-searxng
- SEARXNG_SECRET=${SEARXNG_SECRET:-please-change-me-to-a-long-random-string}
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
restart: unless-stopped
networks:
- WeKnora-network
profiles:
- searxng
- full
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
container_name: WeKnora-minio
+49
View File
@@ -0,0 +1,49 @@
use_default_settings: true
general:
instance_name: "WeKnora SearXNG"
privacypolicy_url: false
donation_url: false
contact_url: false
enable_metrics: false
server:
# Override via env SEARXNG_SECRET; the entrypoint script substitutes ultrasecretkey.
secret_key: "ultrasecretkey"
base_url: false
image_proxy: true
http_protocol_version: "1.0"
method: "GET"
# Disabled so WeKnora's backend can call /search without IP-based throttling.
# Re-enable and add WeKnora's egress IP to a limiter allowlist if you expose
# this instance to the public internet.
limiter: false
public_instance: false
ui:
static_use_hash: true
default_locale: ""
query_in_title: false
infinite_scroll: false
center_alignment: false
theme_args:
simple_style: auto
search_on_category_select: true
hotkeys: default
search:
safe_search: 0
autocomplete: ""
default_lang: "auto"
ban_time_on_fail: 5
max_ban_time_on_fail: 120
formats:
- html
- json
outgoing:
request_timeout: 6.0
max_request_timeout: 10.0
pool_connections: 100
pool_maxsize: 20
enable_http2: true
+3 -1
View File
@@ -5,11 +5,12 @@ export interface WebSearchProviderEntity {
id?: string
tenant_id?: number
name: string
provider: 'bing' | 'google' | 'duckduckgo' | 'tavily' | 'ollama' | 'baidu'
provider: 'bing' | 'google' | 'duckduckgo' | 'tavily' | 'ollama' | 'baidu' | 'searxng'
description?: string
parameters: {
api_key?: string
engine_id?: string
base_url?: string
proxy_url?: string
extra_config?: Record<string, string>
}
@@ -24,6 +25,7 @@ export interface WebSearchProviderTypeInfo {
name: string
requires_api_key: boolean
requires_engine_id?: boolean
requires_base_url?: boolean
supports_proxy?: boolean
description?: string
docs_url?: string
@@ -82,7 +82,7 @@
<t-input v-model="providerForm.description" :placeholder="t('webSearchSettings.providerDescPlaceholder')" />
</t-form-item>
<template v-if="selectedProviderType?.requires_api_key || selectedProviderType?.requires_engine_id">
<template v-if="selectedProviderType?.requires_api_key || selectedProviderType?.requires_engine_id || selectedProviderType?.requires_base_url">
<div class="form-divider"></div>
<div class="credentials-hint" v-if="selectedProviderType?.docs_url">
@@ -92,6 +92,12 @@
</a>
</div>
<t-form-item v-if="selectedProviderType?.requires_base_url" label="Instance URL" name="parameters.base_url">
<t-input
v-model="providerForm.parameters.base_url"
placeholder="https://searxng.example.com"
/>
</t-form-item>
<t-form-item v-if="selectedProviderType?.requires_api_key" :label="t('webSearchSettings.apiKeyLabel')" name="parameters.api_key">
<t-input
v-model="providerForm.parameters.api_key"
@@ -177,7 +183,7 @@ const providerForm = ref<{
name: string
provider: string
description: string
parameters: { api_key?: string; engine_id?: string; proxy_url?: string }
parameters: { api_key?: string; engine_id?: string; base_url?: string; proxy_url?: string }
is_default: boolean
}>({
name: '',
@@ -193,7 +199,7 @@ const selectedProviderType = computed(() => {
})
const isProviderFree = (providerType: WebSearchProviderTypeInfo) => {
return !providerType.requires_api_key && !providerType.requires_engine_id
return !providerType.requires_api_key && !providerType.requires_engine_id && !providerType.requires_base_url
}
const isEntityFree = (entity: WebSearchProviderEntity) => {
@@ -246,6 +252,7 @@ const editProvider = (entity: WebSearchProviderEntity) => {
parameters: {
api_key: '',
engine_id: entity.parameters?.engine_id || '',
base_url: entity.parameters?.base_url || '',
proxy_url: entity.parameters?.proxy_url || '',
},
is_default: entity.is_default || false,
@@ -85,7 +85,8 @@ func isValidProviderType(provider types.WebSearchProviderType) bool {
types.WebSearchProviderTypeDuckDuckGo,
types.WebSearchProviderTypeTavily,
types.WebSearchProviderTypeOllama,
types.WebSearchProviderTypeBaidu:
types.WebSearchProviderTypeBaidu,
types.WebSearchProviderTypeSearxng:
return true
default:
return false
@@ -120,6 +121,13 @@ func validateProviderParameters(provider types.WebSearchProviderType, params typ
}
case types.WebSearchProviderTypeDuckDuckGo:
// No API key required
case types.WebSearchProviderTypeSearxng:
if params.BaseURL == "" {
return fmt.Errorf("base_url is required for SearXNG provider")
}
if err := infra_web_search.ValidateProxyURL(params.BaseURL); err != nil {
return fmt.Errorf("invalid SearXNG base_url: %w", err)
}
}
if err := validateOptionalProxyURL(params.ProxyURL); err != nil {
return err
+1
View File
@@ -1252,6 +1252,7 @@ func registerWebSearchProviders(registry *infra_web_search.Registry) {
registry.Register("tavily", infra_web_search.NewTavilyProvider)
registry.Register("ollama", infra_web_search.NewOllamaProvider)
registry.Register("baidu", infra_web_search.NewBaiduProvider)
registry.Register("searxng", infra_web_search.NewSearxngProvider)
}
// registerIMAdapterFactories registers adapter factories for each IM platform
@@ -0,0 +1,152 @@
package web_search
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/Tencent/WeKnora/internal/utils"
)
const defaultSearxngTimeout = 15 * time.Second
// SearxngProvider implements web search using a self-hosted SearXNG instance.
//
// Unlike commercial providers, SearXNG is self-hosted, so the instance URL is
// supplied by the tenant via WebSearchProviderParameters.BaseURL. The URL is
// validated with utils.ValidateURLForSSRF; private/loopback hosts must be added
// to the SSRF_WHITELIST environment variable.
//
// API key is optional and only required when the SearXNG instance enables it
// (server.secret_key with limiter / json format restrictions).
type SearxngProvider struct {
client *http.Client
baseURL string
apiKey string
}
// NewSearxngProvider builds a SearXNG provider from tenant parameters.
func NewSearxngProvider(params types.WebSearchProviderParameters) (interfaces.WebSearchProvider, error) {
base := strings.TrimSpace(params.BaseURL)
if base == "" {
return nil, fmt.Errorf("base_url is required for SearXNG provider")
}
if err := utils.ValidateURLForSSRF(base); err != nil {
return nil, fmt.Errorf("invalid SearXNG base_url: %w", err)
}
parsed, err := url.Parse(base)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, fmt.Errorf("invalid SearXNG base_url: must be an absolute http(s) URL")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("invalid SearXNG base_url scheme: %s", parsed.Scheme)
}
client, err := NewSearchHTTPClient(defaultSearxngTimeout, params.ProxyURL)
if err != nil {
return nil, err
}
return &SearxngProvider{
client: client,
baseURL: strings.TrimRight(base, "/"),
apiKey: strings.TrimSpace(params.APIKey),
}, nil
}
// Name returns the provider name.
func (p *SearxngProvider) Name() string { return "searxng" }
// Search performs a metasearch query against the configured SearXNG instance.
// SearXNG must have `search.formats: [json]` enabled in settings.yml.
func (p *SearxngProvider) Search(
ctx context.Context,
query string,
maxResults int,
includeDate bool,
) ([]*types.WebSearchResult, error) {
if strings.TrimSpace(query) == "" {
return nil, fmt.Errorf("query is empty")
}
if maxResults <= 0 {
maxResults = 5
}
q := url.Values{}
q.Set("q", query)
q.Set("format", "json")
q.Set("safesearch", "1")
q.Set("language", "auto")
reqURL := p.baseURL + "/search?" + q.Encode()
logger.Infof(ctx, "[WebSearch][SearXNG] query=%q maxResults=%d url=%s", query, maxResults, p.baseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "WeKnora/1.0")
if p.apiKey != "" {
// SearXNG does not have a built-in auth header; the api_key field is reused
// to support reverse-proxies that gate access via Authorization.
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("searxng returned status %d: %s", resp.StatusCode, string(body))
}
var data searxngResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("failed to decode SearXNG response (ensure JSON format is enabled in settings.yml): %w", err)
}
results := make([]*types.WebSearchResult, 0, maxResults)
for _, r := range data.Results {
if len(results) >= maxResults {
break
}
if r.URL == "" || r.Title == "" {
continue
}
item := &types.WebSearchResult{
Title: r.Title,
URL: r.URL,
Snippet: r.Content,
Source: "searxng",
}
if includeDate && r.PublishedDate != "" {
if t, err := time.Parse(time.RFC3339, r.PublishedDate); err == nil {
item.PublishedAt = &t
}
}
results = append(results, item)
}
logger.Infof(ctx, "[WebSearch][SearXNG] returned %d results", len(results))
return results, nil
}
type searxngResponse struct {
Query string `json:"query"`
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
PublishedDate string `json:"publishedDate,omitempty"`
} `json:"results"`
}
+15
View File
@@ -21,6 +21,7 @@ const (
WebSearchProviderTypeTavily WebSearchProviderType = "tavily"
WebSearchProviderTypeOllama WebSearchProviderType = "ollama"
WebSearchProviderTypeBaidu WebSearchProviderType = "baidu"
WebSearchProviderTypeSearxng WebSearchProviderType = "searxng"
)
// WebSearchProviderEntity represents a configured web search provider instance for a tenant.
@@ -71,6 +72,9 @@ type WebSearchProviderParameters struct {
APIKey string `yaml:"api_key" json:"api_key,omitempty"`
// Google Custom Search Engine ID (only for Google provider)
EngineID string `yaml:"engine_id" json:"engine_id,omitempty"`
// Base URL for self-hosted search engines (e.g. SearXNG instance URL).
// Validated with utils.ValidateURLForSSRF; private hosts must be added to SSRF_WHITELIST.
BaseURL string `yaml:"base_url" json:"base_url,omitempty"`
// Optional HTTP/HTTPS proxy URL for outbound search requests (e.g. http://host:port); validated with utils.ValidateURLForSSRF.
// Does not replace the search API endpoint; only tunnels traffic to the official APIs.
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
@@ -121,6 +125,8 @@ type WebSearchProviderTypeInfo struct {
RequiresAPIKey bool `json:"requires_api_key"`
// Whether the provider requires an engine ID (e.g., Google CSE)
RequiresEngineID bool `json:"requires_engine_id"`
// Whether the provider requires a user-supplied base URL (e.g., self-hosted SearXNG instance)
RequiresBaseURL bool `json:"requires_base_url"`
// Whether optional proxy_url in parameters is honored for outbound requests
SupportsProxy bool `json:"supports_proxy"`
// Description
@@ -172,6 +178,15 @@ func GetWebSearchProviderTypes() []WebSearchProviderTypeInfo {
Description: "Ollama Cloud web search (requires Ollama API key)",
DocsURL: "https://docs.ollama.com/capabilities/web-search",
},
{
ID: "searxng",
Name: "SearXNG",
RequiresAPIKey: false,
RequiresBaseURL: true,
SupportsProxy: true,
Description: "Self-hosted SearXNG metasearch instance (provide instance URL; private hosts must be SSRF-whitelisted)",
DocsURL: "https://docs.searxng.org/",
},
{
ID: "baidu",
Name: "Baidu",