Buffy commited on
Commit
42f6abb
·
1 Parent(s): 60e15d5

Add SearXNG userspace runtime with DDG + Google engines

Browse files

Bootstrap pattern mirrors node_runtime: fetch source tarball, pip-install
into a userspace venv on first boot, launch bound to 127.0.0.1:8888.
FastAPI bridge exposes /respite/v2/searx/search (JSON API proxy).

Files changed (3) hide show
  1. app.py +8 -0
  2. searx_bridge.py +43 -0
  3. searx_runtime.py +151 -0
app.py CHANGED
@@ -23,6 +23,8 @@ import json as _json
23
 
24
  import node_probe
25
  import node_runtime
 
 
26
 
27
  for _stream in (sys.stdout, sys.stderr):
28
  if hasattr(_stream, "reconfigure"):
@@ -874,6 +876,12 @@ def _patched_create_app(blocks, **kwargs):
874
  except Exception as _ne:
875
  _log(f"node probe registration failed: {_ne}")
876
 
 
 
 
 
 
 
877
  _log("Routes injected via create_app monkey-patch")
878
  return fa_app
879
 
 
23
 
24
  import node_probe
25
  import node_runtime
26
+ import searx_bridge
27
+ import searx_runtime
28
 
29
  for _stream in (sys.stdout, sys.stderr):
30
  if hasattr(_stream, "reconfigure"):
 
876
  except Exception as _ne:
877
  _log(f"node probe registration failed: {_ne}")
878
 
879
+ try:
880
+ searx_bridge.register_routes(fa_app)
881
+ threading.Thread(target=searx_runtime.start_searxng, daemon=True).start()
882
+ except Exception as _se:
883
+ _log(f"searxng bridge registration failed: {_se}")
884
+
885
  _log("Routes injected via create_app monkey-patch")
886
  return fa_app
887
 
searx_bridge.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SearXNG bridge — runs a SearXNG metasearch instance inside the Space
2
+ container (userspace venv, no root) and proxies queries to it.
3
+
4
+ Mounted under /respite/v2/searx/*:
5
+ /status → bootstrap state of the runtime
6
+ /search → proxy ?q=... queries to SearXNG's JSON API
7
+
8
+ Engine selection comes from searxng_settings.yml (DDG + Google enabled);
9
+ the frontend picks engines per request via the standard `engines` param.
10
+ """
11
+
12
+ import httpx
13
+ from fastapi import Request
14
+ from fastapi.responses import JSONResponse
15
+
16
+ import searx_runtime
17
+
18
+
19
+ def register_routes(fa_app):
20
+ @fa_app.get("/respite/v2/searx/status")
21
+ def _status():
22
+ return JSONResponse({
23
+ "runtime_dir": searx_runtime.RUNTIME_DIR,
24
+ "installed": searx_runtime.searxng_ready(),
25
+ "port": searx_runtime.SEARXNG_PORT,
26
+ })
27
+
28
+ @fa_app.get("/respite/v2/searx/search")
29
+ async def _search(request: Request):
30
+ if not searx_runtime.searxng_ready():
31
+ ok = searx_runtime.start_searxng()
32
+ if not ok:
33
+ return JSONResponse({"error": "searxng not running"}, status_code=503)
34
+ params = dict(request.query_params)
35
+ # SearXNG's JSON API lives at /search with format=json
36
+ params.setdefault("format", "json")
37
+ url = f"http://127.0.0.1:{searx_runtime.SEARXNG_PORT}/search"
38
+ try:
39
+ async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=5.0)) as client:
40
+ r = await client.get(url, params=params)
41
+ return JSONResponse(r.json(), status_code=r.status_code)
42
+ except Exception as e:
43
+ return JSONResponse({"error": str(e)}, status_code=502)
searx_runtime.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """User-space SearXNG bootstrap for the Respite search backend.
2
+
3
+ SearXNG is not on PyPI (the `searxng` package there is the abandoned
4
+ pre-NG searx). Instead we fetch the upstream source tarball into a local
5
+ cache, install it into a userspace virtualenv on first boot (no root),
6
+ and launch the built-in development server bound to 127.0.0.1:8888.
7
+
8
+ The instance is configured by searxng_settings.yml in the repo root
9
+ (SEARXNG_SETTINGS_PATH points there), with the DDG + Google engines
10
+ enabled and JSON output on.
11
+
12
+ Env:
13
+ SEARXNG_VERSION source tag/branch to fetch (default: master)
14
+ SEARXNG_PORT localhost port (default 8888)
15
+ SEARXNG_OFF set to "1" to skip the bootstrap entirely
16
+ """
17
+
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import tarfile
22
+ import threading
23
+ import time
24
+ import urllib.request
25
+
26
+ SEARXNG_VERSION = os.environ.get("SEARXNG_VERSION", "master")
27
+ SEARXNG_PORT = int(os.environ.get("SEARXNG_PORT", "8888"))
28
+ SEARXNG_URL = f"https://github.com/searxng/searxng/archive/refs/heads/{SEARXNG_VERSION}.tar.gz"
29
+
30
+ ROOT = os.path.dirname(os.path.abspath(__file__))
31
+ RUNTIME_DIR = os.path.join(ROOT, ".searxng-runtime")
32
+ SRC_DIR = os.path.join(RUNTIME_DIR, f"searxng-{SEARXNG_VERSION}")
33
+ VENV_DIR = os.path.join(RUNTIME_DIR, "venv")
34
+ VENV_PIP = os.path.join(VENV_DIR, "bin", "pip")
35
+ SETTINGS_FILE = os.path.join(ROOT, "searxng_settings.yml")
36
+
37
+ _proc = None
38
+ _lock = threading.Lock()
39
+ _ready = threading.Event()
40
+
41
+
42
+ def _log(msg):
43
+ print(f"[searxng] {msg}", flush=True)
44
+
45
+
46
+ def _fetch_source() -> bool:
47
+ if os.path.isdir(SRC_DIR) and os.path.exists(os.path.join(SRC_DIR, "searx", "webapp.py")):
48
+ return True
49
+ os.makedirs(RUNTIME_DIR, exist_ok=True)
50
+ tgz = os.path.join(RUNTIME_DIR, "searxng.tar.gz")
51
+ _log(f"downloading {SEARXNG_URL}")
52
+ urllib.request.urlretrieve(SEARXNG_URL, tgz)
53
+ _log("extracting")
54
+ with tarfile.open(tgz, "r:gz") as tf:
55
+ tf.extractall(RUNTIME_DIR)
56
+ os.remove(tgz)
57
+ return os.path.isdir(SRC_DIR)
58
+
59
+
60
+ def _venv_python() -> str | None:
61
+ """Create the venv and install searxng + deps (idempotent)."""
62
+ py = os.path.join(VENV_DIR, "bin", "python")
63
+ if os.path.exists(py):
64
+ return py
65
+ _log("creating venv")
66
+ if subprocess.run(["python3", "-m", "venv", VENV_DIR], capture_output=True).returncode != 0:
67
+ _log("venv creation failed")
68
+ return None
69
+ _log("installing searxng deps (this takes a few minutes on first boot)")
70
+ r1 = subprocess.run(
71
+ [VENV_PIP, "install", "--no-input", "-r", os.path.join(SRC_DIR, "requirements.txt")],
72
+ capture_output=True, text=True, timeout=900,
73
+ )
74
+ if r1.returncode != 0:
75
+ _log(f"deps install failed: {r1.stderr[-2000:]}")
76
+ return None
77
+ r2 = subprocess.run(
78
+ [VENV_PIP, "install", "--no-input", "--no-deps", "-e", SRC_DIR],
79
+ capture_output=True, text=True, timeout=300,
80
+ )
81
+ if r2.returncode != 0:
82
+ _log(f"searxng install failed: {r2.stderr[-2000:]}")
83
+ return None
84
+ _log("searxng installed")
85
+ return py
86
+
87
+
88
+ def _wait_ready(proc: subprocess.Popen, timeout: float = 120.0) -> bool:
89
+ import socket
90
+ deadline = time.time() + timeout
91
+ while time.time() < deadline:
92
+ if proc.poll() is not None:
93
+ _log(f"searxng exited early with code {proc.returncode}")
94
+ return False
95
+ try:
96
+ with socket.create_connection(("127.0.0.1", SEARXNG_PORT), timeout=1):
97
+ return True
98
+ except OSError:
99
+ time.sleep(0.5)
100
+ return False
101
+
102
+
103
+ def start_searxng() -> bool:
104
+ """Launch SearXNG bound to localhost. Safe to call once at startup."""
105
+ global _proc
106
+ if os.environ.get("SEARXNG_OFF") == "1":
107
+ _log("disabled via SEARXNG_OFF")
108
+ return False
109
+ with _lock:
110
+ if _proc and _proc.poll() is None:
111
+ return True
112
+ try:
113
+ if not _fetch_source():
114
+ _log("source fetch failed")
115
+ return False
116
+ py = _venv_python()
117
+ if not py:
118
+ return False
119
+ except Exception as e:
120
+ _log(f"bootstrap failed: {e}")
121
+ return False
122
+ env = os.environ.copy()
123
+ env["SEARXNG_SETTINGS_PATH"] = SETTINGS_FILE
124
+ env["PYTHONPATH"] = SRC_DIR
125
+ _log(f"starting searxng on 127.0.0.1:{SEARXNG_PORT}")
126
+ _proc = subprocess.Popen(
127
+ [py, "-m", "searx.webapp"],
128
+ cwd=SRC_DIR,
129
+ env=env,
130
+ stdout=subprocess.PIPE,
131
+ stderr=subprocess.STDOUT,
132
+ text=True,
133
+ bufsize=1,
134
+ )
135
+ threading.Thread(target=_pump_logs, daemon=True).start()
136
+ if _wait_ready(_proc):
137
+ _log("searxng is READY")
138
+ _ready.set()
139
+ return True
140
+ _log("searxng failed to become ready")
141
+ return False
142
+
143
+
144
+ def _pump_logs():
145
+ assert _proc and _proc.stdout
146
+ for line in _proc.stdout:
147
+ _log(line.rstrip())
148
+
149
+
150
+ def searxng_ready() -> bool:
151
+ return _ready.is_set()