use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::Duration;
use anyhow::{Result, anyhow};
use tokio::sync::Mutex;
const IMAGE: &str = "docker.io/searxng/searxng:latest";
const CONTAINER_NAME: &str = "mermaid-searxng";
struct Running {
runtime: &'static str,
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 = detect_runtime().ok_or_else(|| {
anyhow!(
"web_search needs a container runtime (podman or docker) to auto-start SearXNG. \
Install one, set OLLAMA_API_KEY, or point `[web] search_backend`/`searxng_url` \
at your own instance."
)
})?;
let port = free_port()?;
let config_dir = write_settings()?;
start_container(runtime, port, &config_dir).await?;
let base_url = format!("http://127.0.0.1:{port}");
if let Err(e) = wait_ready(&base_url).await {
let _ = run(runtime, &["rm", "-f", CONTAINER_NAME]).await;
return Err(anyhow!("SearXNG container did not become ready: {e}"));
}
*running = Some(Running {
runtime,
base_url: base_url.clone(),
});
Ok(base_url)
}
async fn stop(&self) {
let mut running = self.running.lock().await;
if let Some(r) = running.take() {
let _ = run(r.runtime, &["rm", "-f", CONTAINER_NAME]).await;
}
}
}
fn detect_runtime() -> Option<&'static str> {
if which::which("podman").is_ok() {
Some("podman")
} else if which::which("docker").is_ok() {
Some("docker")
} else {
None
}
}
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 write_settings() -> Result<PathBuf> {
let dir = crate::app::get_config_dir()?.join("searxng");
std::fs::create_dir_all(&dir)?;
let settings = dir.join("settings.yml");
if !settings.exists() {
std::fs::write(&settings, settings_yml())?;
}
Ok(dir)
}
fn settings_yml() -> String {
format!(
"use_default_settings: true\n\
server:\n\
\x20 secret_key: \"{}\"\n\
\x20 limiter: false\n\
search:\n\
\x20 formats:\n\
\x20 - html\n\
\x20 - json\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 start_container(runtime: &str, port: u16, config_dir: &Path) -> Result<()> {
let mount = format!("{}:/etc/searxng:Z", config_dir.display());
let publish = format!("127.0.0.1:{port}:8080");
let out = tokio::time::timeout(
Duration::from_secs(300), run(
runtime,
&[
"run",
"-d",
"--replace",
"--name",
CONTAINER_NAME,
"-p",
&publish,
"-v",
&mount,
IMAGE,
],
),
)
.await
.map_err(|_| anyhow!("timed out starting the SearXNG container (image pull too slow?)"))??;
if !out.status.success() {
return Err(anyhow!(
"failed to start SearXNG via {runtime}: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(())
}
async fn wait_ready(base_url: &str) -> Result<()> {
let client = reqwest::Client::new();
let probe = format!("{base_url}/search?q=ping&format=json");
for _ in 0..30 {
if let Ok(resp) = client
.get(&probe)
.timeout(Duration::from_secs(3))
.send()
.await
&& resp.status().is_success()
{
return Ok(());
}
tokio::time::sleep(Duration::from_millis(1500)).await;
}
Err(anyhow!("no response from {base_url} after startup"))
}
async fn run(runtime: &str, args: &[&str]) -> Result<std::process::Output> {
tokio::process::Command::new(runtime)
.args(args)
.output()
.await
.map_err(|e| anyhow!("failed to run {runtime}: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_enable_json_with_a_secret() {
let s = settings_yml();
assert!(s.contains("use_default_settings: true"));
assert!(s.contains("- json"), "JSON format must be enabled:\n{s}");
assert!(s.contains("limiter: false"));
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 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");
}
}