diff --git a/config.docker.toml b/config.docker.toml index 8070a469..ccec31cb 100644 --- a/config.docker.toml +++ b/config.docker.toml @@ -73,14 +73,13 @@ ws_url = "ws://chrome:9222/" [renderer.chrome_pool] size = 4 -# /v1/search points at the bundled SearXNG sidecar. Use the `searxng-internal` -# alias, which exists ONLY on the internal `search_rpc` network — the bare -# `searxng` name also resolves on `public_egress`, where the egress firewall -# drops service-to-service traffic, so docker DNS returning that IP breaks the -# answer path. `searxng-internal` pins the search_rpc IP (matches the SaaS, -# which already uses it). See crw-saas docker-compose.prod.yml. +# /v1/search points at the bundled SearXNG sidecar. On the reference +# docker-compose stack the sidecar is reachable as `searxng:8080` over the +# default bridge network (the service is named `searxng`). To point CRW at an +# external SearXNG instead, override this without editing the file by setting +# `CRW_SEARCH__SEARXNG_URL=http://your-host:8080` (env wins over this default). [search] -searxng_url = "http://searxng-internal:8080" +searxng_url = "http://searxng:8080" # Multi-query expansion (cycle-2 of the answer-quality loop): the proven win. # On a 50-q SimpleQA+FRAMES sample it lifted overall 64%->76% and FRAMES # 56%->80% by unioning an entity-rewrite's results with the original. Baked on diff --git a/crates/crw-mcp-proto/src/lib.rs b/crates/crw-mcp-proto/src/lib.rs index 77cdf156..1194dc3c 100644 --- a/crates/crw-mcp-proto/src/lib.rs +++ b/crates/crw-mcp-proto/src/lib.rs @@ -202,7 +202,7 @@ pub fn tool_definitions(proxy_mode: bool) -> Value { let _ = proxy_mode; tools.push(json!({ "name": "crw_search", - "description": "Search the web and return relevant results with titles, URLs, and descriptions/snippets. Backed by a SearXNG sidecar in embedded mode (no API key needed), or by the configured remote API in proxy mode (uses CRW_API_KEY).\n\nReturn shape: `{ \"success\": true, \"data\": { \"results\": [{ \"url\", \"title\", \"description\", \"snippet\", \"position\", \"score\" }, ...] } }`. When `sources` is set, `data.results` is instead an object grouped by source (`{ \"web\": [...], \"news\": [...], \"images\": [...] }`). The `snippet` field is an alias of `description` — both carry the same body text so downstream LLM pipelines that ask for either get a match.\n\nExample: `crw_search(query=\"renewable energy trends 2024\", limit=3)` returns the top 3 web results with title/url/snippet.", + "description": "Search the web and return relevant results with titles, URLs, and descriptions/snippets. Backed by a SearXNG sidecar in embedded mode (no API key needed), or by the configured remote API in proxy mode (uses CRW_API_KEY).\n\nReturn shape: `{ \"success\": true, \"data\": { \"results\": [{ \"url\", \"title\", \"description\", \"snippet\", \"position\", \"score\" }, ...] } }`. When `sources` is set, `data.results` is instead an object grouped by source (`{ \"web\": [...], \"news\": [...], \"images\": [...] }`). The `snippet` field is an alias of `description` — both carry the same body text so downstream LLM pipelines that ask for either get a match.\n\nExample: `crw_search(query=\"renewable energy trends 2024\", limit=3)` returns the top 3 web results with title/url/snippet.\n\nErrors: returns `search_disabled` when no SearXNG backend is configured, or `target_unreachable` / `timeout` (naming the configured host) when the backend can't be reached.", "inputSchema": { "type": "object", "properties": { diff --git a/crates/crw-search/src/client.rs b/crates/crw-search/src/client.rs index 10004f3e..ae028e07 100644 --- a/crates/crw-search/src/client.rs +++ b/crates/crw-search/src/client.rs @@ -152,6 +152,13 @@ impl SearxngClient { } } + /// Configured base URL (trailing slash trimmed). Exposed so the route layer + /// can name the host in `target_unreachable` errors without leaking it raw + /// (callers sanitize to the origin first — see crw-server `diagnostics`). + pub fn base_url(&self) -> &str { + &self.base_url + } + /// Issue a JSON search request. Errors surface as a typed [`SearchError`] /// — the route layer maps them onto `CrwError` for HTTP responses. pub async fn fetch(&self, params: &SearxngParams) -> Result { @@ -191,7 +198,10 @@ impl SearxngClient { if e.is_timeout() { SearchError::Timeout } else { - SearchError::Transport(e.to_string()) + // `without_url()` strips reqwest's embedded request URL from + // the Display string — that URL can carry credentials/tokens + // (issue #90). The route layer re-attaches a sanitized origin. + SearchError::Transport(e.without_url().to_string()) } })?; diff --git a/crates/crw-server/src/diagnostics.rs b/crates/crw-server/src/diagnostics.rs new file mode 100644 index 00000000..311cbcd9 --- /dev/null +++ b/crates/crw-server/src/diagnostics.rs @@ -0,0 +1,119 @@ +//! Operator-facing diagnostics helpers (issue #90). +//! +//! `searxng_url` is operator-set and can carry secrets (`https://user:pass@host`, +//! a `?token=…`, or a token embedded in the path of a reverse-proxy URL). Anything +//! we log or return in an error must be sanitized to the bare origin first. + +use crw_core::config::SearchConfig; +use tracing::Level; + +/// Reduce a URL to its **origin** — `scheme://host[:port]` — dropping userinfo, +/// path, query, and fragment. This is the only form safe to log or echo in an +/// error, because every other component can carry a secret. Falls back to a +/// fixed redaction string if the URL doesn't parse. +pub fn sanitize_url_origin(raw: &str) -> String { + match url::Url::parse(raw) { + Ok(u) => match (u.host_str(), u.port()) { + (Some(host), Some(port)) => format!("{}://{host}:{port}", u.scheme()), + (Some(host), None) => format!("{}://{host}", u.scheme()), + (None, _) => "".to_string(), + }, + Err(_) => "".to_string(), + } +} + +/// One-line summary of the search subsystem's configured state, for the startup +/// log. Distinguishes the three states that otherwise collapse to a single +/// "search disabled" at request time: +/// - `enabled = false` → intentionally off +/// - enabled, `searxng_url` unset → misconfigured (every call will 503) +/// - enabled, `searxng_url` set → active (host shown, origin-sanitized) +pub fn search_startup_status(cfg: &SearchConfig) -> (Level, String) { + if !cfg.enabled { + ( + Level::INFO, + "search: disabled ([search].enabled = false)".to_string(), + ) + } else if let Some(url) = &cfg.searxng_url { + ( + Level::INFO, + format!("search: enabled (searxng={})", sanitize_url_origin(url)), + ) + } else { + ( + Level::WARN, + "search: enabled but no [search].searxng_url — /v1/search will return 503".to_string(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_strips_userinfo_path_query_fragment() { + assert_eq!( + sanitize_url_origin("https://user:pass@host:9000/searxng/tok123?q=x#frag"), + "https://host:9000" + ); + assert_eq!( + sanitize_url_origin("http://searxng:8080"), + "http://searxng:8080" + ); + assert_eq!( + sanitize_url_origin("http://searxng:8080/"), + "http://searxng:8080" + ); + // Default port is preserved as written only if explicit; no port → no port. + assert_eq!( + sanitize_url_origin("https://example.com"), + "https://example.com" + ); + } + + #[test] + fn sanitize_redacts_unparseable() { + assert_eq!(sanitize_url_origin("not a url"), ""); + } + + fn cfg(enabled: bool, url: Option<&str>) -> SearchConfig { + let toml = match url { + Some(u) => format!("enabled = {enabled}\nsearxng_url = \"{u}\"\n"), + None => format!("enabled = {enabled}\n"), + }; + toml::from_str(&toml).expect("valid SearchConfig") + } + + #[test] + fn startup_status_enabled_with_url() { + let (level, msg) = search_startup_status(&cfg(true, Some("http://searxng:8080"))); + assert_eq!(level, Level::INFO); + assert!( + msg.contains("enabled (searxng=http://searxng:8080)"), + "{msg}" + ); + } + + #[test] + fn startup_status_enabled_no_url_warns() { + let (level, msg) = search_startup_status(&cfg(true, None)); + assert_eq!(level, Level::WARN); + assert!(msg.contains("no [search].searxng_url"), "{msg}"); + } + + #[test] + fn startup_status_disabled() { + let (level, msg) = search_startup_status(&cfg(false, Some("http://searxng:8080"))); + assert_eq!(level, Level::INFO); + assert!(msg.contains("disabled"), "{msg}"); + } + + #[test] + fn startup_status_never_leaks_credentials() { + let (_, msg) = search_startup_status(&cfg(true, Some("https://u:secret@host:8080/tok"))); + assert!(!msg.contains("secret"), "{msg}"); + assert!(!msg.contains("tok"), "{msg}"); + assert!(msg.contains("https://host:8080"), "{msg}"); + } +} diff --git a/crates/crw-server/src/lib.rs b/crates/crw-server/src/lib.rs index edd18718..427040f0 100644 --- a/crates/crw-server/src/lib.rs +++ b/crates/crw-server/src/lib.rs @@ -21,6 +21,7 @@ //! ``` pub mod app; +pub mod diagnostics; pub mod error; pub mod middleware; pub mod routes; diff --git a/crates/crw-server/src/main.rs b/crates/crw-server/src/main.rs index 03bbc90d..37135d99 100644 --- a/crates/crw-server/src/main.rs +++ b/crates/crw-server/src/main.rs @@ -75,6 +75,16 @@ async fn run_server() { tracing::info!("LLM structured extraction: enabled"); } + // Issue #90: make the search subsystem's configured state visible at boot. + // Three states (disabled / enabled-but-unconfigured / enabled) otherwise + // collapse to a single request-time error. The host is origin-sanitized so + // a credentialed `searxng_url` never reaches the logs. + let (search_level, search_msg) = crw_server::diagnostics::search_startup_status(&config.search); + match search_level { + tracing::Level::WARN => tracing::warn!("{search_msg}"), + _ => tracing::info!("{search_msg}"), + } + // Boot guard: when SaaS fronts opencore it sets `CRW_DISABLE_SERVER_LLM_KEY=1` // to prevent the most common ops mistake — leaving a server-wide key // configured behind the SaaS, which would leak the org's key to every @@ -111,6 +121,50 @@ async fn run_server() { tracing::warn!("No CDP renderer active — JS rendering disabled"); } + // Issue #90: one-shot, non-fatal reachability probe so a misconfigured or + // down SearXNG is *spoken* at boot instead of failing silently on the first + // search. Bundled-compose users are already gated by `depends_on: + // searxng condition: service_healthy`, so this mainly helps operators who + // point `CRW_SEARCH__SEARXNG_URL` at an external host. The origin is + // sanitized; the probe hits the origin's `/healthz` (the same path the + // compose healthcheck uses) and is bounded by its own short timeout — + // never `connect_timeout`, which wouldn't bound a stalled response. + if state.config.search.enabled + && let Some(raw_url) = state.config.search.searxng_url.clone() + { + let origin = crw_server::diagnostics::sanitize_url_origin(&raw_url); + tokio::spawn(async move { + let probe = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(3)) + .build(); + match probe { + Ok(client) => { + let healthz = format!("{origin}/healthz"); + match client.get(&healthz).send().await { + Ok(resp) if resp.status().is_success() => { + tracing::info!("search: SearXNG reachable at {origin}"); + } + Ok(resp) => { + tracing::warn!( + "search: SearXNG at {origin} answered /healthz with {} — \ + search calls may fail until it is healthy", + resp.status() + ); + } + Err(e) => { + tracing::warn!( + "search: configured host {origin} UNREACHABLE at startup \ + ({}) — search calls will fail until it resolves", + e.without_url() + ); + } + } + } + Err(e) => tracing::warn!("search: could not build startup probe client: {e}"), + } + }); + } + // Issue #35 transparency: when auto-extension widens the implicit deadline // beyond the operator's `deadline_ms_default`, log the effective values so // operators can correlate "request took longer than my SLO" against the diff --git a/crates/crw-server/src/routes/search.rs b/crates/crw-server/src/routes/search.rs index 612ea805..aac33212 100644 --- a/crates/crw-server/src/routes/search.rs +++ b/crates/crw-server/src/routes/search.rs @@ -187,12 +187,12 @@ pub async fn search_inner( .clamp(1, MAX_QUERY_EXPAND_VARIANTS); fetch_expanded(&client, &req.query, ¶ms, llm, variants_n) .await - .map_err(|e| map_search_error(e, state.config.search.timeout_ms))? + .map_err(|e| map_search_error(e, state.config.search.timeout_ms, client.base_url()))? } else { client .fetch(¶ms) .await - .map_err(|e| map_search_error(e, state.config.search.timeout_ms))? + .map_err(|e| map_search_error(e, state.config.search.timeout_ms, client.base_url()))? }; let has_sources = req.sources.as_ref().is_some_and(|s| !s.is_empty()); @@ -859,7 +859,12 @@ fn validate_request(req: &SearchRequest, max_limit: u32) -> Result<(), CrwError> Ok(()) } -fn map_search_error(err: SearchError, timeout_ms: u64) -> CrwError { +/// Map a transport/timeout/upstream `SearchError` onto the HTTP `CrwError`. +/// `base_url` is the configured SearXNG URL; the transport (`target_unreachable`) +/// arm names its **origin** (issue #90) so the operator sees *which* host failed +/// — sanitized, so a credentialed URL never reaches the response. Timeouts keep +/// `error_code: "timeout"`; the host is correlated via the startup log instead. +fn map_search_error(err: SearchError, timeout_ms: u64, base_url: &str) -> CrwError { match err { SearchError::Timeout => CrwError::Timeout(timeout_ms), SearchError::Upstream { status, body } => CrwError::HttpError(format!( @@ -869,7 +874,10 @@ fn map_search_error(err: SearchError, timeout_ms: u64) -> CrwError { SearchError::InvalidResponse(msg) => { CrwError::HttpError(format!("SearXNG returned invalid JSON: {msg}")) } - SearchError::Transport(msg) => CrwError::TargetUnreachable(format!("SearXNG: {msg}")), + SearchError::Transport(msg) => CrwError::TargetUnreachable(format!( + "SearXNG ({}): {msg}", + crate::diagnostics::sanitize_url_origin(base_url) + )), } } @@ -1152,7 +1160,7 @@ mod tests { #[test] fn map_search_error_timeout_to_timeout() { assert!(matches!( - map_search_error(SearchError::Timeout, 7500), + map_search_error(SearchError::Timeout, 7500, "http://searxng:8080"), CrwError::Timeout(7500) )); } @@ -1164,11 +1172,28 @@ mod tests { body: "down".into(), }; assert!(matches!( - map_search_error(err, 5000), + map_search_error(err, 5000, "http://searxng:8080"), CrwError::HttpError(_) )); } + #[test] + fn map_search_error_transport_names_sanitized_host() { + // issue #90: the unreachable error must name the configured host so the + // operator knows *what* failed — but origin-only, never the raw URL. + let err = SearchError::Transport("dns error: failed to lookup address".into()); + let mapped = map_search_error(err, 5000, "https://user:pass@searxng:8080/tok?k=v"); + match mapped { + CrwError::TargetUnreachable(msg) => { + assert!(msg.contains("https://searxng:8080"), "{msg}"); + assert!(!msg.contains("user"), "must not leak userinfo: {msg}"); + assert!(!msg.contains("pass"), "must not leak credentials: {msg}"); + assert!(!msg.contains("tok"), "must not leak path token: {msg}"); + } + other => panic!("expected TargetUnreachable, got {other:?}"), + } + } + #[test] fn _suppress_unused_search_source_warning() { let _ = SearchSource::Web; diff --git a/crates/crw-server/tests/docker_config_hosts.rs b/crates/crw-server/tests/docker_config_hosts.rs new file mode 100644 index 00000000..a8c2557a --- /dev/null +++ b/crates/crw-server/tests/docker_config_hosts.rs @@ -0,0 +1,94 @@ +//! Regression guard for issue #90: every host referenced in `config.docker.toml` +//! must resolve to a service that the reference `docker-compose.yml` actually +//! defines. Issue #90 shipped a SaaS-only hostname (`searxng-internal`) in the +//! opencore default; that name has no service/alias on the single-bridge compose +//! network, so search was permanently broken out of the box. +//! +//! This test parses the TOML (we have the `toml` crate as a dev-dep) and checks +//! each renderer/search host against the known compose service names. We do NOT +//! parse the compose YAML — there's no YAML parser in-tree and the service list +//! is small and stable. If you add a compose service that a config host points +//! at, add it to `COMPOSE_SERVICE_NAMES` below. + +use std::path::PathBuf; + +/// Service names defined in `docker-compose.yml` / `docker-compose.stealth.yml` +/// that a config host is allowed to point at. `crw` itself is the app, not a +/// target host, so it's intentionally excluded. +/// +/// MAINTENANCE CONTRACT: if you add a compose service that `config.docker.toml` +/// points a renderer/search URL at, add its service name here. +const COMPOSE_SERVICE_NAMES: &[&str] = &["searxng", "lightpanda", "chrome", "chrome-stealth"]; + +fn repo_root() -> PathBuf { + // CARGO_MANIFEST_DIR is /crates/crw-server; go up two levels. + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest + .parent() + .and_then(|p| p.parent()) + .expect("crw-server should live at /crates/crw-server") + .to_path_buf() +} + +/// Extract the host from a `scheme://host:port/...` URL string. +fn host_of(url: &str) -> String { + url::Url::parse(url) + .unwrap_or_else(|e| panic!("config.docker.toml URL `{url}` did not parse: {e}")) + .host_str() + .unwrap_or_else(|| panic!("config.docker.toml URL `{url}` has no host")) + .to_string() +} + +#[test] +fn docker_config_hosts_match_compose_services() { + let config_path = repo_root().join("config.docker.toml"); + assert!( + config_path.exists(), + "expected config.docker.toml at {} — did the crate move relative to the repo root?", + config_path.display() + ); + + let raw = std::fs::read_to_string(&config_path).expect("read config.docker.toml"); + let doc: toml::Value = toml::from_str(&raw).expect("parse config.docker.toml"); + + // (config key path, the URL string) for every host-bearing field we ship. + let mut hosts: Vec<(&str, String)> = Vec::new(); + + let get = |table: &str, sub: &str, key: &str| -> Option { + doc.get(table)? + .get(sub)? + .get(key)? + .as_str() + .map(str::to_string) + }; + + if let Some(u) = get("renderer", "lightpanda", "ws_url") { + hosts.push(("renderer.lightpanda.ws_url", u)); + } + if let Some(u) = get("renderer", "chrome", "ws_url") { + hosts.push(("renderer.chrome.ws_url", u)); + } + if let Some(u) = doc + .get("search") + .and_then(|s| s.get("searxng_url")) + .and_then(|v| v.as_str()) + { + hosts.push(("search.searxng_url", u.to_string())); + } + + assert!( + !hosts.is_empty(), + "no renderer/search host URLs found in config.docker.toml — did the schema change?" + ); + + for (field, url) in &hosts { + let host = host_of(url); + assert!( + COMPOSE_SERVICE_NAMES.contains(&host.as_str()), + "config.docker.toml `{field}` host '{host}' is not a docker-compose service name \ + (known: {COMPOSE_SERVICE_NAMES:?}). A SaaS-only or typo'd host leaked into the \ + opencore default — see issue #90. Either fix the host or, if you added a new compose \ + service, extend COMPOSE_SERVICE_NAMES in this test." + ); + } +} diff --git a/docs/docker/index.html b/docs/docker/index.html index 3f53dacc..f8f2e265 100644 --- a/docs/docker/index.html +++ b/docs/docker/index.html @@ -294,54 +294,79 @@

Docker Compose

cd crw docker compose up -

This starts two services:

+

The bundled docker-compose.yml starts these services:

+ - + + + + + + + + - + + + + + + + + + + + + + +
Service PortDefault? Description
crw 3000API server with CDP enabledAPI server (loads config.docker.toml)
searxng8080SearXNG meta-search backend for /v1/search
lightpanda 9222Headless browser for JS renderingLightweight headless browser for JS rendering
chrome9222--profile heavyFull Chromium fallback for complex SPAs
chrome-stealth3000--profile stealthAnti-fingerprint Chromium (browserless, SSPL-licensed)
-

docker-compose.yml

+

The crw service reads its configuration from the mounted config.docker.toml (via +CRW_CONFIG=config.docker), which already points each renderer and the search backend at the matching +service name on Compose's default bridge network (lightpanda:9222, chrome:9222, searxng:8080). You +don't need to wire renderer URLs through environment variables — they're in the config file.

+

The optional chrome / chrome-stealth tiers are opt-in so small hosts skip the ~500 MB Chromium image:

+
docker compose --profile heavy up -d      # add the vanilla Chromium fallback
+docker compose --profile stealth up -d    # add the anti-fingerprint tier (review the SSPL license first)
+
+

Search (SearXNG)

+

/v1/search (and the crw_search MCP tool) is backed by the bundled searxng service, reachable inside +the Compose network as searxng:8080. This is configured by default in config.docker.toml:

+
[search]
+searxng_url = "http://searxng:8080"
+
+

To point CRW at an external SearXNG instead of the sidecar, override it without editing the file:

services:
   crw:
-    build: .
-    ports:
-      - "3000:3000"
-    depends_on:
-      - lightpanda
     environment:
-      - RUST_LOG=info
-      - CRW_RENDERER__LIGHTPANDA__WS_URL=ws://lightpanda:9222
-
-  lightpanda:
-    image: lightpanda/browser:latest
-    ports:
-      - "9222:9222"
-
-

Add Playwright (optional)

-
  playwright:
-    image: mcr.microsoft.com/playwright:v1.49.0-noble
-    command: ["npx", "playwright", "run-server", "--port=9223"]
-    ports:
-      - "9223:9223"
-
-

Then add to the crw service:

-
    environment:
-      - CRW_RENDERER__PLAYWRIGHT__WS_URL=ws://playwright:9223
+      - CRW_SEARCH__SEARXNG_URL=http://your-searxng-host:8080   # env wins over the config file
 
+
+

Two different URLs — don't confuse them. SEARXNG_BASE_URL (set on the searxng service) is +SearXNG's own self-reference for the links it renders. [search].searxng_url / +CRW_SEARCH__SEARXNG_URL is the host CRW calls. They happen to share the value searxng:8080 in the +bundled stack, but they serve different roles.

+
+
+

Cold start. crw waits for searxng to report healthy (depends_on: condition: service_healthy), +so the first search after docker compose up can take ~15–30 s once the images are present (longer on the +first pull). A target_unreachable or timeout error in the first few seconds usually just means SearXNG +hasn't finished booting yet — the server logs the configured search host at startup so you can confirm it.

+

Dockerfile

Multi-stage build for minimal image size:

FROM rust:1.93-bookworm AS builder
diff --git a/docs/docs/docker.md b/docs/docs/docker.md
index b5fdf9eb..20e8d4b7 100644
--- a/docs/docs/docker.md
+++ b/docs/docs/docker.md
@@ -19,50 +19,57 @@ cd crw
 docker compose up
 ```
 
-This starts two services:
+The bundled `docker-compose.yml` starts these services:
 
-| Service | Port | Description |
-|---------|------|-------------|
-| **crw** | 3000 | API server with CDP enabled |
-| **lightpanda** | 9222 | Headless browser for JS rendering |
+| Service | Port | Default? | Description |
+|---------|------|----------|-------------|
+| **crw** | 3000 | ✅ | API server (loads `config.docker.toml`) |
+| **searxng** | 8080 | ✅ | SearXNG meta-search backend for `/v1/search` |
+| **lightpanda** | 9222 | ✅ | Lightweight headless browser for JS rendering |
+| **chrome** | 9222 | `--profile heavy` | Full Chromium fallback for complex SPAs |
+| **chrome-stealth** | 3000 | `--profile stealth` | Anti-fingerprint Chromium (browserless, SSPL-licensed) |
 
-### docker-compose.yml
+The `crw` service reads its configuration from the mounted `config.docker.toml` (via
+`CRW_CONFIG=config.docker`), which already points each renderer and the search backend at the matching
+service name on Compose's default bridge network (`lightpanda:9222`, `chrome:9222`, `searxng:8080`). You
+don't need to wire renderer URLs through environment variables — they're in the config file.
 
-```yaml
-services:
-  crw:
-    build: .
-    ports:
-      - "3000:3000"
-    depends_on:
-      - lightpanda
-    environment:
-      - RUST_LOG=info
-      - CRW_RENDERER__LIGHTPANDA__WS_URL=ws://lightpanda:9222
+The optional `chrome` / `chrome-stealth` tiers are opt-in so small hosts skip the ~500 MB Chromium image:
 
-  lightpanda:
-    image: lightpanda/browser:latest
-    ports:
-      - "9222:9222"
+```bash
+docker compose --profile heavy up -d      # add the vanilla Chromium fallback
+docker compose --profile stealth up -d    # add the anti-fingerprint tier (review the SSPL license first)
 ```
 
-### Add Playwright (optional)
+## Search (SearXNG)
 
-```yaml
-  playwright:
-    image: mcr.microsoft.com/playwright:v1.49.0-noble
-    command: ["npx", "playwright", "run-server", "--port=9223"]
-    ports:
-      - "9223:9223"
+`/v1/search` (and the `crw_search` MCP tool) is backed by the bundled **searxng** service, reachable inside
+the Compose network as `searxng:8080`. This is configured by default in `config.docker.toml`:
+
+```toml
+[search]
+searxng_url = "http://searxng:8080"
 ```
 
-Then add to the crw service:
+To point CRW at an **external** SearXNG instead of the sidecar, override it without editing the file:
 
 ```yaml
+services:
+  crw:
     environment:
-      - CRW_RENDERER__PLAYWRIGHT__WS_URL=ws://playwright:9223
+      - CRW_SEARCH__SEARXNG_URL=http://your-searxng-host:8080   # env wins over the config file
 ```
 
+> **Two different URLs — don't confuse them.** `SEARXNG_BASE_URL` (set on the `searxng` service) is
+> SearXNG's *own* self-reference for the links it renders. `[search].searxng_url` /
+> `CRW_SEARCH__SEARXNG_URL` is the host **CRW** calls. They happen to share the value `searxng:8080` in the
+> bundled stack, but they serve different roles.
+
+> **Cold start.** `crw` waits for `searxng` to report healthy (`depends_on: condition: service_healthy`),
+> so the first search after `docker compose up` can take ~15–30 s once the images are present (longer on the
+> first pull). A `target_unreachable` or `timeout` error in the first few seconds usually just means SearXNG
+> hasn't finished booting yet — the server logs the configured search host at startup so you can confirm it.
+
 ## Dockerfile
 
 Multi-stage build for minimal image size:
diff --git a/docs/docs/mcp.md b/docs/docs/mcp.md
index ac558bf9..7f3619bb 100644
--- a/docs/docs/mcp.md
+++ b/docs/docs/mcp.md
@@ -11,7 +11,7 @@ CRW includes a built-in MCP (Model Context Protocol) server that gives any MCP-c
 | Mode | When | Tools | Description |
 |------|------|-------|-------------|
 | **Embedded** (default) | No `--api-url` / `CRW_API_URL` set | scrape, crawl, map | Self-contained. No server needed. The scraping engine runs inside the MCP process. |
-| **Proxy / Cloud** | `--api-url` / `CRW_API_URL` set | scrape, crawl, map + **search** | Forwards tool calls to a remote CRW server. Cloud mode ([fastcrw.com](https://fastcrw.com)) adds `crw_search` for web search. |
+| **Proxy / Server** | `--api-url` / `CRW_API_URL` set | scrape, crawl, map + **search** | Forwards tool calls to a remote CRW server — the [fastcrw.com](https://fastcrw.com) cloud **or your own self-hosted server**. `crw_search` works whenever that server has SearXNG configured (the Docker stack enables it by default). |
 
 ## Where to use what
 
@@ -150,7 +150,7 @@ cargo build -p crw-mcp --no-default-features --release
 | `crw_crawl` | Start async crawl → returns job ID | `POST /v1/crawl` | All modes |
 | `crw_check_crawl_status` | Poll crawl status and get results | `GET /v1/crawl/:id` | All modes |
 | `crw_map` | Discover all URLs on a site | `POST /v1/map` | All modes |
-| `crw_search` | Search the web → titles, URLs, descriptions | `POST /v1/search` | **Cloud only** |
+| `crw_search` | Search the web → titles, URLs, descriptions | `POST /v1/search` | **Server with SearXNG** (cloud or self-hosted) |
 
 ## Browser Automation (`crw-browse`)
 
@@ -232,9 +232,9 @@ For cloud mode and file-based configs, continue in [MCP Client Setup](#mcp-clien
 | `maxDepth` | integer | no | Discovery depth (default: 2) |
 | `useSitemap` | boolean | no | Read sitemap.xml (default: true) |
 
-### crw_search (cloud only)
+### crw_search (server-backed)
 
-Available only when connected to [fastcrw.com](https://fastcrw.com) via `CRW_API_URL`. Not available in embedded or self-hosted mode.
+Available when connected to a CRW **server** that has SearXNG configured — the [fastcrw.com](https://fastcrw.com) cloud, or your own self-hosted server (the Docker stack enables it by default; see [Docker → Search (SearXNG)](/docker)). Point the MCP at it with `--api-url` / `CRW_API_URL`. It is *not* available from the standalone embedded MCP binary, which has no search backend.
 
 | Parameter | Type | Required | Description |
 |-----------|------|----------|-------------|
@@ -248,7 +248,7 @@ Available only when connected to [fastcrw.com](https://fastcrw.com) via `CRW_API
 
 A clean MCP setup often assigns each CRW route a narrow purpose:
 
-- `search` for web discovery when you don't know the URL (cloud only),
+- `search` for web discovery when you don't know the URL (needs a search-enabled server),
 - `map` for site-specific URL discovery,
 - `scrape` for single-page extraction,
 - `crawl` for bounded recursive work.
diff --git a/docs/docs/search.md b/docs/docs/search.md
index b7562533..1a929973 100644
--- a/docs/docs/search.md
+++ b/docs/docs/search.md
@@ -25,7 +25,7 @@
 
 
 :::note
-**Self-hosted users**: `docker compose up` boots a SearXNG sidecar automatically. `/v1/search` is live on `http://localhost:3000` with no extra setup. To point at an existing SearXNG instance instead, set `CRW_SEARCH__SEARXNG_URL=http://your-host:8080` and remove the `searxng` service from your compose file. To disable search entirely, set `[search].enabled = false` — the route returns a clear `search_disabled` error (HTTP 503).
+**Self-hosted users**: `docker compose up` boots a SearXNG sidecar automatically (reachable inside the Compose network as `searxng:8080`). `/v1/search` is live on `http://localhost:3000` with no extra setup. To point at an existing SearXNG instance instead, set `CRW_SEARCH__SEARXNG_URL=http://your-host:8080` and remove the `searxng` service from your compose file. To disable search entirely, set `[search].enabled = false` — the route returns a clear `search_disabled` error (HTTP 503). See the [Docker → Search (SearXNG)](/docker) section for the full setup, the `SEARXNG_BASE_URL` vs `searxng_url` distinction, and cold-start timing.
 :::
 
 ## Searching the web with CRW
diff --git a/docs/docs/self-hosting.md b/docs/docs/self-hosting.md
index 14e3bce3..492a0433 100644
--- a/docs/docs/self-hosting.md
+++ b/docs/docs/self-hosting.md
@@ -21,6 +21,7 @@ curl -X POST http://localhost:3000/v1/scrape \
 ## What You Get
 
 - the same core self-hosted routes: `scrape`, `crawl`, `map`, `mcp`, `health`
+- `search` too, when you run the Docker stack — it boots a SearXNG sidecar so `/v1/search` and the `crw_search` MCP tool work out of the box (see [Docker → Search (SearXNG)](/docker))
 - optional auth with Bearer tokens
 - optional browser-backed rendering
 - your own reverse proxy, logging, rate limits, and deployment choices
diff --git a/docs/mcp/index.html b/docs/mcp/index.html
index ccefdf08..4fa75b8f 100644
--- a/docs/mcp/index.html
+++ b/docs/mcp/index.html
@@ -305,10 +305,10 @@ 

Two Modes

Self-contained. No server needed. The scraping engine runs inside the MCP process. -Proxy / Cloud +Proxy / Server --api-url / CRW_API_URL set scrape, crawl, map + search -Forwards tool calls to a remote CRW server. Cloud mode (fastcrw.com) adds crw_search for web search. +Forwards tool calls to a remote CRW server — the fastcrw.com cloud or your own self-hosted server. crw_search works whenever that server has SearXNG configured (the Docker stack enables it by default).

Where to use what

@@ -493,7 +493,7 @@

Available Tools

crw_search Search the web → titles, URLs, descriptions POST /v1/search -Cloud only +Server with SearXNG (cloud or self-hosted)

Browser Automation (crw-browse)

@@ -656,8 +656,8 @@

crw_map

Read sitemap.xml (default: true) -

crw_search (cloud only)

-

Available only when connected to fastcrw.com via CRW_API_URL. Not available in embedded or self-hosted mode.

+

crw_search (server-backed)

+

Available when connected to a CRW server that has SearXNG configured — the fastcrw.com cloud, or your own self-hosted server (the Docker stack enables it by default; see Docker → Search (SearXNG)). Point the MCP at it with --api-url / CRW_API_URL. It is not available from the standalone embedded MCP binary, which has no search backend.

@@ -701,7 +701,7 @@

crw_search (cloud only)

Example Agent Tool Flow

A clean MCP setup often assigns each CRW route a narrow purpose:

    -
  • search for web discovery when you don't know the URL (cloud only),
  • +
  • search for web discovery when you don't know the URL (needs a search-enabled server),
  • map for site-specific URL discovery,
  • scrape for single-page extraction,
  • crawl for bounded recursive work.
  • diff --git a/docs/search/index.html b/docs/search/index.html index 2d100f0d..9970a3b9 100644 --- a/docs/search/index.html +++ b/docs/search/index.html @@ -305,7 +305,7 @@

    Search

    Get API Key

    :::note -Self-hosted users: docker compose up boots a SearXNG sidecar automatically. /v1/search is live on http://localhost:3000 with no extra setup. To point at an existing SearXNG instance instead, set CRW_SEARCH__SEARXNG_URL=http://your-host:8080 and remove the searxng service from your compose file. To disable search entirely, set [search].enabled = false — the route returns a clear search_disabled error (HTTP 503). +Self-hosted users: docker compose up boots a SearXNG sidecar automatically (reachable inside the Compose network as searxng:8080). /v1/search is live on http://localhost:3000 with no extra setup. To point at an existing SearXNG instance instead, set CRW_SEARCH__SEARXNG_URL=http://your-host:8080 and remove the searxng service from your compose file. To disable search entirely, set [search].enabled = false — the route returns a clear search_disabled error (HTTP 503). See the Docker → Search (SearXNG) section for the full setup, the SEARXNG_BASE_URL vs searxng_url distinction, and cold-start timing. :::

    Searching the web with CRW

    /v1/search

    diff --git a/docs/self-hosting/index.html b/docs/self-hosting/index.html index e8378e34..35afefab 100644 --- a/docs/self-hosting/index.html +++ b/docs/self-hosting/index.html @@ -296,6 +296,7 @@

    Quick Start

    What You Get

    • the same core self-hosted routes: scrape, crawl, map, mcp, health
    • +
    • search too, when you run the Docker stack — it boots a SearXNG sidecar so /v1/search and the crw_search MCP tool work out of the box (see Docker → Search (SearXNG))
    • optional auth with Bearer tokens
    • optional browser-backed rendering
    • your own reverse proxy, logging, rate limits, and deployment choices