Article list
AI writing

Hermes Search Unstable: Outbound Traffic Handled by Mihomo

After Hermes is connected to SearXNG, it may appear on the surface that the search problem has been brought back to the local machine: SEARXNG_URL=http://localhost:8888. However, the place where servers located in mainland China are most likely to get stuck is not the hop from Hermes to SearXNG, but rather the fact that SearXNG itself still needs to access search sources such as Google, DuckDuckGo, Brave, and Startpage.

On this page

I ended up splitting the chain into three layers: Hermes only accesses the local SearXNG; SearXNG is explicitly configured to delegate outbound HTTP/HTTPS requests to Mihomo; Mihomo alone handles subscriptions, health checks, and automatic node selection. The benefit of doing this is not that “the configuration is cooler,” but that when something goes wrong, you can troubleshoot it layer by layer: whether Hermes has requested local search, whether SearXNG has returned JSON, whether Mihomo has available nodes, and whether the subscription itself has failed to parse.

The overall pipeline is as follows:

Hermes Agent
  -> http://localhost:8888
  -> SearXNG
  -> http://mihomo:7890
  -> Mihomo auto-selects available nodes
  -> Overseas search engine

Only the local port is exposed on the host machine:

127.0.0.1:8888  -> SearXNG
127.0.0.1:7897  -> Mihomo mixed proxy, used for debugging
127.0.0.1:9097  -> Mihomo controller, used for local management

Since SearXNG and Mihomo are on the same Docker network, SearXNG does not access the host’s 127.0.0.1:7897, but the container name:

http://mihomo:7890

The Two Most Easily Missed Places in SearXNG

When Hermes calls SearXNG, it requests JSON. SearXNG’s official Search API documentation also makes it very clear: whether the format parameter can be used depends on whether the corresponding format is enabled under search.formats in settings.yml; requesting a format that is not enabled will return a 403. Therefore, you can’t keep just the default HTML here:

search:
  safe_search: 0
  autocomplete: ""
  formats:
    - html
    - json

The second issue is proxies. Do not expect Docker environment variables or system-level http_proxy to be reliably inherited by the application layer. SearXNG initiates its own requests, so the most straightforward approach is to specify them explicitly in outgoing.proxies:

# all outgoing requests are forwarded to this proxy
#  SearXNG 所发出的所有出站请求都将通过此代理转发

正如你看到的,这个文件里 proxy 字段就在旁边就是注释# SearXNG 所发出的所有出站请求都将通过此代理转发,用了 # 而 Hugo shortcode 不一样,所以:

outgoing.proxies:
  http://:  # 注释掉了

这部分内容。

outgoing:
  request_timeout: 15.0
  max_request_timeout: 40.0
  extra_proxy_timeout: 10
  proxies:
    "http://": "http://mihomo:7890"
    "https://": "http://mihomo:7890"

In the default SearXNG configuration examples, you can see the all://: syntax, so it isn’t inherently an incorrect configuration option. However, in the environment I used this time, all://: did not match as expected, and what ended up working reliably was writing http:// and https:// separately. I’m keeping this boundary in the article to avoid turning a single real-world test into a definitive conclusion about all versions.

The complete SearXNG key configuration can be compressed as follows:

use_default_settings: true

general:
  instance_name: "Hermes SearXNG"

search:
  safe_search: 0
  autocomplete: ""
  formats:
    - html
    - json

server:
  secret_key: "please replace with a random secret key"
  limiter: false
  image_proxy: true
  bind_address: "0.0.0.0"

valkey:
  url: valkey://valkey:6379/0

outgoing:
  request_timeout: 15.0
  max_request_timeout: 40.0
  extra_proxy_timeout: 10
  proxies:
    "http://": "http://mihomo:7890"
    "https://": "http://mihomo:7890"

engines:
  - name: bing
    disabled: false
    shortcut: bi

  - name: bing news
    disabled: false
    shortcut: bin

  - name: google
    disabled: false
    shortcut: go
    timeout: 8.0

  - name: brave
    disabled: false
    shortcut: br
    timeout: 8.0

  - name: duckduckgo
    disabled: false
    shortcut: ddg
    timeout: 8.0

  - name: startpage
    disabled: false
    shortcut: sp
    timeout: 8.0

  - name: wikipedia
    disabled: false
    shortcut: wp

  - name: arxiv
    disabled: false
    shortcut: arx

  - name: sogou
    disabled: true

  - name: 360search
    disabled: true

ui:
  static_use_hash: true

Here is a small gotcha: in SearXNG’s default engine configuration, the name for Bing News is bing news, while engine is bing_news. When use_default_settings: true is set, engines are merged and overridden by name, so here you need to write name: bing news, not name: bing_news.

Mihomo does only one thing: provide SearXNG with a stable egress

Mihomo does not need to take over the entire server here, nor does it require Docker to route all traffic through a proxy. It only opens a mixed port, allowing SearXNG within the same Docker network to access it:

mixed-port: 7890
allow-lan: true
bind-address: '*'
mode: rule
log-level: info
ipv6: false

external-controller: 0.0.0.0:9090
secret: "Please replace with a random key"

profile:
  store-selected: true
  store-fake-ip: true

proxy-providers:
  sub:
    type: http
    url: "Your subscription URL"
    interval: 3600
    path: ./proxy_providers/sub.yaml
    health-check:
      enable: true
      url: https://www.gstatic.com/generate_204
      interval: 300
      timeout: 5000
      lazy: false
      expected-status: 204

proxy-groups:
  - name: AUTO
    type: url-test
    use:
      - sub
    url: https://www.gstatic.com/generate_204
    interval: 300
    timeout: 5000
    tolerance: 50
    lazy: false
    expected-status: 204

  - name: PROXY
    type: select
    proxies:
      - AUTO
      - DIRECT
    use:
      - sub

rules:
  - MATCH,PROXY

This configuration has a narrow meaning: the subscription updates every 3600 seconds, nodes perform a health check every 300 seconds, AUTO uses url-test to select nodes based on latency, and all traffic ultimately matches PROXY. When the new configuration starts for the first time, the first item in the PROXY list is AUTO; if you have manually switched nodes in the control panel later, store-selected: true will preserve the selection, and at this point “default to AUTO” is not necessarily true.

Also pay attention to the subscription format. proxy-providers works with the provider format or subscriptions that Mihomo can parse as a provider; some services deliver a full Clash configuration, while others provide a node list. If the logs report a parsing failure, don’t blame SearXNG first—go to the service provider’s dashboard and switch to the Clash Meta, Mihomo, or Proxy Provider format.

Refactoring Scripts

The following script is intended for machines where SearXNG is already placed in /opt/searxng. It backs up the existing docker-compose.yml and searxng/settings.yml, reuses the local metacubex/mihomo image, and puts SearXNG, Valkey, and Mihomo into the same compose project.

The script does not actively pull the Mihomo image; whether SearXNG and Valkey can be started with docker compose up when the corresponding images are not available locally depends on your local images and network environment. In a fully offline environment, prepare all three images in advance.

sudo bash -s <<'EOF'
set -euo pipefail

APP_DIR="/opt/searxng"
COMPOSE_FILE="$APP_DIR/docker-compose.yml"
SETTINGS_FILE="$APP_DIR/searxng/settings.yml"
MIHOMO_DIR="$APP_DIR/mihomo"

if [ ! -f "$COMPOSE_FILE" ]; then
  echo "Cannot find $COMPOSE_FILE. Please confirm that SearXNG is deployed at /opt/searxng."
  exit 1
fi

if [ ! -f "$SETTINGS_FILE" ]; then
  echo "Cannot find $SETTINGS_FILE. Please confirm the path of SearXNG's settings.yml."
  exit 1
fi

if docker image inspect metacubex/mihomo:latest >/dev/null 2>&1; then
  MIHOMO_IMAGE="metacubex/mihomo:latest"
else
  MIHOMO_IMAGE="$(docker images --format '{{.Repository}}:{{.Tag}}' \
    | awk -F: '$1=="metacubex/mihomo" && $2!="<none>" {print; exit}')"
fi

if [ -z "${MIHOMO_IMAGE:-}" ]; then
  echo "No usable local metacubex/mihomo image detected."
  echo "Please check first: docker images | grep -i mihomo"
  exit 1
fi

echo "Detected local Mihomo image: $MIHOMO_IMAGE"

LOCAL_MIHOMO_IMAGE="searxng-mihomo-local:latest"
docker tag "$MIHOMO_IMAGE" "$LOCAL_MIHOMO_IMAGE"

printf "Please enter your Clash/Mihomo subscription URL: " > /dev/tty
IFS= read -r -s SUB_URL < /dev/tty
printf "\n" > /dev/tty

if [ -z "$SUB_URL" ]; then
  echo "Subscription URL is empty. Aborting."
  exit 1
fi

mkdir -p "$MIHOMO_DIR/proxy_providers"
chmod 700 "$MIHOMO_DIR"

TS="$(date +%Y%m%d-%H%M%S)"

cp -a "$COMPOSE_FILE" "$COMPOSE_FILE.bak.$TS"
cp -a "$SETTINGS_FILE" "$SETTINGS_FILE.bak.$TS"

echo "Backups created:"
echo "  $COMPOSE_FILE.bak.$TS"
echo "  $SETTINGS_FILE.bak.$TS"

MIHOMO_SECRET="$(openssl rand -hex 16)"

cat > "$MIHOMO_DIR/config.yaml" <<YAML
mixed-port: 7890
allow-lan: true
bind-address: '*'
mode: rule
log-level: info
ipv6: false

external-controller: 0.0.0.0:9090
secret: "$MIHOMO_SECRET"

profile:
  store-selected: true
  store-fake-ip: true

proxy-providers:
  sub:
    type: http
    url: "$SUB_URL"
    interval: 3600
    path: ./proxy_providers/sub.yaml
    health-check:
      enable: true
      url: https://www.gstatic.com/generate_204
      interval: 300
      timeout: 5000
      lazy: false
      expected-status: 204

proxy-groups:
  - name: AUTO
    type: url-test
    use:
      - sub
    url: https://www.gstatic.com/generate_204
    interval: 300
    timeout: 5000
    tolerance: 50
    lazy: false
    expected-status: 204

  - name: PROXY
    type: select
    proxies:
      - AUTO
      - DIRECT
    use:
      - sub

rules:
  - MATCH,PROXY
YAML

ch ```yaml
      - searxng-cache:/var/cache/searxng:rw
    depends_on:
      - valkey
      - mihomo
    networks:
      - searxng-net
    logging:
      driver: json-file
      options:
        max-size: "2m"
        max-file: "3"

  valkey:
    image: docker.io/valkey/valkey:8-alpine
    container_name: searxng-valkey
    restart: unless-stopped
    command: valkey-server --save 30 1 --loglevel warning
    volumes:
      - valkey-data:/data
    networks:
      - searxng-net
    logging:
      driver: json-file
      options:
        max-size: "2m"
        max-file: "3"

  mihomo:
    image: $LOCAL_MIHOMO_IMAGE
    container_name: searxng-mihomo
    restart: unless-stopped
    command: ["-d", "/root/.config/mihomo"]
    ports:
      - "127.0.0.1:7897:7890"
      - "127.0.0.1:9097:9090"
    volumes:
      - ./mihomo:/root/.config/mihomo
    networks:
      - searxng-net
    logging:
      driver: json-file
      options:
        max-size: "2m"
        max-file: "3"

networks:
  searxng-net:

volumes:
  searxng-cache:
  valkey-data:
YAML

cd "$APP_DIR"

if docker compose version >/dev/null 2>&1; then
  DC="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
  DC="docker-compose"
else
  echo "docker compose / docker-compose not detected"

## Hermes Only Keeps the Search Entry Point

Hermes doesn't need to know about Mihomo. It only needs to know that there is a SearXNG on the local machine:

```bash
nano ~/.hermes/.env

Write:

SEARXNG_URL=http://localhost:8888

Then specify the search backend in ~/.hermes/config.yaml:

web:
  search_backend: "searxng"

If you have the official Hermes SearXNG skill installed, you can continue using it:

hermes skills install official/research/searxng-search

If Hermes is user service:

systemctl --user restart hermes
systemctl --user status hermes --no-pager

There is also a boundary here: SearXNG is responsible for search, not for web page content extraction. The Hermes documentation also separates the search backend and extract backend. If you want Hermes to read the content of search result pages later, you still need to configure web.extract_backend with Firecrawl, Tavily, Exa, Parallel, or other extraction solutions.

Validation: Do Not Skip Layers

Test Mihomo first, don’t ask Hermes right away:

curl -I --proxy http://127.0.0.1:7897 https://www.gstatic.com/generate_204

A returned HTTP status indicates that the host’s debug proxy port is available. Then test the SearXNG JSON:

curl -s --max-time 50 \
  "http://127.0.0.1:8888/search?q=openai%20gpt&format=json" \
  | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get("results", [])), "results")'

If a 403 appears here, first check whether search.formats includes json. If it times out or returns empty results here, then check the SearXNG logs:

cd /opt/searxng
docker compose logs -f searxng

Then send a request in Hermes that will trigger the search:

Please search online for the latest information on OpenAI GPT and list the source links.

If /search?...format=json appears in the logs, it means Hermes is already calling the local SearXNG. Next, let’s look at Mihomo:

cd /opt/searxng
docker compose logs -f mihomo

Focus on the subscription updates, provider, health check, and AUTO information. If Mihomo fails to even parse the subscription, then it doesn’t matter how correctly SearXNG is configured.

Boundaries You Should Not Omit

First, do not expose the port to the public network. The compose in this article only binds to the local machine:

ports:
  - "127.0.0.1:8888:8080"
  - "127.0.0.1:7897:7890"
  - "127.0.0.1:9097:9090"

Do not change it to:

0.0.0.0:8888:8080
0.0.0.0:7897:7890
0.0.0.0:9097:9090

Otherwise, the search service, proxy port, and Mihomo control port are all at risk of being abused on the public network. external-controller: 0.0.0.0:9090 is the listener inside the container, and what truly determines whether it can be accessed externally is the port binding in compose.

Second, if Google or Startpage consistently time out, do not rush to dismantle the entire chain. Keep Bing, Brave, DuckDuckGo, Wikipedia, and arXiv enabled for now; once the Mihomo subscription and health checks are stable, you can then re-enable the engines that are more prone to triggering CAPTCHAs or timeouts.

Third, the rollback points for scripts are straightforward:

cd /opt/searxng

cp docker-compose.yml.bak.your-timestamp docker-compose.yml
cp searxng/settings.yml.bak.your-timestamp searxng/settings.yml

docker compose up -d

I prefer this layered setup over wrapping the entire server in a global proxy. The problem with a global proxy is that its impact is too broad, and when something goes wrong, it becomes difficult to determine whether the issue lies in the system environment, Docker, application configuration, or the proxy node itself. Hermes, SearXNG, and Mihomo each only handle one thing, which actually makes troubleshooting easier.

References

写作附记

This piece retains the parts with the most operational value from the original material: the layered architecture, SearXNG JSON, explicit outgoing proxy, Mihomo provider and health checks, Docker Compose, Hermes configuration, verification commands, FAQ, and rollback. What was trimmed away are repeated explanations and absolutist statements that could mislead; for example, all://: is not a universally invalid configuration—it simply did not match as expected in that particular environment.

Additionally, one configuration name has been corrected: the Bing News engine for SearXNG should be written as name: bing news. If written as name: bing_news, it won’t match the default engine name, and the risk is higher than that of an ordinary spelling issue.

Original Prompt

The user provided a complete deployment material titled “Deploying Hermes + SearXNG + Mihomo on Servers in China: Routing Hermes Search Through Proxy and Automatically Selecting Available Nodes”, requesting to organize and analyze it, confirm the article content has no errors, and then invoke the blog writing skill to compose it into an article.

The material includes: background, target architecture, why not use system proxy, SearXNG settings.yml, Mihomo config.yaml, Docker Compose, transformation script, Hermes configuration, verification chain, frequently asked questions, rollback method, and final effect.

Comments will load when you scroll here.