searxng 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -1
- data/README.md +84 -40
- data/bin/searxng +7 -4
- data/lib/searxng/client.rb +83 -9
- data/lib/searxng/errors.rb +64 -0
- data/lib/searxng/network_policy.rb +74 -0
- data/lib/searxng/server.rb +793 -2
- data/lib/searxng/version.rb +1 -1
- data/sig/searxng.rbs +11 -1
- metadata +18 -3
data/lib/searxng/server.rb
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
require "fast_mcp"
|
|
2
2
|
require "searxng"
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "open3"
|
|
7
|
+
require "socket"
|
|
8
|
+
require "time"
|
|
9
|
+
require "timeout"
|
|
10
|
+
require "uri"
|
|
11
|
+
require "searxng/network_policy"
|
|
3
12
|
|
|
4
13
|
FastMcp = MCP unless defined?(FastMcp)
|
|
5
14
|
|
|
@@ -53,15 +62,47 @@ module Searxng
|
|
|
53
62
|
end
|
|
54
63
|
|
|
55
64
|
def self.start
|
|
65
|
+
validate_environment!
|
|
56
66
|
server = FastMcp::Server.new(
|
|
57
67
|
name: "searxng",
|
|
58
68
|
version: Searxng::VERSION,
|
|
59
69
|
logger: NullLogger.new
|
|
60
70
|
)
|
|
61
|
-
server
|
|
71
|
+
register_tools(server)
|
|
72
|
+
register_resources(server)
|
|
62
73
|
server.start
|
|
63
74
|
end
|
|
64
75
|
|
|
76
|
+
def self.register_tools(server)
|
|
77
|
+
server.register_tool(SearxngWebSearchTool)
|
|
78
|
+
server.register_tool(WebUrlReadTool)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.register_resources(server)
|
|
82
|
+
server.register_resource(ServerConfigResource)
|
|
83
|
+
server.register_resource(UsageGuideResource)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.validate_environment!
|
|
87
|
+
searxng_url = ENV["SEARXNG_URL"]
|
|
88
|
+
if searxng_url.nil? || searxng_url.strip.empty?
|
|
89
|
+
raise ConfigurationError, ErrorMessages.configuration_missing_url
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
uri = URI.parse(searxng_url)
|
|
93
|
+
unless %w[http https].include?(uri.scheme)
|
|
94
|
+
raise ConfigurationError, ErrorMessages.configuration_invalid_protocol(uri.scheme)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
user = ENV["SEARXNG_USER"] || ENV["AUTH_USERNAME"]
|
|
98
|
+
password = ENV["SEARXNG_PASSWORD"] || ENV["AUTH_PASSWORD"]
|
|
99
|
+
if (user && !password) || (!user && password)
|
|
100
|
+
raise ConfigurationError, ErrorMessages.configuration_auth_pair
|
|
101
|
+
end
|
|
102
|
+
rescue URI::InvalidURIError
|
|
103
|
+
raise ConfigurationError, ErrorMessages.configuration_invalid_url(searxng_url)
|
|
104
|
+
end
|
|
105
|
+
|
|
65
106
|
class BaseTool < FastMcp::Tool
|
|
66
107
|
protected
|
|
67
108
|
|
|
@@ -73,6 +114,7 @@ module Searxng
|
|
|
73
114
|
class SearxngWebSearchTool < BaseTool
|
|
74
115
|
tool_name "searxng_web_search"
|
|
75
116
|
description "Performs a web search using the SearXNG API. Use this to find information on the web. Aggregates results from multiple search engines."
|
|
117
|
+
annotations(readOnlyHint: true, openWorldHint: true) if respond_to?(:annotations)
|
|
76
118
|
|
|
77
119
|
arguments do
|
|
78
120
|
required(:query).filled(:string).description("The search query")
|
|
@@ -98,6 +140,7 @@ module Searxng
|
|
|
98
140
|
|
|
99
141
|
def format_search_result(data, max_results: 10, pageno: 1)
|
|
100
142
|
out = []
|
|
143
|
+
query = data[:query].to_s
|
|
101
144
|
data[:infoboxes]&.each do |ib|
|
|
102
145
|
out << "Infobox: #{ib[:infobox]}"
|
|
103
146
|
out << "ID: #{ib[:id]}"
|
|
@@ -107,7 +150,7 @@ module Searxng
|
|
|
107
150
|
results = data[:results] || []
|
|
108
151
|
total = data[:number_of_results]
|
|
109
152
|
if results.empty?
|
|
110
|
-
out << "
|
|
153
|
+
out << ErrorMessages.no_results(query.empty? ? "your query" : query)
|
|
111
154
|
else
|
|
112
155
|
limit = [max_results.to_i, 1].max
|
|
113
156
|
shown = results.first(limit)
|
|
@@ -127,5 +170,753 @@ module Searxng
|
|
|
127
170
|
out.join("\n").strip
|
|
128
171
|
end
|
|
129
172
|
end
|
|
173
|
+
|
|
174
|
+
class WebUrlReadTool < BaseTool
|
|
175
|
+
tool_name "web_url_read"
|
|
176
|
+
description "Reads a URL and converts HTML/XML content into Markdown. Supports character pagination, section extraction, paragraph ranges, and heading-only mode."
|
|
177
|
+
annotations(readOnlyHint: true, openWorldHint: true) if respond_to?(:annotations)
|
|
178
|
+
|
|
179
|
+
CACHE_TTL_SECONDS = 300
|
|
180
|
+
CACHE_MAX_ENTRIES = 32
|
|
181
|
+
CACHE_MAX_BYTES = 8 * 1024 * 1024
|
|
182
|
+
MAX_FETCH_BYTES = 2 * 1024 * 1024
|
|
183
|
+
DEFAULT_MAX_LENGTH = 20_000
|
|
184
|
+
MIN_TIMEOUT_MS = 1_000
|
|
185
|
+
MAX_TIMEOUT_MS = 30_000
|
|
186
|
+
DEFAULT_URL_SCHEMES = %w[http https ipfs].freeze
|
|
187
|
+
SUPPORTED_URL_SCHEMES = %w[http https ftp sftp smb gemini ipfs].freeze
|
|
188
|
+
@@url_cache = {} # rubocop:disable Style/ClassVars
|
|
189
|
+
@@url_cache_bytes = 0 # rubocop:disable Style/ClassVars
|
|
190
|
+
@@url_cache_mutex = Mutex.new # rubocop:disable Style/ClassVars
|
|
191
|
+
|
|
192
|
+
arguments do
|
|
193
|
+
required(:url).filled(:string).description("URL to fetch and read")
|
|
194
|
+
optional(:startChar).filled(:integer).description("Starting character position (default: 0)")
|
|
195
|
+
optional(:maxLength).filled(:integer).description("Maximum characters to return (default: 20000)")
|
|
196
|
+
optional(:section).filled(:string).description("Extract by heading text")
|
|
197
|
+
optional(:paragraphRange).filled(:string).description("Paragraph range, e.g. '1-5', '3', '10-'")
|
|
198
|
+
optional(:readHeadings).filled(:bool).description("Return only headings when true")
|
|
199
|
+
optional(:timeoutMs).filled(:integer).description("Request timeout in ms (default: 10000, clamped 1000..30000)")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def call(url:, **kwargs)
|
|
203
|
+
start_char = kwargs.fetch(:startChar, 0)
|
|
204
|
+
max_length = kwargs.fetch(:maxLength, DEFAULT_MAX_LENGTH)
|
|
205
|
+
section = kwargs[:section]
|
|
206
|
+
paragraph_range = kwargs[:paragraphRange]
|
|
207
|
+
read_headings = kwargs.fetch(:readHeadings, false)
|
|
208
|
+
timeout_ms = clamp_timeout_ms(kwargs.fetch(:timeoutMs, 10_000))
|
|
209
|
+
|
|
210
|
+
normalized = normalize_url(url)
|
|
211
|
+
uri = URI.parse(normalized)
|
|
212
|
+
ensure_scheme_enabled!(uri.scheme)
|
|
213
|
+
unless uri.scheme == "ipfs"
|
|
214
|
+
_validated_uri, ip_address = NetworkPolicy.validate!(
|
|
215
|
+
normalized,
|
|
216
|
+
allowed_schemes: [uri.scheme],
|
|
217
|
+
allow_userinfo: !%w[http https].include?(uri.scheme)
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
markdown = case uri.scheme
|
|
221
|
+
when "http", "https"
|
|
222
|
+
html = fetch_html(normalized, timeout_ms: timeout_ms)
|
|
223
|
+
convert_to_markdown(html, redact_uri_string(normalized))
|
|
224
|
+
when "ftp"
|
|
225
|
+
content = fetch_ftp(uri, ip_address: ip_address, timeout_ms: timeout_ms)
|
|
226
|
+
convert_fetched_content(content, redact_uri_string(normalized))
|
|
227
|
+
when "sftp"
|
|
228
|
+
content = fetch_sftp(uri, ip_address: ip_address, timeout_ms: timeout_ms)
|
|
229
|
+
convert_fetched_content(content, redact_uri_string(normalized))
|
|
230
|
+
when "smb"
|
|
231
|
+
content = fetch_smb(uri, ip_address: ip_address, timeout_ms: timeout_ms)
|
|
232
|
+
convert_fetched_content(content, redact_uri_string(normalized))
|
|
233
|
+
when "gemini"
|
|
234
|
+
gemtext = fetch_gemini(uri, ip_address: ip_address, timeout_ms: timeout_ms)
|
|
235
|
+
gemtext_to_markdown(gemtext, uri)
|
|
236
|
+
when "ipfs"
|
|
237
|
+
gateway_url = resolve_ipfs_url(uri)
|
|
238
|
+
html = fetch_html(gateway_url, timeout_ms: timeout_ms)
|
|
239
|
+
convert_to_markdown(html, gateway_url)
|
|
240
|
+
else
|
|
241
|
+
raise ConfigurationError, %(Unsupported URL scheme "#{uri.scheme}". Supported schemes: http, https, ftp, sftp, smb, gemini, ipfs.)
|
|
242
|
+
end
|
|
243
|
+
apply_options(markdown, start_char: start_char, max_length: max_length, section: section, paragraph_range: paragraph_range, read_headings: read_headings)
|
|
244
|
+
rescue Searxng::Error => e
|
|
245
|
+
"Error: #{e.message}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
def normalize_url(url)
|
|
251
|
+
raw = url.to_s.strip
|
|
252
|
+
uri = URI.parse(raw)
|
|
253
|
+
if uri.scheme
|
|
254
|
+
scheme = uri.scheme.downcase
|
|
255
|
+
return raw if uri.host && %w[http https ftp sftp smb gemini ipfs].include?(scheme)
|
|
256
|
+
|
|
257
|
+
raise ConfigurationError, %(Unsupported URL scheme "#{uri.scheme}". Supported schemes: http, https, ftp, sftp, smb, gemini, ipfs.)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
candidate = "https://#{raw}"
|
|
261
|
+
parsed = URI.parse(candidate)
|
|
262
|
+
return candidate if parsed.host
|
|
263
|
+
|
|
264
|
+
raise ArgumentError
|
|
265
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
266
|
+
raise ConfigurationError, %(URL Format Error: Invalid URL "#{url}")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def ensure_scheme_enabled!(scheme)
|
|
270
|
+
return if enabled_url_schemes.include?(scheme)
|
|
271
|
+
|
|
272
|
+
raise ConfigurationError, %(URL scheme "#{scheme}" is not enabled)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def enabled_url_schemes
|
|
276
|
+
configured = ENV["SEARXNG_WEB_URL_SCHEMES"]
|
|
277
|
+
return DEFAULT_URL_SCHEMES unless configured
|
|
278
|
+
|
|
279
|
+
configured.split(",").map { |scheme| scheme.strip.downcase } & SUPPORTED_URL_SCHEMES
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def clamp_timeout_ms(value)
|
|
283
|
+
value.to_i.clamp(MIN_TIMEOUT_MS, MAX_TIMEOUT_MS)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def redact_uri_string(url)
|
|
287
|
+
uri = url.is_a?(URI) ? url.dup : URI.parse(url.to_s)
|
|
288
|
+
uri.user = nil
|
|
289
|
+
uri.password = nil
|
|
290
|
+
uri.to_s
|
|
291
|
+
rescue URI::InvalidURIError
|
|
292
|
+
"(invalid URL)"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def fetch_html(url, timeout_ms:)
|
|
296
|
+
now = Time.now.to_i
|
|
297
|
+
cached = cache_get(url, now: now)
|
|
298
|
+
return cached if cached
|
|
299
|
+
|
|
300
|
+
uri, ip_address = NetworkPolicy.validate!(url, allowed_schemes: %w[http https])
|
|
301
|
+
client = get_client
|
|
302
|
+
http = build_fetch_http(client, uri, ip_address, timeout_ms: timeout_ms)
|
|
303
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
304
|
+
request["Host"] = uri.hostname || uri.host
|
|
305
|
+
request["User-Agent"] = ENV["SEARXNG_USER_AGENT"].to_s.strip.empty? ? "searxng-ruby/#{Searxng::VERSION} web_url_read" : ENV["SEARXNG_USER_AGENT"]
|
|
306
|
+
request["Accept-Encoding"] = "identity"
|
|
307
|
+
safe_url = redact_uri_string(url)
|
|
308
|
+
body = nil
|
|
309
|
+
status_code = nil
|
|
310
|
+
status_message = nil
|
|
311
|
+
location = nil
|
|
312
|
+
Timeout.timeout(timeout_ms / 1000.0) do
|
|
313
|
+
http.request(request) do |response|
|
|
314
|
+
status_code = response.code.to_i
|
|
315
|
+
status_message = response.message
|
|
316
|
+
location = response["location"]
|
|
317
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
318
|
+
read_limited_body(response)
|
|
319
|
+
raise APIError.new(
|
|
320
|
+
ErrorMessages.fetch_http_error(status_code, status_message, location: location),
|
|
321
|
+
status_code: status_code,
|
|
322
|
+
uri: safe_url
|
|
323
|
+
)
|
|
324
|
+
end
|
|
325
|
+
body = read_limited_body(response)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
if body.to_s.strip.empty?
|
|
329
|
+
raise APIError.new("Content Error: Website returned empty content.", status_code: status_code, uri: safe_url)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
cache_put(url, body, at: now)
|
|
333
|
+
body
|
|
334
|
+
rescue Timeout::Error
|
|
335
|
+
raise NetworkError, "Timeout Error: #{URI.parse(url).host} took longer than #{timeout_ms}ms to respond"
|
|
336
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError, EOFError => e
|
|
337
|
+
raise NetworkError, ErrorMessages.network_error(e, uri: URI.parse(url), target: "website")
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def build_fetch_http(client, uri, ip_address, timeout_ms:)
|
|
341
|
+
proxy_uri = client.send(:proxy_uri_for, uri)
|
|
342
|
+
seconds = [timeout_ms / 1000.0, 1.0].max
|
|
343
|
+
if proxy_uri && uri.scheme == "http"
|
|
344
|
+
http = Net::HTTP::Proxy(proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password)
|
|
345
|
+
.new(ip_address, uri.port)
|
|
346
|
+
http.open_timeout = seconds
|
|
347
|
+
http.read_timeout = seconds
|
|
348
|
+
http.max_retries = 0 if http.respond_to?(:max_retries=)
|
|
349
|
+
return http
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
http = client.send(:build_http, uri)
|
|
353
|
+
http.ipaddr = ip_address
|
|
354
|
+
http.open_timeout = seconds
|
|
355
|
+
http.read_timeout = seconds
|
|
356
|
+
http.max_retries = 0 if http.respond_to?(:max_retries=)
|
|
357
|
+
http
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def read_limited_body(response)
|
|
361
|
+
body = +""
|
|
362
|
+
response.read_body do |chunk|
|
|
363
|
+
body << chunk
|
|
364
|
+
if body.bytesize > MAX_FETCH_BYTES
|
|
365
|
+
raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: nil)
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
body
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def cache_get(url, now:)
|
|
372
|
+
@@url_cache_mutex.synchronize do
|
|
373
|
+
purge_expired_cache!(now)
|
|
374
|
+
entry = @@url_cache[url]
|
|
375
|
+
(entry && (now - entry[:at] <= CACHE_TTL_SECONDS)) ? entry[:html] : nil
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def cache_put(url, html, at:)
|
|
380
|
+
@@url_cache_mutex.synchronize do
|
|
381
|
+
purge_expired_cache!(at)
|
|
382
|
+
while (@@url_cache.size >= CACHE_MAX_ENTRIES || (@@url_cache_bytes + html.bytesize) > CACHE_MAX_BYTES) && !@@url_cache.empty?
|
|
383
|
+
evict_oldest_cache_entry!
|
|
384
|
+
end
|
|
385
|
+
return if html.bytesize > CACHE_MAX_BYTES
|
|
386
|
+
|
|
387
|
+
previous = @@url_cache[url]
|
|
388
|
+
@@url_cache_bytes -= previous[:html].bytesize if previous
|
|
389
|
+
@@url_cache[url] = {at: at, html: html}
|
|
390
|
+
@@url_cache_bytes += html.bytesize
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def purge_expired_cache!(now)
|
|
395
|
+
@@url_cache.delete_if do |_key, entry|
|
|
396
|
+
expired = now - entry[:at] > CACHE_TTL_SECONDS
|
|
397
|
+
@@url_cache_bytes -= entry[:html].bytesize if expired
|
|
398
|
+
expired
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def evict_oldest_cache_entry!
|
|
403
|
+
oldest_key, oldest = @@url_cache.min_by { |_key, entry| entry[:at] }
|
|
404
|
+
return unless oldest_key
|
|
405
|
+
|
|
406
|
+
@@url_cache_bytes -= oldest[:html].bytesize
|
|
407
|
+
@@url_cache.delete(oldest_key)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def convert_to_markdown(html, base_url)
|
|
411
|
+
load_teplo_core!
|
|
412
|
+
ast = TeploCore.parse_html(html)
|
|
413
|
+
markdown = TeploCore.ast_to_markdown(ast, base_url)
|
|
414
|
+
if markdown.to_s.strip.empty?
|
|
415
|
+
"Content Warning: Page fetched but appears empty after conversion (#{base_url}). May contain only media or require JavaScript."
|
|
416
|
+
else
|
|
417
|
+
markdown
|
|
418
|
+
end
|
|
419
|
+
rescue Searxng::Error
|
|
420
|
+
raise
|
|
421
|
+
rescue => e
|
|
422
|
+
raise APIError.new("Conversion Error: Cannot convert HTML to Markdown (#{base_url}) - #{e.message}", uri: base_url)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def convert_fetched_content(content, base_url)
|
|
426
|
+
text = content.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
|
|
427
|
+
return text if text.strip.empty?
|
|
428
|
+
|
|
429
|
+
looks_like_markup = text.match?(/\A\s*<(?:!doctype|html|body|xml|rss|feed|svg|\?xml)/i)
|
|
430
|
+
looks_like_markup ? convert_to_markdown(text, base_url) : text
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def fetch_ftp(uri, timeout_ms:, ip_address: nil)
|
|
434
|
+
begin
|
|
435
|
+
require "net/ftp"
|
|
436
|
+
rescue LoadError
|
|
437
|
+
raise ConfigurationError, "FTP support requires the 'net-ftp' gem."
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
ip_address ||= validated_ip_address(uri, allow_userinfo: true)
|
|
441
|
+
body = +""
|
|
442
|
+
ftp = nil
|
|
443
|
+
safe_uri = redact_uri_string(uri)
|
|
444
|
+
Timeout.timeout(timeout_ms / 1000.0) do
|
|
445
|
+
ftp = Net::FTP.new
|
|
446
|
+
ftp.passive = true
|
|
447
|
+
ftp.use_pasv_ip = false if ftp.respond_to?(:use_pasv_ip=)
|
|
448
|
+
begin
|
|
449
|
+
ftp.connect(ip_address, uri.port || 21)
|
|
450
|
+
ftp.read_timeout = [timeout_ms / 1000.0, 1.0].max
|
|
451
|
+
ftp.open_timeout = [timeout_ms / 1000.0, 1.0].max
|
|
452
|
+
user = uri.user || credential_for(uri.host, "FTP_USER") || "anonymous"
|
|
453
|
+
pass = uri.password || credential_for(uri.host, "FTP_PASSWORD") || "anonymous@"
|
|
454
|
+
ftp.login(user, pass)
|
|
455
|
+
path = URI.decode_www_form_component(uri.path.to_s)
|
|
456
|
+
raise APIError.new("FTP path is required", uri: safe_uri) if path.empty? || path == "/"
|
|
457
|
+
raise ConfigurationError, "FTP path contains unsupported characters" if path.match?(/[\r\n\x00]/)
|
|
458
|
+
|
|
459
|
+
ftp.retrbinary("RETR #{path}", 16_384) do |chunk|
|
|
460
|
+
body << chunk
|
|
461
|
+
if body.bytesize > MAX_FETCH_BYTES
|
|
462
|
+
raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: safe_uri)
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
ensure
|
|
466
|
+
ftp&.close unless ftp.nil? || ftp.closed?
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
body
|
|
470
|
+
rescue Timeout::Error
|
|
471
|
+
raise NetworkError, "Timeout Error: #{uri.host} took longer than #{timeout_ms}ms to respond"
|
|
472
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE, EOFError => e
|
|
473
|
+
raise NetworkError, ErrorMessages.network_error(e, uri: uri, target: "FTP server")
|
|
474
|
+
rescue Searxng::Error
|
|
475
|
+
raise
|
|
476
|
+
rescue => e
|
|
477
|
+
if e.class.name.start_with?("Net::FTP")
|
|
478
|
+
raise APIError.new("FTP Error: #{e.message}", uri: redact_uri_string(uri))
|
|
479
|
+
end
|
|
480
|
+
raise e
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def fetch_sftp(uri, timeout_ms:, ip_address: nil)
|
|
484
|
+
begin
|
|
485
|
+
require "net/sftp"
|
|
486
|
+
rescue LoadError
|
|
487
|
+
raise ConfigurationError, "SFTP support requires the 'net-sftp' gem."
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
ip_address ||= validated_ip_address(uri, allow_userinfo: true)
|
|
491
|
+
user = uri.user || credential_for(uri.host, "SFTP_USER")
|
|
492
|
+
password = uri.password || credential_for(uri.host, "SFTP_PASSWORD")
|
|
493
|
+
raise ConfigurationError, "SFTP requires username (sftp://user@host/path or SFTP_USER)." unless user
|
|
494
|
+
|
|
495
|
+
path = URI.decode_www_form_component(uri.path.to_s)
|
|
496
|
+
safe_uri = redact_uri_string(uri)
|
|
497
|
+
raise APIError.new("SFTP path is required", uri: safe_uri) if path.empty? || path == "/"
|
|
498
|
+
raise ConfigurationError, "SFTP path contains unsupported characters" if path.match?(/[\r\n\x00]/)
|
|
499
|
+
|
|
500
|
+
result = nil
|
|
501
|
+
Timeout.timeout(timeout_ms / 1000.0) do
|
|
502
|
+
options = {password: password, port: uri.port || 22, non_interactive: true, verify_host_key: :always}
|
|
503
|
+
options[:host_key_alias] = uri.host if uri.host
|
|
504
|
+
Net::SFTP.start(ip_address, user, **options) do |sftp|
|
|
505
|
+
result = sftp.download!(path).to_s
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
if result.to_s.bytesize > MAX_FETCH_BYTES
|
|
509
|
+
raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: safe_uri)
|
|
510
|
+
end
|
|
511
|
+
result.to_s
|
|
512
|
+
rescue Timeout::Error
|
|
513
|
+
raise NetworkError, "Timeout Error: #{uri.host} took longer than #{timeout_ms}ms to respond"
|
|
514
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE, EOFError => e
|
|
515
|
+
raise NetworkError, ErrorMessages.network_error(e, uri: uri, target: "SFTP server")
|
|
516
|
+
rescue Searxng::Error
|
|
517
|
+
raise
|
|
518
|
+
rescue => e
|
|
519
|
+
raise e if e.is_a?(ConfigurationError)
|
|
520
|
+
if e.class.name.include?("StatusException")
|
|
521
|
+
description = e.respond_to?(:description) ? e.description : e.message
|
|
522
|
+
raise APIError.new("SFTP Error: #{description}", uri: safe_uri)
|
|
523
|
+
end
|
|
524
|
+
if e.class.name.include?("AuthenticationFailed")
|
|
525
|
+
raise APIError.new("SFTP authentication failed", uri: safe_uri)
|
|
526
|
+
end
|
|
527
|
+
raise APIError.new("SFTP Error: #{e.message}", uri: safe_uri)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def fetch_smb(uri, timeout_ms:, ip_address: nil)
|
|
531
|
+
ip_address ||= validated_ip_address(uri, allow_userinfo: true)
|
|
532
|
+
share, remote_path = parse_smb_path(uri)
|
|
533
|
+
user = uri.user || credential_for(uri.host, "SMB_USER")
|
|
534
|
+
pass = uri.password || credential_for(uri.host, "SMB_PASSWORD")
|
|
535
|
+
domain = credential_for(uri.host, "SMB_DOMAIN")
|
|
536
|
+
|
|
537
|
+
full_user = if user
|
|
538
|
+
(domain && !domain.empty?) ? "#{domain}\\#{user}" : user
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
cmd = ["smbclient", "//#{ip_address}/#{share}", "-c", %(get "#{remote_path}" -), "-t", [(timeout_ms / 1000.0).ceil, 1].max.to_s]
|
|
542
|
+
env = {}
|
|
543
|
+
if full_user
|
|
544
|
+
cmd << "-U" << full_user
|
|
545
|
+
env["PASSWD"] = pass.to_s
|
|
546
|
+
else
|
|
547
|
+
cmd << "-N"
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
stdout = +""
|
|
551
|
+
stderr = +""
|
|
552
|
+
status = nil
|
|
553
|
+
wait_thr = nil
|
|
554
|
+
Timeout.timeout(timeout_ms / 1000.0) do
|
|
555
|
+
Open3.popen3(env, *cmd) do |stdin, out, err, thr|
|
|
556
|
+
stdin.close
|
|
557
|
+
wait_thr = thr
|
|
558
|
+
stdout = out.read.to_s
|
|
559
|
+
stderr = err.read.to_s
|
|
560
|
+
status = thr.value
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
if stdout.bytesize > MAX_FETCH_BYTES
|
|
564
|
+
raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: redact_uri_string(uri))
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
raise APIError.new("SMB Error: #{stderr.strip.empty? ? "request failed" : stderr.strip[0, 500]}", uri: redact_uri_string(uri)) unless status&.success?
|
|
568
|
+
|
|
569
|
+
stdout
|
|
570
|
+
rescue Errno::ENOENT
|
|
571
|
+
raise ConfigurationError, "SMB support requires 'smbclient' to be installed and available in PATH."
|
|
572
|
+
rescue Timeout::Error
|
|
573
|
+
if wait_thr&.alive?
|
|
574
|
+
begin
|
|
575
|
+
Process.kill("TERM", wait_thr.pid)
|
|
576
|
+
rescue Errno::ESRCH
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
raise NetworkError, "Timeout Error: #{uri.host} took longer than #{timeout_ms}ms to respond"
|
|
580
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE, EOFError => e
|
|
581
|
+
raise NetworkError, ErrorMessages.network_error(e, uri: uri, target: "SMB server")
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def parse_smb_path(uri)
|
|
585
|
+
raw_path = uri.path.to_s
|
|
586
|
+
segments = raw_path.split("/").reject(&:empty?).map { |segment| URI.decode_www_form_component(segment) }
|
|
587
|
+
share = segments.shift
|
|
588
|
+
remote = segments.join("/")
|
|
589
|
+
raise APIError.new("SMB path must include share and file (smb://host/share/path/file)", uri: redact_uri_string(uri)) if share.to_s.empty? || remote.empty?
|
|
590
|
+
if share.match?(/[\x00-\x1f\x7f;"\\]/) || remote.match?(/[\x00-\x1f\x7f;"\\]/)
|
|
591
|
+
raise ConfigurationError, "SMB path contains unsupported characters"
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
[share, remote]
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def fetch_gemini(uri, timeout_ms:, ip_address: nil)
|
|
598
|
+
ip_address ||= validated_ip_address(uri)
|
|
599
|
+
response_header = nil
|
|
600
|
+
body = +""
|
|
601
|
+
tcp = nil
|
|
602
|
+
ssl = nil
|
|
603
|
+
safe_uri = redact_uri_string(uri)
|
|
604
|
+
Timeout.timeout(timeout_ms / 1000.0) do
|
|
605
|
+
tcp = TCPSocket.new(ip_address, uri.port || 1965)
|
|
606
|
+
begin
|
|
607
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
608
|
+
if ENV["GEMINI_INSECURE"] == "1"
|
|
609
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
610
|
+
else
|
|
611
|
+
ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
612
|
+
end
|
|
613
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
|
|
614
|
+
ssl.hostname = uri.host if ssl.respond_to?(:hostname=)
|
|
615
|
+
ssl.connect
|
|
616
|
+
ssl.post_connection_check(uri.host) unless ENV["GEMINI_INSECURE"] == "1"
|
|
617
|
+
ssl.write("#{uri}\r\n")
|
|
618
|
+
response_header = ssl.gets("\r\n", 2048)
|
|
619
|
+
while body.bytesize <= MAX_FETCH_BYTES
|
|
620
|
+
chunk = ssl.read(16_384)
|
|
621
|
+
break if chunk.nil? || chunk.empty?
|
|
622
|
+
body << chunk
|
|
623
|
+
end
|
|
624
|
+
raise APIError.new("Content Error: response exceeds #{MAX_FETCH_BYTES} bytes", uri: safe_uri) if body.bytesize > MAX_FETCH_BYTES
|
|
625
|
+
ensure
|
|
626
|
+
ssl&.close
|
|
627
|
+
tcp&.close unless tcp.nil? || tcp.closed?
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
unless response_header
|
|
632
|
+
raise APIError.new("Gemini response error: empty response header", uri: safe_uri)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
status, meta = response_header.strip.split(/\s+/, 2)
|
|
636
|
+
status_code = status.to_i
|
|
637
|
+
case status_code
|
|
638
|
+
when 20..29
|
|
639
|
+
body
|
|
640
|
+
when 30..39
|
|
641
|
+
raise APIError.new("Gemini redirect (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
|
|
642
|
+
when 40..49
|
|
643
|
+
raise APIError.new("Gemini temporary failure (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
|
|
644
|
+
when 50..59
|
|
645
|
+
raise APIError.new("Gemini permanent failure (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
|
|
646
|
+
when 60..69
|
|
647
|
+
raise APIError.new("Gemini certificate required (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
|
|
648
|
+
else
|
|
649
|
+
raise APIError.new("Gemini response error (#{status_code}): #{meta}", status_code: status_code, uri: safe_uri)
|
|
650
|
+
end
|
|
651
|
+
rescue Timeout::Error
|
|
652
|
+
raise NetworkError, "Timeout Error: #{uri.host} took longer than #{timeout_ms}ms to respond"
|
|
653
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError, EOFError => e
|
|
654
|
+
raise NetworkError, ErrorMessages.network_error(e, uri: uri, target: "Gemini server")
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def gemtext_to_markdown(gemtext, base_uri)
|
|
658
|
+
lines = gemtext.to_s.split("\n")
|
|
659
|
+
out = lines.map do |line|
|
|
660
|
+
if line.start_with?("=>")
|
|
661
|
+
target, *label_parts = line.sub(/\A=>\s*/, "").split(/\s+/)
|
|
662
|
+
label = label_parts.join(" ").strip
|
|
663
|
+
next "" if target.to_s.empty?
|
|
664
|
+
resolved = resolve_relative_uri(base_uri, target)
|
|
665
|
+
display = label.empty? ? resolved : label
|
|
666
|
+
"- [#{display}](#{resolved})"
|
|
667
|
+
elsif line.start_with?("### ")
|
|
668
|
+
"### #{line[4..].to_s.strip}"
|
|
669
|
+
elsif line.start_with?("## ")
|
|
670
|
+
"## #{line[3..].to_s.strip}"
|
|
671
|
+
elsif line.start_with?("# ")
|
|
672
|
+
"# #{line[2..].to_s.strip}"
|
|
673
|
+
elsif line.start_with?("> ")
|
|
674
|
+
"> #{line[2..]}"
|
|
675
|
+
else
|
|
676
|
+
line
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
out.join("\n")
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def resolve_relative_uri(base_uri, target)
|
|
683
|
+
target_uri = URI.parse(target)
|
|
684
|
+
return target if target_uri.scheme
|
|
685
|
+
|
|
686
|
+
URI.join(base_uri.to_s, target).to_s
|
|
687
|
+
rescue URI::Error
|
|
688
|
+
target
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def resolve_ipfs_url(uri)
|
|
692
|
+
gateway = ENV["IPFS_GATEWAY"] || "https://ipfs.io"
|
|
693
|
+
gateway = gateway.sub(%r{/+\z}, "")
|
|
694
|
+
parsed_gateway = URI.parse(gateway)
|
|
695
|
+
unless %w[http https].include?(parsed_gateway.scheme)
|
|
696
|
+
raise ConfigurationError, "IPFS_GATEWAY must use http or https, got: #{parsed_gateway.scheme.inspect}"
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
cid = uri.host.to_s
|
|
700
|
+
unless cid.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
701
|
+
raise ConfigurationError, "IPFS CID is invalid"
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
path = uri.path.to_s
|
|
705
|
+
segments = path.split("/").reject(&:empty?)
|
|
706
|
+
segments.each do |segment|
|
|
707
|
+
decoded = URI.decode_www_form_component(segment)
|
|
708
|
+
if decoded == "." || decoded == ".." || decoded.include?("\0")
|
|
709
|
+
raise ConfigurationError, "IPFS path is not allowed"
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
query = uri.query ? "?#{uri.query}" : ""
|
|
714
|
+
"#{gateway}/ipfs/#{cid}#{path}#{query}"
|
|
715
|
+
rescue URI::InvalidURIError
|
|
716
|
+
raise ConfigurationError, "IPFS_GATEWAY has invalid format: #{gateway.inspect}"
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def credential_for(host, variable)
|
|
720
|
+
allowed_hosts = ENV["SEARXNG_WEB_URL_CREDENTIAL_HOSTS"].to_s
|
|
721
|
+
.split(",")
|
|
722
|
+
.map { |candidate| candidate.strip.downcase }
|
|
723
|
+
ENV[variable] if allowed_hosts.include?(host.downcase)
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def validated_ip_address(uri, allow_userinfo: false)
|
|
727
|
+
NetworkPolicy.validate!(
|
|
728
|
+
uri.to_s,
|
|
729
|
+
allowed_schemes: [uri.scheme],
|
|
730
|
+
allow_userinfo: allow_userinfo
|
|
731
|
+
)[1]
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
def load_teplo_core!
|
|
735
|
+
return if defined?(TeploCore)
|
|
736
|
+
|
|
737
|
+
require "teplo_core"
|
|
738
|
+
rescue LoadError
|
|
739
|
+
local = File.expand_path("../../../textplorer/core-ruby/lib/teplo_core", __dir__)
|
|
740
|
+
if File.exist?("#{local}.rb")
|
|
741
|
+
require local
|
|
742
|
+
else
|
|
743
|
+
raise ConfigurationError, "web_url_read requires the teplo_core gem for HTML to Markdown conversion."
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def apply_options(markdown, start_char:, max_length:, section:, paragraph_range:, read_headings:)
|
|
748
|
+
result = markdown.to_s
|
|
749
|
+
return extract_headings(result) if read_headings
|
|
750
|
+
|
|
751
|
+
if section && !section.strip.empty?
|
|
752
|
+
section_text = extract_section(result, section)
|
|
753
|
+
return %(Section "#{section}" not found in the content.) if section_text.empty?
|
|
754
|
+
|
|
755
|
+
result = section_text
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
if paragraph_range && !paragraph_range.strip.empty?
|
|
759
|
+
paragraph_text = extract_paragraph_range(result, paragraph_range)
|
|
760
|
+
return %(Paragraph range "#{paragraph_range}" is invalid or out of bounds.) if paragraph_text.empty?
|
|
761
|
+
|
|
762
|
+
result = paragraph_text
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
start = [start_char.to_i, 0].max
|
|
766
|
+
result = (start >= result.length) ? "" : result[start..]
|
|
767
|
+
|
|
768
|
+
if max_length
|
|
769
|
+
max = max_length.to_i
|
|
770
|
+
result = result[0, max] if max.positive?
|
|
771
|
+
end
|
|
772
|
+
result
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
def extract_section(markdown, heading)
|
|
776
|
+
lines = markdown.split("\n")
|
|
777
|
+
section_regex = /^\#{1,6}\s*.*#{Regexp.escape(heading)}.*$/i
|
|
778
|
+
|
|
779
|
+
start_index = -1
|
|
780
|
+
current_level = 0
|
|
781
|
+
lines.each_with_index do |line, idx|
|
|
782
|
+
next unless line.match?(section_regex)
|
|
783
|
+
|
|
784
|
+
start_index = idx
|
|
785
|
+
current_level = line[/^#+/].to_s.length
|
|
786
|
+
break
|
|
787
|
+
end
|
|
788
|
+
return "" if start_index < 0
|
|
789
|
+
|
|
790
|
+
end_index = lines.length
|
|
791
|
+
lines[(start_index + 1)..]&.each_with_index do |line, offset|
|
|
792
|
+
level = line[/^#+/].to_s.length
|
|
793
|
+
next if level.zero? || level > current_level
|
|
794
|
+
|
|
795
|
+
end_index = start_index + 1 + offset
|
|
796
|
+
break
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
lines[start_index...end_index].join("\n")
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def extract_paragraph_range(markdown, range)
|
|
803
|
+
paragraphs = markdown.split(/\n{2,}/).map(&:strip).reject(&:empty?)
|
|
804
|
+
match = range.to_s.strip.match(/\A(\d+)(?:-(\d*))?\z/)
|
|
805
|
+
return "" unless match
|
|
806
|
+
|
|
807
|
+
start = match[1].to_i - 1
|
|
808
|
+
return "" if start.negative? || start >= paragraphs.length
|
|
809
|
+
|
|
810
|
+
if match[2].nil?
|
|
811
|
+
paragraphs[start].to_s
|
|
812
|
+
elsif match[2].empty?
|
|
813
|
+
paragraphs[start..].join("\n\n")
|
|
814
|
+
else
|
|
815
|
+
ending = match[2].to_i
|
|
816
|
+
paragraphs[start...ending].join("\n\n")
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
def extract_headings(markdown)
|
|
821
|
+
headings = markdown.split("\n").select { |line| line.match?(/^\#{1,6}\s+/) }
|
|
822
|
+
headings.empty? ? "No headings found in the content." : headings.join("\n")
|
|
823
|
+
end
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
class ServerConfigResource < FastMcp::Resource
|
|
827
|
+
uri "config://server-config"
|
|
828
|
+
resource_name "Server Configuration"
|
|
829
|
+
description "Current SearXNG MCP server configuration and capabilities"
|
|
830
|
+
mime_type "application/json"
|
|
831
|
+
|
|
832
|
+
def content
|
|
833
|
+
searxng_url = ENV["SEARXNG_URL"].to_s
|
|
834
|
+
user = ENV["SEARXNG_USER"] || ENV["AUTH_USERNAME"]
|
|
835
|
+
password = ENV["SEARXNG_PASSWORD"] || ENV["AUTH_PASSWORD"]
|
|
836
|
+
config = {
|
|
837
|
+
server_info: {
|
|
838
|
+
name: "searxng",
|
|
839
|
+
version: Searxng::VERSION
|
|
840
|
+
},
|
|
841
|
+
environment: {
|
|
842
|
+
searxng_url: safe_url(searxng_url),
|
|
843
|
+
has_auth: !!(user && password),
|
|
844
|
+
has_proxy: !!(ENV["HTTP_PROXY"] || ENV["HTTPS_PROXY"] || ENV["http_proxy"] || ENV["https_proxy"] || ENV["ALL_PROXY"] || ENV["all_proxy"]),
|
|
845
|
+
has_no_proxy: !!(ENV["NO_PROXY"] || ENV["no_proxy"])
|
|
846
|
+
},
|
|
847
|
+
capabilities: {
|
|
848
|
+
tools: %w[searxng_web_search web_url_read],
|
|
849
|
+
resources: %w[config://server-config help://usage-guide],
|
|
850
|
+
transport: ["stdio"]
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
JSON.pretty_generate(config)
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
private
|
|
857
|
+
|
|
858
|
+
def safe_url(raw)
|
|
859
|
+
return "(not configured)" if raw.nil? || raw.strip.empty?
|
|
860
|
+
|
|
861
|
+
uri = URI.parse(raw)
|
|
862
|
+
uri.user = nil
|
|
863
|
+
uri.password = nil
|
|
864
|
+
uri.to_s
|
|
865
|
+
rescue URI::InvalidURIError
|
|
866
|
+
"(invalid URL)"
|
|
867
|
+
end
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
class UsageGuideResource < FastMcp::Resource
|
|
871
|
+
uri "help://usage-guide"
|
|
872
|
+
resource_name "Usage Guide"
|
|
873
|
+
description "Short guide for using SearXNG MCP tools and environment variables"
|
|
874
|
+
mime_type "text/markdown"
|
|
875
|
+
|
|
876
|
+
def content
|
|
877
|
+
<<~MD
|
|
878
|
+
# SearXNG MCP Server Help
|
|
879
|
+
|
|
880
|
+
## Tools
|
|
881
|
+
1. `searxng_web_search` - Search the web via SearXNG.
|
|
882
|
+
2. `web_url_read` - Fetch a URL and return Markdown-converted content.
|
|
883
|
+
|
|
884
|
+
## Required Environment
|
|
885
|
+
- `SEARXNG_URL` (must be `http://` or `https://`)
|
|
886
|
+
|
|
887
|
+
## Optional Environment
|
|
888
|
+
- `SEARXNG_USER` and `SEARXNG_PASSWORD` (or `AUTH_USERNAME` and `AUTH_PASSWORD`)
|
|
889
|
+
- `HTTP_PROXY` / `HTTPS_PROXY`
|
|
890
|
+
- `ALL_PROXY` (for shared proxy config)
|
|
891
|
+
- `NO_PROXY`
|
|
892
|
+
- `SEARXNG_WEB_URL_SCHEMES` (comma-separated opt-in protocols; default: `http,https,ipfs`)
|
|
893
|
+
- `SEARXNG_WEB_URL_CREDENTIAL_HOSTS` (comma-separated hosts allowed to receive environment credentials)
|
|
894
|
+
- `FTP_USER` / `FTP_PASSWORD` (optional credentials for allowlisted hosts)
|
|
895
|
+
- `SFTP_USER` / `SFTP_PASSWORD` (optional credentials for allowlisted hosts)
|
|
896
|
+
- `SMB_USER` / `SMB_PASSWORD` / `SMB_DOMAIN` (optional credentials for allowlisted hosts)
|
|
897
|
+
- `IPFS_GATEWAY` (default: `https://ipfs.io`)
|
|
898
|
+
- `GEMINI_INSECURE=1` (optional, disables TLS verification for Gemini)
|
|
899
|
+
|
|
900
|
+
## Protocol Notes
|
|
901
|
+
- `http` / `https`: fully supported (`searxng_web_search` + `web_url_read`)
|
|
902
|
+
- `ftp`: opt-in through `SEARXNG_WEB_URL_SCHEMES` (file retrieval via FTP)
|
|
903
|
+
- `sftp`: opt-in through `SEARXNG_WEB_URL_SCHEMES` (requires `net-sftp` gem)
|
|
904
|
+
- `smb`: opt-in through `SEARXNG_WEB_URL_SCHEMES` via `smbclient`
|
|
905
|
+
- `gemini`: opt-in through `SEARXNG_WEB_URL_SCHEMES` (Gemini fetch + Gemtext conversion)
|
|
906
|
+
- `ipfs`: supported via HTTP gateway mapping (`ipfs://...` -> `IPFS_GATEWAY`)
|
|
907
|
+
- `.onion` / `.i2p`: not supported by `web_url_read` (local DNS and destination policy run before any proxy)
|
|
908
|
+
- `socks5`: may work for SearXNG search via proxy env; not a bypass for `web_url_read` destination policy
|
|
909
|
+
|
|
910
|
+
## Examples
|
|
911
|
+
- Search: `{"query":"latest ruby news","time_range":"day"}`
|
|
912
|
+
- Read URL: `{"url":"https://example.com","section":"Introduction","maxLength":2000}`
|
|
913
|
+
- Read FTP: `{"url":"ftp://ftp.example.com/path/file.txt"}`
|
|
914
|
+
- Read SFTP: `{"url":"sftp://user@example.com/path/file.txt"}`
|
|
915
|
+
- Read SMB: `{"url":"smb://fileserver/share/path/file.txt"}`
|
|
916
|
+
- Read Gemini: `{"url":"gemini://geminiprotocol.net"}`
|
|
917
|
+
- Read IPFS: `{"url":"ipfs://bafybeigdyrzt.../index.html"}`
|
|
918
|
+
MD
|
|
919
|
+
end
|
|
920
|
+
end
|
|
130
921
|
end
|
|
131
922
|
end
|