diff --git a/dankclaw/searxng/quadlet/gluetun.container b/dankclaw/searxng/quadlet/gluetun.container new file mode 100644 index 0000000..34bca84 --- /dev/null +++ b/dankclaw/searxng/quadlet/gluetun.container @@ -0,0 +1,39 @@ +# Quadlet unit: Gluetun VPN tunnel (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/gluetun.container +# Unit name: gluetun.service (container name: gluetun) +# +# Creates the WireGuard tunnel to ProtonVPN (US-WA). SearXNG reuses this +# container's network namespace (see searxng.container), so its outbound search +# traffic exits here. The WireGuard credentials live in /opt/searxng/.env +# (mode 0600); this file holds no secrets. See docs/Dankclaw/Gluetun.md. + +[Unit] +Description=Gluetun VPN tunnel container +Wants=searxng-network.service +After=searxng-network.service network.target + +[Container] +ContainerName=gluetun +Network=searxng.network +Image=docker.io/qmcgaw/gluetun:latest +CapAdd=NET_ADMIN +Device=/dev/net/tun:/dev/net/tun +Environment=VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER} +Environment=VPN_TYPE=${VPN_TYPE} +Environment=WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY} +Environment=WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES} +Environment=WIREGUARD_PUBLIC_KEY=${WIREGUARD_PUBLIC_KEY} +Environment=WIREGUARD_ENDPOINT_IP=${WIREGUARD_ENDPOINT_IP} +Environment=WIREGUARD_ENDPOINT_PORT=${WIREGUARD_ENDPOINT_PORT} +Environment=WIREGUARD_ALLOWED_IPS=${WIREGUARD_ALLOWED_IPS} +Environment=WIREGUARD_PERSISTENT_KEEPALIVE_INTERVAL=${WIREGUARD_PERSISTENT_KEEPALIVE_INTERVAL} +PublishPort=8081:8080 + +[Service] +EnvironmentFile=/opt/searxng/.env +TimeoutStartSec=900 +Restart=always + +[Install] +WantedBy=default.target diff --git a/dankclaw/searxng/quadlet/searxng-valkey.container b/dankclaw/searxng/quadlet/searxng-valkey.container new file mode 100644 index 0000000..8d4bd6b --- /dev/null +++ b/dankclaw/searxng/quadlet/searxng-valkey.container @@ -0,0 +1,28 @@ +# Quadlet unit: Valkey cache for SearXNG (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/searxng-valkey.container +# Unit name: searxng-valkey.service (container name: searxng-valkey) +# +# The limiter's rate-limit state store. Joins the searxng network and is +# reachable by the name "valkey" (settings.yml points there). Data persists in +# /opt/searxng/valkey. See docs/Dankclaw/Valkey.md. + +[Unit] +Description=SearXNG Valkey cache container +Wants=searxng-network.service +After=searxng-network.service network.target + +[Container] +ContainerName=searxng-valkey +Network=searxng.network +NetworkAlias=valkey +Image=docker.io/valkey/valkey:9-alpine +Volume=/opt/searxng/valkey:/data + +[Service] +EnvironmentFile=/opt/searxng/.env +TimeoutStartSec=900 +Restart=always + +[Install] +WantedBy=default.target diff --git a/dankclaw/searxng/quadlet/searxng.container b/dankclaw/searxng/quadlet/searxng.container new file mode 100644 index 0000000..7e0abf9 --- /dev/null +++ b/dankclaw/searxng/quadlet/searxng.container @@ -0,0 +1,30 @@ +# Quadlet unit: SearXNG search engine (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/searxng.container +# Unit name: searxng.service (container name: searxng) +# +# Reuses gluetun's network namespace (Network=gluetun.container) so its +# outbound traffic exits the WireGuard tunnel. It publishes no port of its own; +# the port lives on gluetun. It reaches valkey by name on the searxng network. +# Config mounts from /opt/searxng/config. See docs/Dankclaw/SearXNG.md. + +[Unit] +Description=SearXNG search engine container +Wants=gluetun.service searxng-valkey.service +After=gluetun.service searxng-valkey.service network.target + +[Container] +ContainerName=searxng +Network=gluetun.container +Image=ghcr.io/searxng/searxng:latest +Environment=SEARXNG_SECRET=${SEARXNG_SECRET} +Environment=SEARXNG_BASE_URL=${SEARXNG_BASE_URL} +Volume=/opt/searxng/config:/etc/searxng + +[Service] +EnvironmentFile=/opt/searxng/.env +TimeoutStartSec=900 +Restart=always + +[Install] +WantedBy=default.target diff --git a/dankclaw/searxng/quadlet/searxng.network b/dankclaw/searxng/quadlet/searxng.network new file mode 100644 index 0000000..439a677 --- /dev/null +++ b/dankclaw/searxng/quadlet/searxng.network @@ -0,0 +1,16 @@ +# Quadlet network: private bridge for the SearXNG stack (rootless). +# +# Install path on dankclaw: ~/.config/containers/systemd/searxng.network +# Unit name: searxng-network.service (podman network name: systemd-searxng) +# +# DNS is enabled by default for quadlet networks. This matters: the automatic +# "podman" default network has dns_enabled=false, so container names would not +# resolve. searxng (which reuses gluetun's netns) reaches valkey by the name +# "valkey" on this network. +# +# The subnet is pinned so the value stays stable across reboots. It must match +# an entry in shared/searxng/limiter.toml trusted_proxies. 10.89.2.0/24 is free +# (gitea uses 10.89.0.0/24; the old compose net used 10.89.1.0/24). + +[Network] +Subnet=10.89.2.0/24 diff --git a/shared/searxng/limiter.toml b/shared/searxng/limiter.toml index 1b8ad64..338ac54 100644 --- a/shared/searxng/limiter.toml +++ b/shared/searxng/limiter.toml @@ -25,6 +25,7 @@ trusted_proxies = [ '192.168.0.0/24', '10.88.0.0/24', '10.89.1.0/24', + '10.89.2.0/24', ] [botdetection.ip_limit] diff --git a/tests/searxng_quadlet.bats b/tests/searxng_quadlet.bats new file mode 100644 index 0000000..040c7f3 --- /dev/null +++ b/tests/searxng_quadlet.bats @@ -0,0 +1,101 @@ +#!/usr/bin/env bats +# Seam: SearXNG stack boot-persistence via Quadlet. +# +# The gluetun + searxng + valkey trio runs under systemd as quadlet units so it +# survives a reboot (the podman-compose deployment did not). The crux: searxng +# must reuse gluetun's network namespace (Network=gluetun.container) so its +# outbound search traffic exits the WireGuard tunnel, while still reaching valkey +# by name on the shared searxng network. + +load helpers + +QDIR="$REPO_ROOT/dankclaw/searxng/quadlet" + +@test "all four quadlet files exist" { + [ -f "$QDIR/searxng.network" ] + [ -f "$QDIR/gluetun.container" ] + [ -f "$QDIR/searxng.container" ] + [ -f "$QDIR/searxng-valkey.container" ] +} + +@test "searxng reuses gluetun's network namespace (exits the tunnel)" { + grep -qE '^Network=gluetun\.container' "$QDIR/searxng.container" \ + || { echo "searxng.container must set Network=gluetun.container"; return 1; } +} + +@test "searxng publishes no port (the port lives on gluetun)" { + if grep -qE '^PublishPort=' "$QDIR/searxng.container"; then + echo "searxng.container must not set PublishPort (it shares gluetun's netns)" + return 1 + fi + return 0 +} + +@test "gluetun publishes SearXNG on 8081:8080" { + grep -qE '^PublishPort=8081:8080' "$QDIR/gluetun.container" \ + || { echo "gluetun.container must publish 8081:8080"; return 1; } +} + +@test "gluetun has NET_ADMIN and the /dev/net/tun device" { + grep -qE '^CapAdd=NET_ADMIN' "$QDIR/gluetun.container" \ + || { echo "gluetun.container must set CapAdd=NET_ADMIN"; return 1; } + grep -qE '^Device=/dev/net/tun' "$QDIR/gluetun.container" \ + || { echo "gluetun.container must set Device=/dev/net/tun"; return 1; } +} + +@test "valkey joins searxng.network and is reachable by the name valkey" { + grep -qE '^Network=searxng\.network' "$QDIR/searxng-valkey.container" \ + || { echo "searxng-valkey.container must set Network=searxng.network"; return 1; } + grep -qE '^NetworkAlias=valkey' "$QDIR/searxng-valkey.container" \ + || { echo "searxng-valkey.container must set NetworkAlias=valkey"; return 1; } +} + +@test "gluetun joins searxng.network so searxng (in its netns) can reach valkey" { + grep -qE '^Network=searxng\.network' "$QDIR/gluetun.container" \ + || { echo "gluetun.container must set Network=searxng.network"; return 1; } +} + +@test "searxng.network pins a subnet that limiter.toml trusts as a proxy" { + # The pinned subnet must appear in limiter.toml trusted_proxies so the value + # stays stable across reboots (rootless podman otherwise reassigns the CIDR). + local subnet + subnet=$(grep -oE '^Subnet=[0-9./:]+' "$QDIR/searxng.network" | head -1 | cut -d= -f2) + [ -n "$subnet" ] || { echo "searxng.network must pin a Subnet="; return 1; } + grep -qF "'$subnet'" "$REPO_ROOT/shared/searxng/limiter.toml" \ + || { echo "pinned subnet $subnet missing from limiter.toml trusted_proxies"; return 1; } +} + +@test "every container unit feeds \${VAR} expansion via [Service] EnvironmentFile" { + # An EnvironmentFile in [Container] only lands in the ignored [X-Container] + # section and does not feed ${VAR} expansion. It must be in [Service]. + local f + for f in gluetun searxng searxng-valkey; do + # The file must contain a [Service] section that sets EnvironmentFile=. + awk '/^\[Service\]/{s=1;next} /^\[/{s=0} s&&/EnvironmentFile=/{found=1} END{exit found?0:1}' \ + "$QDIR/$f.container" \ + || { echo "$f.container: [Service] must set EnvironmentFile="; return 1; } + done + return 0 +} + +@test "every container unit is installed for boot (WantedBy=default.target)" { + local f + for f in gluetun searxng searxng-valkey; do + grep -qE '^WantedBy=default\.target' "$QDIR/$f.container" \ + || { echo "$f.container: [Install] must set WantedBy=default.target"; return 1; } + done + return 0 +} + +@test "no secret material in the quadlet files" { + # The units reference \${VAR} placeholders, never literal keys. A WireGuard + # key is a 44-char base64 string; none may appear in the tracked files. + local f + for f in "$QDIR"/*.container "$QDIR"/*.network; do + if grep -qE '[A-Za-z0-9+/]{44}=' "$f"; then + echo "$f: contains a secret-like value" + return 1 + fi + done + has_no_leaked_secret "$QDIR/gluetun.container" +}