pub mod bundle;
pub mod bundle_manifest;
use std::path::{Path, PathBuf};
use std::process::{Child, Stdio};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use anyhow::{Result, anyhow};
use tokio::sync::Mutex;
struct Running {
child: Child,
base_url: String,
}
#[derive(Default)]
pub struct SearxngManager {
running: Mutex<Option<Running>>,
}
static MANAGER: OnceLock<SearxngManager> = OnceLock::new();
pub fn manager() -> &'static SearxngManager {
MANAGER.get_or_init(SearxngManager::default)
}
pub async fn shutdown() {
if let Some(mgr) = MANAGER.get() {
mgr.stop().await;
}
}
impl SearxngManager {
pub async fn ensure_running(&self) -> Result<String> {
let mut running = self.running.lock().await;
if let Some(r) = running.as_ref() {
return Ok(r.base_url.clone());
}
let runtime = bundle::ensure_bundle().await?;
let port = free_port()?;
let settings = write_settings()?;
let mut child = spawn_granian(&runtime, port, &settings)
.map_err(|e| anyhow!("could not start the SearXNG server (Granian): {e}"))?;
let base_url = format!("http://127.0.0.1:{port}");
if let Err(e) = wait_ready(&base_url, &mut child).await {
crate::utils::terminate_tree(child.id(), crate::utils::Grace::Immediate).await;
let _ = child.wait();
return Err(anyhow!("SearXNG did not become ready: {e}"));
}
*running = Some(Running {
child,
base_url: base_url.clone(),
});
Ok(base_url)
}
async fn stop(&self) {
let mut running = self.running.lock().await;
if let Some(mut r) = running.take() {
crate::utils::terminate_tree(r.child.id(), crate::utils::Grace::Graceful).await;
let _ = r.child.wait();
}
}
}
fn free_port() -> Result<u16> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")
.map_err(|e| anyhow!("could not find a free port for SearXNG: {e}"))?;
Ok(listener.local_addr()?.port())
}
fn python_bin(runtime: &Path) -> PathBuf {
#[cfg(windows)]
{
runtime.join("python").join("python.exe")
}
#[cfg(not(windows))]
{
runtime.join("python").join("bin").join("python3")
}
}
fn spawn_granian(runtime: &Path, port: u16, settings: &Path) -> std::io::Result<Child> {
let python = python_bin(runtime);
let port_str = port.to_string();
let mut cmd = std::process::Command::new(&python);
cmd.args([
"-m",
"granian",
"--interface",
"wsgi",
"--host",
"127.0.0.1",
"--port",
port_str.as_str(),
"searx.webapp:application",
])
.env("SEARXNG_SETTINGS_PATH", settings)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(crate::utils::DETACHED_PROCESS | crate::utils::CREATE_NEW_PROCESS_GROUP);
}
cmd.spawn()
}
fn write_settings() -> Result<PathBuf> {
let dir = crate::runtime::data_dir()?.join("searxng");
std::fs::create_dir_all(&dir)?;
let settings = dir.join("settings.yml");
std::fs::write(&settings, settings_yml())?;
Ok(settings)
}
fn settings_yml() -> String {
format!(
"use_default_settings:\n\
\x20 engines:\n\
\x20 remove:\n\
\x20 - ahmia\n\
\x20 - torch\n\
server:\n\
\x20 secret_key: \"{}\"\n\
\x20 limiter: false\n\
search:\n\
\x20 formats:\n\
\x20 - html\n\
\x20 - json\n\
valkey:\n\
\x20 url: false\n",
random_secret()
)
}
fn random_secret() -> String {
let mut bytes = [0u8; 16];
if getrandom::fill(&mut bytes).is_err() {
return "mermaid-searxng-local".to_string();
}
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
async fn wait_ready(base_url: &str, child: &mut Child) -> Result<()> {
let client = reqwest::Client::new();
let probe = format!("{base_url}/search?q=ping&format=json");
let deadline = Instant::now() + Duration::from_secs(60);
loop {
if let Ok(resp) = client
.get(&probe)
.timeout(Duration::from_secs(3))
.send()
.await
&& resp.status().is_success()
{
return Ok(());
}
if let Ok(Some(status)) = child.try_wait() {
return Err(anyhow!("the Granian server exited immediately ({status})"));
}
if Instant::now() >= deadline {
return Err(anyhow!("no response from {base_url} after startup"));
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_enable_json_and_disable_limiter_and_valkey() {
let s = settings_yml();
assert!(s.contains("use_default_settings:"));
assert!(
s.contains("- ahmia") && s.contains("- torch"),
"dead onion engines must be removed:\n{s}"
);
assert!(s.contains("- json"), "JSON format must be enabled:\n{s}");
assert!(s.contains("limiter: false"));
assert!(s.contains("url: false"), "Valkey must be disabled:\n{s}");
assert!(s.contains("secret_key:"));
assert!(!s.contains("secret_key: \"\""), "secret must be non-empty");
}
#[test]
fn free_port_is_nonzero() {
assert!(free_port().unwrap() > 0);
}
#[test]
fn python_bin_sits_under_the_runtime() {
let py = python_bin(Path::new("/data/searxng/runtime"));
assert!(py.starts_with("/data/searxng/runtime/python"), "{py:?}");
assert!(py.ends_with(if cfg!(windows) {
"python.exe"
} else {
"python3"
}));
}
#[test]
fn random_secret_is_hex_and_unique() {
let a = random_secret();
let b = random_secret();
assert_eq!(a.len(), 32);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(a, b, "each secret should be freshly random");
}
#[tokio::test]
#[ignore]
async fn managed_searxng_end_to_end() {
let base = manager()
.ensure_running()
.await
.expect("ensure_running should download the bundle and start Granian");
assert!(base.starts_with("http://127.0.0.1:"), "base_url: {base}");
let probe = format!("{base}/search?q=mermaid&format=json");
let resp = reqwest::Client::new()
.get(&probe)
.send()
.await
.expect("search probe");
assert!(resp.status().is_success(), "status: {}", resp.status());
let body: serde_json::Value = resp.json().await.expect("json body");
assert!(body.get("results").is_some(), "no results field in {body}");
shutdown().await;
let after = reqwest::Client::new()
.get(&probe)
.timeout(std::time::Duration::from_secs(2))
.send()
.await;
assert!(
after.is_err() || !after.unwrap().status().is_success(),
"server still answering after shutdown — reap failed"
);
}
}