testsAndMisc/linux_configuration/features/setup_searxng.sh
Krzysztof kuhy Rudnicki f15f00f3fe Re-point paths for the ~ reorganisation (home-tidy)
Also bumps the 16 meta/requirements pins the freshness gate flagged
(anthropic 1.5.0, numpy 2.5.3, pytest-randomly 5, flake8-bugbear 26.9.9,
ruff 0.16.7, pyright 1.1.414, selenium 4.49.0, hypothesis 6.168.0, lxml
6.1.3, pipdeptree 4.2.5, the two types-* stubs) and Flutter 3.47.3 in
meta/.fvmrc. One test still expected the pre-move ~/mcp-servers path.

The pre-commit hook fed .zsh files to shfmt and shellcheck, neither of
which parses zsh (SC2148/SC1071), so no zsh file could be committed since
the hook was tightened on 2026-08-22; those now get `zsh -n` instead. The
hook's own shfmt pass also staged its reformat of 8 shell files.

CI_GREEN_SKIP because main is red on exactly the dependency-freshness
finding this commit fixes, which is the documented use of that flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
2026-09-11 20:50:19 +02:00

184 lines
6.7 KiB
Bash
Executable File

#!/bin/bash
#
# setup_searxng.sh — SearXNG metasearch at searx.kuhy.duckdns.org
#
# Runs a private SearXNG instance behind the existing shared Caddy edge
# (gitea-caddy), configured to earn an A+ Mozilla Observatory grade while
# staying "Vanilla" (settings-only changes — no template or static-file edits).
#
# No DuckDNS work is needed: DuckDNS wildcards *.kuhy.duckdns.org, so adding the
# Caddy snippet is enough to get a certificate.
#
# What it does (idempotent — safe to re-run):
# - Installs runtime deps.
# - Writes ~/services/searxng/{docker-compose.yml,core-config/settings.yml}.
# - Runs valkey (limiter backend) on 127.0.0.1:6379.
# - Runs searxng on 127.0.0.1:8090, both with network_mode: host.
# - Adds a reverse-proxy + security-header snippet to the shared edge.
# - Opens the firewall via setup_wireguard_ssh.sh (already open in practice).
#
# Why network_mode: host — this host's nftables ruleset has
# `chain forward { policy drop; }` with no accept rules, and a DROP at
# NF_INET_FORWARD is terminal regardless of Docker's own legacy-iptables
# ACCEPTs. Docker BRIDGE networking therefore has zero outbound access here, so
# a stock bridge-network SearXNG cannot reach a single search engine. Host
# networking sidesteps the FORWARD chain entirely. The cost is that there is no
# bridge isolation left, so both containers are pinned to loopback explicitly
# and that binding is asserted before the site is published.
#
# Reboot survival: containers use restart: unless-stopped and docker.service is
# enabled — no systemd unit is needed.
#
# Usage:
# setup_searxng.sh [setup] Deploy (default).
# setup_searxng.sh status Self-diagnose the deployment.
# setup_searxng.sh help Show this help.
#
# Prerequisite: setup_gitea.sh must have been run at least once (the gitea-caddy
# edge must exist). Run as your normal user, not root.
set -euo pipefail
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly SCRIPT_DIR
# shellcheck source=../lib/common.sh
source "${SCRIPT_DIR}/../lib/common.sh"
# --- Configuration ----------------------------------------------------------
readonly SEARX_DOMAIN="searx.kuhy.duckdns.org"
# 8080 is granian's default AND is already taken on this host, so it is set
# explicitly rather than left alone.
readonly SEARX_PORT="8090"
readonly VALKEY_PORT="6379"
# searxng/searxng:latest as of 2026-08-09 == release 2026.8.4. See the comment
# on the image: line in the compose heredoc for why this is pinned.
readonly SEARX_IMAGE="searxng/searxng@sha256:f4c8e59de166ed71f6380c0847c312ca51f0d41996e31d0559163b6b09ecde52"
readonly SEARX_DATA_DIR="${HOME}/services/searxng"
readonly SEARX_COMPOSE="${SEARX_DATA_DIR}/docker-compose.yml"
readonly SEARX_CONFIG_DIR="${SEARX_DATA_DIR}/core-config"
readonly SEARX_SETTINGS="${SEARX_CONFIG_DIR}/settings.yml"
readonly SEARX_LIMITER="${SEARX_CONFIG_DIR}/limiter.toml"
# Shared edge owned by setup_gitea.sh.
readonly GITEA_DATA_DIR="${HOME}/services/gitea"
readonly SITES_DIR="${GITEA_DATA_DIR}/sites"
readonly SEARX_SNIPPET="${SITES_DIR}/searx.caddy"
readonly CADDY_CONTAINER="gitea-caddy"
readonly SEARX_CONTAINER="searxng"
readonly VALKEY_CONTAINER="searxng-valkey"
# Sibling script (single source of truth for the firewall).
readonly WG_SCRIPT="${SCRIPT_DIR}/setup_wireguard_ssh.sh"
die() {
log_error "$1"
exit 1
}
usage() {
grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
exit "${1:-0}"
}
# --- Phase 1: preflight -----------------------------------------------------
port_in_use() {
local port="$1"
ss -ltn 2>/dev/null | grep -qE "[:.]${port}[[:space:]]"
}
# A port held by one of OUR OWN containers is a re-run, not a conflict. Only a
# foreign holder is fatal, otherwise the second `setup` run can never succeed.
port_held_by_us() {
local port="$1" name pid listeners
for name in "$SEARX_CONTAINER" "$VALKEY_CONTAINER"; do
pid="$(docker inspect -f '{{.State.Pid}}' "$name" 2>/dev/null || echo "")"
[[ -n $pid && $pid != "0" ]] || continue
listeners="$(sudo -n ss -ltnp 2>/dev/null | grep "pid=${pid}," || true)"
if grep -qE "[:.]${port}[[:space:]]" <<<"$listeners"; then
return 0
fi
done
return 1
}
preflight() {
[[ ${EUID} -ne 0 ]] || die "Run as your normal user, not root."
install_missing_pacman_packages docker docker-compose openssl curl
is_service_active docker || die "docker.service is not running."
docker ps --format '{{.Names}}' | grep -qx "$CADDY_CONTAINER" ||
die "The ${CADDY_CONTAINER} edge is not running — run setup_gitea.sh first."
local port
for port in "$SEARX_PORT" "$VALKEY_PORT"; do
if port_in_use "$port" && ! port_held_by_us "$port"; then
die "Port ${port} is already in use by something else. Free it or change the port."
fi
done
log_ok "Preflight passed (edge up, ports ${SEARX_PORT}/${VALKEY_PORT} available)."
}
# --- Phase 2: write the stack -----------------------------------------------
# Regenerating secret_key on every run would invalidate every image-proxy HMAC
# and every stored preference cookie, so an existing one is reused. openssl,
# not an inline python heredoc (house shell rule).
read_or_make_secret() {
local existing=""
if [[ -f $SEARX_SETTINGS ]]; then
existing="$(grep -oP '^\s*secret_key:\s*"\K[^"]+' "$SEARX_SETTINGS" 2>/dev/null || true)"
fi
if [[ -n $existing ]]; then
printf '%s' "$existing"
else
openssl rand -hex 32
fi
}
# The container entrypoint chowns core-config/ and settings.yml to its own
# searxng user (uid 977), after which this script's own user can no longer
# overwrite them — so a second `setup` run dies with "Permission denied" unless
# ownership is reclaimed first. Idempotence is the whole point of this script,
# so reclaim rather than skip.
reclaim_config_ownership() {
local target
for target in "$SEARX_CONFIG_DIR" "$SEARX_SETTINGS" "$SEARX_LIMITER"; do
[[ -e $target ]] || continue
[[ -O $target ]] && continue
if ! sudo -n chown "$(id -u):$(id -g)" "$target" 2>/dev/null; then
die "${target} is owned by the container (uid 977) and passwordless sudo is unavailable. Run: sudo chown -R $(id -u):$(id -g) ${SEARX_CONFIG_DIR}"
fi
log_info "Reclaimed ownership of ${target} from the container."
done
}
# shellcheck source=lib/searxng_stack.sh
source "$SCRIPT_DIR/lib/searxng_stack.sh"
# shellcheck source=lib/searxng_settings.sh
source "$SCRIPT_DIR/lib/searxng_settings.sh"
# shellcheck source=lib/searxng_limiter.sh
source "$SCRIPT_DIR/lib/searxng_limiter.sh"
# shellcheck source=lib/searxng_start.sh
source "$SCRIPT_DIR/lib/searxng_start.sh"
# shellcheck source=lib/searxng_expose.sh
source "$SCRIPT_DIR/lib/searxng_expose.sh"
# shellcheck source=lib/searxng_status.sh
source "$SCRIPT_DIR/lib/searxng_status.sh"
main() {
local cmd="${1:-setup}"
case "$cmd" in
setup)
setup_cmd
;;
status)
status_cmd
;;
help | -h | --help)
usage
;;
*)
log_error "Unknown command: $cmd"
usage 1
;;
esac
}
main "$@"