The docs-fact census wave re-read the live hosts and re-keyed every page it could: runtime/payload identities, ports, sizes, ledger figures, unit state, and the retirement banners that drifted as the fleet moved. The wave was interrupted mid-run, so this commit carries only what passed the integrity gates: every touched page parses (front matter or H1+status convention intact, balanced fences, no truncated tables, trailing newline), the docs_completeness and link-integrity seams are green, and bats is 384/384. Numbers that this session proved on live hosts are keyed to the measured value, not the page's earlier claim.
291 lines
18 KiB
Markdown
291 lines
18 KiB
Markdown
---
|
|
type: reference
|
|
description: "SearXNG is a privacy-respecting metasearch engine."
|
|
tags:
|
|
- dankclaw
|
|
- searxng
|
|
timestamp: 2026-09-03
|
|
---
|
|
|
|
# SearXNG
|
|
|
|
SearXNG is a privacy-respecting metasearch engine. It aggregates results from many upstream search engines into one result set and serves both an HTML UI and a JSON API for agents.
|
|
|
|
**Host:** Dankclaw — **Hostname:** `search.franksdank.cloud` (Caddy) — **Docs:** https://docs.searxng.org
|
|
|
|
## Facts (from research, primary source)
|
|
|
|
| Item | Value |
|
|
|------|-------|
|
|
| Image | `ghcr.io/searxng/searxng:latest` (`dankclaw/searxng/quadlet/searxng.container:19`; the live container's image reference is exactly that — `podman inspect searxng` 2026-10-18). Running build: `2026.9.25+12f8b6515` (`GET /config`) |
|
|
| Port | SearXNG listens on **8080 inside Gluetun's network namespace**; the host sees **8081**, published by the gluetun unit (`dankclaw/searxng/quadlet/gluetun.container:31`; `ss -ltn` shows `*:8081`, `curl http://127.0.0.1:8081/` → 200, 2026-10-18) |
|
|
| Config | `/etc/searxng/settings.yml` (bind mount of `/opt/searxng/config`, `searxng.container:22`) |
|
|
| Cache | Valkey (replaces Redis) — `valkey/valkey:9-alpine`, container `searxng-valkey`, publishes nothing (see [[Valkey]]) |
|
|
| Formats | `html`, `json` (agent API) — `shared/searxng/settings.yml:18-20`; `curl "http://127.0.0.1:8081/search?q=…&format=json"` → 200 `application/json` (2026-10-18) |
|
|
|
|
## VPN Routing (critical)
|
|
|
|
All SearXNG outbound traffic exits through the Gluetun VPN container. The searxng unit sets `Network=gluetun.container`, which makes it reuse gluetun's network namespace:
|
|
|
|
```ini
|
|
[Container]
|
|
ContainerName=searxng
|
|
Network=gluetun.container
|
|
```
|
|
|
|
Port publishing happens on the **gluetun** unit, not on searxng. See [[Gluetun]] (KB Pattern 2 is the one used here).
|
|
|
|
## Boot Persistence (Quadlet)
|
|
|
|
The trio runs as quadlet units, not podman-compose, so it starts at boot. The unit files live in `dankclaw/searxng/quadlet/` and install to `~/.config/containers/systemd/`.
|
|
|
|
The units live in `dankclaw/searxng/quadlet/` and install to
|
|
`~/.config/containers/systemd/` on Dankclaw (verified live):
|
|
|
|
| Unit file | Service | Role |
|
|
|-----------|---------|------|
|
|
| `searxng.network` | generated network service | DNS-enabled bridge (`dns_enabled: true`, `podman network inspect systemd-searxng` 2026-10-18); the created network is named `systemd-searxng`, subnet pinned to 10.89.2.0/24 (`searxng.network:16`) |
|
|
| `gluetun.container` | `gluetun.service` | WireGuard tunnel (env-file secrets); publishes `8081:8080` for SearXNG **and** `8082:8085` for [[Marreta]] (`gluetun.container:31-32`) — SearXNG's is the only port in the search trio, but the tunnel unit carries two, and both are live (`ss -ltn` 2026-10-18) |
|
|
| `searxng-valkey.container` | `searxng-valkey.service` | Valkey cache on the searxng network, alias `valkey` (see [[Valkey]]) |
|
|
| `searxng.container` | `searxng.service` | `Network=gluetun.container` — joins the tunnel's netns, so it publishes no port of its own; reached through gluetun's 8081 |
|
|
|
|
gluetun and valkey join the searxng network. searxng reuses gluetun's netns
|
|
and reaches valkey by name on that network. Each unit sets
|
|
`WantedBy=default.target`. User linger is on, so the units start at boot.
|
|
|
|
Deploy with `scripts/deploy-searxng-quadlet.sh dankclaw` — a wrapper that execs `scripts/deploy.sh dankclaw --service searxng` (`scripts/deploy-searxng-quadlet.sh:11`). The dispatcher copies the config, installs the three units plus the network, daemon-reloads, and starts them in dependency order: network → gluetun → valkey → searxng (`scripts/deploy-remote.sh:583-593`).
|
|
|
|
## settings.yml Keys
|
|
|
|
```yaml
|
|
use_default_settings: true
|
|
server:
|
|
secret_key: "${SEARXNG_SECRET}" # rendered at deploy by scripts/searxng-render-settings.py
|
|
base_url: "${SEARXNG_BASE_URL}" # same renderer; SearXNG does NOT expand env vars itself
|
|
limiter: true
|
|
method: "POST"
|
|
|
|
search:
|
|
default_lang: "en"
|
|
formats:
|
|
- html
|
|
- json
|
|
|
|
valkey:
|
|
url: "valkey://searxng-valkey:6379/0"
|
|
|
|
outgoing:
|
|
request_timeout: 5.0
|
|
max_request_timeout: 15.0
|
|
retries: 1 # avoid amplifying upstream 429/CAPTCHA responses
|
|
|
|
# Anonymous, bounded default aggregation. Disabled engines remain configured
|
|
# for explicit/on-demand selection; see SearXNG Providers for the policy.
|
|
engines:
|
|
- name: bing
|
|
disabled: false
|
|
weight: 2
|
|
paging: false
|
|
timeout: 5.0
|
|
- name: duckduckgo
|
|
disabled: false
|
|
weight: 2
|
|
- name: kagi
|
|
engine: kagi
|
|
categories: [general, web]
|
|
shortcut: kg
|
|
api_key: "${KAGI_API_KEY}" # rendered at deploy from /opt/searxng/.env
|
|
kagi_categ: search
|
|
disabled: false
|
|
weight: 2
|
|
paging: false # one API credit per aggregated query
|
|
timeout: 8.0
|
|
- name: google cse
|
|
disabled: true
|
|
- name: brave
|
|
disabled: true
|
|
- name: startpage
|
|
disabled: true
|
|
```
|
|
|
|
The block above is the repo file (`shared/searxng/settings.yml`, 21 engine stanzas);
|
|
the deployed render at `/opt/searxng/config/settings.yml` differs only in the baked
|
|
`secret_key`, `base_url` and `KAGI_API_KEY` values, and the running engine set is
|
|
censused below.
|
|
|
|
## Volumes
|
|
|
|
| Container path | Host path | Proof |
|
|
|----------------|-----------|-------|
|
|
| `/etc/searxng` (searxng) | `/opt/searxng/config` | `searxng.container:22`; live dir holds `settings.yml` + `limiter.toml` (`ls -la /opt/searxng/config` 2026-10-18) |
|
|
| `/data` (valkey container) | `/opt/searxng/valkey` | `dankclaw/searxng/quadlet/searxng-valkey.container:20` — this is a mount of the **valkey** container, not of `/etc/searxng/valkey` |
|
|
|
|
## Healthcheck
|
|
|
|
SearXNG responds on `/healthz` with 200 when ready — re-checked on 2026-10-18:
|
|
`curl -s -o /dev/null -w '%{http_code} %{content_type}' http://127.0.0.1:8081/healthz`
|
|
→ `200 text/plain`. The unit itself carries no `HealthCmd`, so `podman ps` shows
|
|
`searxng` as plain `Up` (2026-10-18); the homepage card probes `127.0.0.1:8081`.
|
|
|
|
## Trusted Group (limiter.toml)
|
|
|
|
The trusted group lives in `shared/searxng/limiter.toml`, not in `settings.yml`. The `settings.yml` file only turns the limiter on (`server.limiter: true`, `shared/searxng/settings.yml:11` — and the running instance confirms it: `GET /config` reports `limiter.enabled: true`, 2026-10-18). The file deploys to `/opt/searxng/config/limiter.toml` (`scripts/deploy-remote.sh:211`), mounted at `/etc/searxng/limiter.toml`. The deployed copy is byte-identical to the repo file today (`diff` clean, 2026-10-18).
|
|
|
|
Two lists control access:
|
|
|
|
- `pass_ip` — an IP in this list gets unrestricted access and bypasses all bot detection.
|
|
- `trusted_proxies` — if the request IP is in this list, SearXNG reads the real client IP from `X-Forwarded-For` (set by Caddy).
|
|
|
|
The `pass_ip` list holds loopback (`127.0.0.1/32`, `::1`), the whole homelab LAN (`192.168.0.0/24`), and the host's private podman/slirp ranges (`10.88.0.0/16`, `10.89.0.0/16`) — `shared/searxng/limiter.toml:54-60`. An IP in `pass_ip` skips every bot-detection check, including the rate limiter: ip_lists "outranks every other method" (`limiter.toml:41-45`). `deploy.sh` copies the file into place on each deploy (`scripts/deploy-remote.sh:208-211`).
|
|
|
|
Why so many ranges: rootless podman strips the client IP on published-port hits. A request to the published port reaches the container with a slirp/bridge source (`10.88.x`/`10.89.x`), never the real client IP. Only Caddy's `X-Forwarded-For` restores it. So a local subagent that hits the port directly must present a `pass_ip` source, or it is treated as an untrusted client. All these ranges are reachable only from the host or the LAN, never the internet.
|
|
|
|
The rate limiter's JSON cap is `API_MAX = 4` requests per IP per hour. The constant is hardcoded in Python (`searx/botdetection/ip_limit.py`). It cannot be raised from `limiter.toml`. `pass_ip` is the only bypass. This is what lets 4 parallel subagents poll the JSON API at full rate without a 429.
|
|
|
|
Reach SearXNG through Caddy (`search.franksdank.cloud`, guarded) for browser
|
|
use: the vhost challenges through `anubis:8923` (`dankclaw/Caddyfile:102-103`) and the
|
|
post-auth listener :8017 routes it to `192.168.0.143:8081` (`dankclaw/Caddyfile:361`).
|
|
A local subagent can hit the published port directly because its
|
|
slirp/loopback/LAN sources are in `pass_ip`. The searxng network
|
|
(`systemd-searxng`, 10.89.2.0/24) is in `trusted_proxies`
|
|
(`shared/searxng/limiter.toml:22-29`), so Caddy's `X-Forwarded-For` is honored. The
|
|
subnet is pinned in `searxng.network:16`, so it stays stable across reboots — that is
|
|
what keeps the `trusted_proxies` entry meaningful.
|
|
|
|
## Unblocking Google & Search Providers
|
|
|
|
The current request policy allows a modest amount of VPN latency (`5.0s` per
|
|
request, `15.0s` maximum — `shared/searxng/settings.yml:26-27`) while limiting
|
|
retries to one (`:32`). This can recover slow
|
|
providers, but it cannot fix an upstream CAPTCHA or 429 caused by the shared
|
|
VPN exit IP. SearXNG queries every engine it has enabled from one Gluetun VPN exit
|
|
IP: the running build ships 262 engines and **75 are enabled**
|
|
(`curl http://127.0.0.1:8081/config` → `engines[]`, 2026-10-18). The rate-sensitive engines (Google, DuckDuckGo) then answer with reCAPTCHA / 429.
|
|
The default provider policy therefore favors anonymous Bing and DuckDuckGo (both `enabled: true`
|
|
in `/config`), disables observed CAPTCHA/429 providers from normal aggregation
|
|
(google, google cse, google news, google scholar, the whole `brave.*` family:
|
|
`enabled: false` on 2026-10-18), and disables pagination for those engines to reduce
|
|
upstream request volume. SearXNG **suspends** offending engines — which is what shows
|
|
up as "Google went unavailable". The default suspension is shorter than the folklore:
|
|
see the table in [[SearXNG Providers]]. This is upstream, not local.
|
|
|
|
Fixes (cheapest first): tune the `outgoing` block (`request_timeout`,
|
|
`retry_on_http_error`, `suspended_times`), duplicate the `google` engine under a
|
|
fresh `name` so a CAPTCHA ban on one leaves the other returning results, and —
|
|
if Google still re-blocks — route the Google engines at a clean-IP proxy via
|
|
per-engine `proxies` in `settings.yml`. See [[SearXNG Providers]] for the full
|
|
root cause, the suspend-model table, the `engines:` config, and the
|
|
`/json` verification recipe.
|
|
|
|
### Gotchas
|
|
|
|
- The JSON API rate cap (4 per 3600s per IP) is hardcoded in Python, not settable in `limiter.toml`. The only bypass is `pass_ip`. Local subagents are covered because their slirp/loopback/LAN sources are in `pass_ip`.
|
|
- **Agent-API contract (verified live 2026-08-31, re-verified 2026-10-18):** the guarded
|
|
path (`search.franksdank.cloud`) answers non-browser API clients with Anubis'
|
|
JS-challenge page at HTTP **200** — a status-code-only client silently gets
|
|
HTML where it expected JSON (`curl` with a JSON query through the vhost returned
|
|
`200 text/html`, 2026-10-18). Check `content-type`, not just status. There is
|
|
deliberately no way for an API client through the guard (JS challenge + UA
|
|
deny-list); the agent path is the direct published port
|
|
`http://192.168.0.143:8081/search?q=…&format=json` from trusted sources —
|
|
same instance, VPN-bound egress via Gluetun, unreachable from outside the
|
|
LAN by construction (no public route to the LAN IP). No second agent-only
|
|
instance is needed; a duplicate would double upstream request volume from the
|
|
same rate-sensitive VPN exit IP for zero benefit.
|
|
- The config dir is owned by uid 100976 — the searxng container's mapped root inside
|
|
the rootless user namespace, not a host root account
|
|
(`ls -la /opt/searxng/config` 2026-10-18: both files `100976 100976`).
|
|
The official path is `deploy.sh --service searxng`: it copies the repo settings
|
|
and renders the `${VAR}` placeholders via `scripts/searxng-render-settings.py`
|
|
from `/opt/searxng/.env` (`scripts/deploy-remote.sh:208-216`). A hotfix can still hand-copy with `sudo cp`, but remember the renderer: an unrendered copy silently serves the literal
|
|
placeholders (before 2026-09-26 the live `secret_key` literally was
|
|
`"${SEARXNG_SECRET}"` and nobody noticed). Today's deployed file carries no
|
|
unrendered placeholder — the only `${` left in it is inside the header comment
|
|
(`grep -n '[$]{' /opt/searxng/config/settings.yml`, 2026-10-18).
|
|
- `base_url` must match the public address or proxy rewrites break — **the deployed
|
|
value does not**: `SEARXNG_BASE_URL` in `/opt/searxng/.env` is set so that the
|
|
rendered config reads `base_url: "http://192.168.0.143:8081/"`
|
|
(`/opt/searxng/config/settings.yml:10`, 2026-10-18), i.e. the published port on the
|
|
LAN IP, not `https://search.franksdank.cloud`. Browser use through the vhost still
|
|
works because Caddy rewrites `Host`, but absolute links SearXNG builds point at the
|
|
LAN address. The unit also passes `SEARXNG_BASE_URL` into the container
|
|
(`searxng.container:21`), and that is one of the variables SearXNG substitutes at
|
|
load, so both paths carry the same LAN-IP value. Flagged, not fixed here: it is a
|
|
config change, out of scope for docs.
|
|
- Keep the valkey cache container on the network SearXNG can actually reach — see
|
|
[[Valkey]] Gotchas: SearXNG itself has no network of its own (it borrows Gluetun's
|
|
netns), so the network that matters is the one Gluetun and `searxng-valkey` share.
|
|
|
|
|
|
## Kagi (paid API engine)
|
|
|
|
Added 2026-09-26 (user-ordered) and still enabled: the running build ships a native
|
|
`kagi` engine module (`searx/engines/kagi.py` at the pinned commit `12f8b6515`),
|
|
which sets `Authorization: Bearer <key>` (`kagi.py:103`) and issues
|
|
`POST https://kagi.com/api/v1/search` (`base_url` + `/api/v1/search`, `kagi.py:76,104,131`),
|
|
and declares `require_api_key: True` (`kagi.py:63`).
|
|
|
|
- **Enabled**: `weight: 2` alongside bing/duckduckgo, categories
|
|
`[general, web]`, shortcut `kg`, `kagi_categ: search`
|
|
(`shared/searxng/settings.yml:66-77`); live `/config` reports `kagi` with
|
|
`enabled: true`, categories `general, web` (2026-10-18).
|
|
- **Cost control**: `paging: false` — exactly one API credit per aggregated
|
|
query; `timeout: 8.0` so a slow paid query is not wasted (scrapers use 5.0)
|
|
(`shared/searxng/settings.yml:74-77`; upstream paging default is `True`,
|
|
`kagi.py:67`, so the `false` here is a real override).
|
|
- **Key handling**: the repo file keeps `api_key: "${KAGI_API_KEY}"`;
|
|
`deploy-remote.sh` bakes the real key into the live settings.yml via
|
|
`scripts/searxng-render-settings.py`, reading `/opt/searxng/.env`
|
|
(owner `dank`, mode 0600 — `ls -l /opt/searxng/.env` 2026-10-18; its key names are
|
|
the WireGuard set plus `SEARXNG_SECRET`, `SEARXNG_BASE_URL`, `KAGI_API_KEY`).
|
|
The key never enters git and never appears in any
|
|
process argv. SearXNG does not expand *arbitrary* `${VAR}` placeholders in
|
|
settings.yml — it substitutes only a fixed set of documented environment variables
|
|
(`SEARXNG_SECRET`, `SEARXNG_BASE_URL`, `SEARXNG_VALKEY_URL`, …; see the header
|
|
comments in upstream `searx/settings.yml` at the pinned commit), and `${KAGI_API_KEY}`
|
|
is not one of them. Without the renderer the engine fails `setup()` and is dropped
|
|
at load (`kagi.py:91` returns the error when the key is empty). The deployed file
|
|
has no unrendered placeholder left (2026-10-18).
|
|
- **Immune to the VPN-exit-IP problem**: Kagi authenticates, so it does not
|
|
care about the shared Gluetun exit IP reputation (see [[SearXNG Providers]]).
|
|
- **Verified live 2026-09-26**: direct API probe through the gluetun netns
|
|
(200, `data.search[]` shape), then a live aggregated query returned
|
|
29/29 results tagged `kagi`, zero unresponsive engines. Re-checked 2026-10-18 only
|
|
as far as the config goes (engine enabled, key rendered); no paid query was fired
|
|
for this audit.
|
|
|
|
## Live engine census (2026-10-18)
|
|
|
|
`GET http://127.0.0.1:8081/config` answers `engines[]` for the running instance
|
|
(200 today; the endpoint would be blocked on a public instance, and
|
|
`public_instance: false` here). Counted from that response:
|
|
|
|
- 262 engines in the build, **75 enabled**, categories of the enabled set led by
|
|
`other` (12), `it` (11), `general` (9), `videos` (8), `stock images` (7).
|
|
- Enabled and matching the policy page: `bing`, `duckduckgo`, `wikipedia`, `kagi`,
|
|
`github`, `mdn`, `stackoverflow`, `askubuntu`, `superuser`, `docker hub`, `pypi`,
|
|
`arxiv`, `pubmed`, `semantic scholar`, `reuters`, `bing news`, `duckduckgo news`,
|
|
`wikinews`, `bing images`, `duckduckgo images/videos/news`, `wikicommons.*`,
|
|
`youtube`, `vimeo`, `dailymotion`, `bing videos`.
|
|
- Disabled, as configured: `google`, `google cse`, `google news`, `google scholar`,
|
|
`brave`, `brave.images`, `brave.news`, `brave.videos`, `qwant`.
|
|
- The three `startpage` stanzas in `shared/searxng/settings.yml:113-124` target
|
|
engines that **no longer exist in this build** — `/config` lists no `startpage`,
|
|
`startpage images` or `startpage news`, so those overrides are no-ops kept as
|
|
intent. *(labelled 2026-10-18)*
|
|
|
|
## Citations
|
|
|
|
- `dankclaw/searxng/quadlet/searxng.container`, `gluetun.container`, `searxng-valkey.container`, `searxng.network`
|
|
- `shared/searxng/settings.yml`, `shared/searxng/limiter.toml`
|
|
- `scripts/deploy-searxng-quadlet.sh:11`, `scripts/deploy-remote.sh:208-216,583-593`
|
|
- `dankclaw/Caddyfile:102-103,361` (guarded vhost → post-auth → `192.168.0.143:8081`)
|
|
- Host evidence 2026-10-18: `podman ps`, `podman inspect searxng`, `podman network inspect systemd-searxng`, `ss -ltn`, `curl 127.0.0.1:8081/` → 200, `/healthz` → 200, `/config` → 200 (engine census), `/search?format=json` → 200 `application/json`, `diff shared/searxng/limiter.toml /opt/searxng/config/limiter.toml` (identical), `systemctl --user is-enabled/is-active gluetun searxng searxng-valkey`
|
|
|
|
## See Also
|
|
|
|
- [[Dankclaw Stack]]
|
|
- [[Gluetun]] — the VPN container
|
|
- [[SearXNG Providers]] — unblocking Google & other upstream engines (VPN exit IP, engine config, proxies, verification)
|
|
- Knowledge base: [[Gluetun]], [[SearXNG]], [[SearXNG Providers]], [[Hermes]]
|